Top.Mail.Ru
OSEN-НИЙ SAAALEСкидка 50% на виртуальный хостинг и VDS
до 30.11.2025 Подробнее
Выберите продукт

Защищённая выдача файлов: Nginx secure_link, сроки жизни ссылок и кэш

Разбираем защищённую выдачу файлов через Nginx secure_link: форматы URL и подписи, контроль TTL и коды 403/410, X-Accel-Redirect, кэш и CDN, ротация секретов. Дадим готовые конфиги, примеры генерации токена и чек-лист для продакшена.
Защищённая выдача файлов: Nginx secure_link, сроки жизни ссылок и кэш

Когда нужно отдать пользователю файл, но нельзя просто открыть прямую ссылку, на помощь приходит модуль Nginx secure_link. Он позволяет проверять подпись (и срок жизни) запроса ещё до отдачи данных. Это удобно для приватных загрузок, платного контента, одноразовых ссылок, выгрузок резервных копий и любых сценариев, где безопасность и контроль важнее простоты.

Как работает secure_link в Nginx

Модуль проверяет подпись, которая приходит в запросе, и, при необходимости, срок жизни ссылки. Самый гибкий вариант — использовать параметры запроса: один для подписи, второй для времени истечения. Схема такова: приложение генерирует URL вида /dl/path/to/file?md5=...&expires=..., где expires — это метка времени (UNIX timestamp) в секундах, а md5 — подпись, рассчитанная по формуле. На стороне Nginx мы сопоставляем эти параметры с директивами secure_link и secure_link_md5.

Важные детали:

  • secure_link_md5 вычисляет MD5 от заданной строки и сравнивает с пришедшим значением. Ожидается base64url-кодирование без паддинга.
  • Переменная $secure_link_expires получает число из второго аргумента secure_link и используется в формуле подписи и проверке срока.
  • $secure_link принимает значения: пустая строка — подпись неверна; 0 — истек срок; 1 — проверка пройдена.

Базовая конфигурация Nginx

Ниже — минимальный рабочий пример для защиты каталога загрузок. Ссылка выглядит так: /dl/any/relative/path?md5=TOKEN&expires=TIMESTAMP.

http {
    # ... другие настройки
    # секрет для подписи держим вне репозитория, например в отдельном файле
    # здесь показано прямое встраивание для простоты примера

    server {
        listen 80;
        server_name example.local;

        # логирование без query string, чтобы не светить токены в логах
        log_format clean '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" $status $body_bytes_sent "$http_referer" "$http_user_agent"';
        access_log /var/log/nginx/access.log clean;

        # корень сайта
        root /var/www/site;

        # защищённые загрузки
        location /dl/ {
            # 1) указываем, из каких аргументов брать подпись и срок
            secure_link $arg_md5,$arg_expires;

            # 2) формула подписи: expires + uri + secret
            # обратите внимание: используется $uri, а не $request_uri
            secure_link_md5 "$secure_link_expires$uri very-strong-secret";

            # 3) обработка ошибок проверки
            if ($secure_link = "") {
                return 403;  # подпись невалидна
            }
            if ($secure_link = "0") {
                return 410;  # истек срок жизни ссылки
            }

            # 4) если проверка пройдена — отдаём файл из файловой системы
            # важно: используйте alias, если /dl не совпадает с реальным путем
            alias /srv/downloads/;
            # пример: запрос /dl/reports/q1.pdf отдаст /srv/downloads/reports/q1.pdf

            # производительность
            sendfile on;
            tcp_nopush on;
            aio threads;
        }

        # страницы ошибок можно оформить отдельно
        error_page 403 /errors/403.html;
        error_page 410 /errors/410.html;
        location /errors/ {
            internal;
        }
    }
}

Зачем alias

alias разграничивает публичный URL и реальный путь к файлам. Пользователь никогда не увидит, где на диске лежит контент. Убедитесь, что соответствие корректно: /dl/xxx/srv/downloads/xxx.

Если вы хотите полный контроль над конфигом Nginx и модулями, используйте VDS. Для работы через панель посмотрите наш сравнительный обзор панелей для VDS.

Пример конфигурации Nginx: secure_link, параметры md5 и expires, и директива alias

Генерация подписи на стороне приложения

Формула должна совпадать с тем, что вы передаёте в secure_link_md5. В примере выше это строка "$secure_link_expires$uri very-strong-secret". Здесь $uri — ровно тот путь, который придёт к Nginx: без домена, без аргументов, с ведущим слешем.

Порядок действий:

  1. Определить срок истечения: expires = now + ttl_seconds.
  2. Собрать строку: data = expires + uri + secret.
  3. Вычислить MD5 от data в бинарном виде.
  4. Закодировать в base64url без символа = на конце.
  5. Сформировать ссылку с параметрами md5 и expires.

Примеры генерации

Shell (проверка формулы на лету):

# переменные для примера
URI="/dl/reports/q1.pdf"
EXPIRES=1730000000
SECRET="very-strong-secret"

# вычислить md5(data) → base64 → base64url (без =)
echo -n "${EXPIRES}${URI} ${SECRET}" | openssl dgst -md5 -binary | openssl base64 | tr '+/' '-_' | tr -d '='

PHP:

<?php
$uri = "/dl/reports/q1.pdf";
$expires = 1730000000; // time() + 3600
$secret = "very-strong-secret";

$data = $expires . $uri . " " . $secret;
$md5bin = md5($data, true);
$md5b64 = rtrim(strtr(base64_encode($md5bin), "+/", "-_"), "=");
$url = $uri . "?expires=" . $expires . "&md5=" . $md5b64;
echo $url;

Python:

import base64, hashlib, time
uri = "/dl/reports/q1.pdf"
expires = int(time.time()) + 3600
secret = "very-strong-secret"

payload = f"{expires}{uri} {secret}".encode()
md5bin = hashlib.md5(payload).digest()
md5b64url = base64.urlsafe_b64encode(md5bin).decode().rstrip('=')
url = f"{uri}?expires={expires}&md5={md5b64url}"
print(url)

Node.js:

const crypto = require('crypto');
const uri = '/dl/reports/q1.pdf';
const expires = Math.floor(Date.now() / 1000) + 3600;
const secret = 'very-strong-secret';

const data = `${expires}${uri} ${secret}`;
const md5 = crypto.createHash('md5').update(data).digest();
const md5b64 = md5.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const url = `${uri}?expires=${expires}&md5=${md5b64}`;
console.log(url);

TTL: как выбрать срок жизни ссылки

Чем короче TTL, тем меньше окно риска при утечке URL. Но слишком короткий TTL ломает возобновление загрузки и доставку больших файлов. Руководствуйтесь:

  • Учитывайте реальный размер файлов и минимальную скорость клиентов. При 1 Гбайт и 5 Мбит/с загрузка займет около 30 минут — TTL должен быть запасом больше, скажем 1–2 часа.
  • Если выдаёте одноразовые ссылки «скачать за 10 минут», убедитесь, что клиенту разрешён докачиваемый диапазон и общий срок хватает на полную загрузку.
  • TTL должен быть меньше или равен любому внешнему кэшу (подробнее в блоке про кэш).

Коды ответов при просрочке и ошибке подписи

Рекомендуется возвращать 403 при неверной подписи и 410 при истекшем сроке. Это помогает аналитике и клиентской логике. Также полезно отдать заголовок Cache-Control: no-store для этих ошибок, чтобы страницы с ошибкой не кэшировались промежуточными прокси.

location = /errors/403.html {
    add_header Cache-Control "no-store";
}
location = /errors/410.html {
    add_header Cache-Control "no-store";
}
FastFox VDS
Облачный VDS-сервер в России
Аренда виртуальных серверов с моментальным развертыванием инфраструктуры от 195₽ / мес

Интеграция с приложением через X-Accel-Redirect

Если файлы лежат вне веб-корня или доступ к ним должен решать бэкенд, можно использовать внутренние локации и заголовок X-Accel-Redirect. Проверку secure_link делаем в Nginx, а вот маппинг идентификатора к реальному пути отдаёт бэкенд.

location /dl/ {
    secure_link $arg_md5,$arg_expires;
    secure_link_md5 "$secure_link_expires$uri very-strong-secret";
    if ($secure_link = "") { return 403; }
    if ($secure_link = "0") { return 410; }

    internal;
}

# Пример: приложение отвечает 200 и даёт заголовок
# X-Accel-Redirect: /protected/path/on/disk
location /protected/ {
    internal;
    alias /srv/downloads/;
    sendfile on;
}

Плюсы: приложение контролирует, какой именно путь отдать; минусы: нужна дополнительная логика и аккуратная валидация.

Кэширование: как не превратить безопасность в мираж

Самая частая ошибка — забыть, что кэши (включая обратные прокси и CDN) принимают решение по URL, не зная про истечение подписи на бэкенде. Если вы кэшируете защищённые загрузки где-то перед Nginx, нарушите инвариант: просроченная ссылка может продолжать работать из кэша, пока не истечёт кэш, а не сама ссылка.

Базовые правила

  • Если ссылка персональная и одноразовая — полностью выключайте кэш: Cache-Control: private, no-store.
  • Если контент общий, а подпись защищает только ссылку, то кэш возможен, но TTL кэша обязан быть не больше TTL ссылки. Используйте s-maxage для общих прокси и короткие значения.
  • Не кладите токены в заголовки кэширующего ключа. Если хотите дедупликацию кэша, уберите токен из ключа кэша на вашей стороне осознанно и храните единую копию по $uri без параметров.

Дедупликация кэша без потери безопасности

Часто хочется, чтобы все подписанные ссылки на один и тот же файл не создавали тысячи объектов в кэше. Это реально, если проверка подписи не зависит от query string в точке, где формируется ключ кэша.

Один из вариантов — вынести токен в путь:

location ~ ^/dl/(?<token>[A-Za-z0-9_-]{22})/(?<exp>\d+)(?<path>/.*)$ {
    set $md5 $token;
    set $expires $exp;
    set $clean_path $path;

    secure_link $md5,$expires;
    secure_link_md5 "$secure_link_expires$clean_path very-strong-secret";

    if ($secure_link = "") { return 403; }
    if ($secure_link = "0") { return 410; }

    # ключ кэша без токена
    proxy_cache my_cache;
    proxy_cache_key $clean_path;

    # внутренняя выдача статики либо проксирование на бэкенд
    alias /srv/downloads/;
}

Такой подход позволяет хранить одну копию контента в кэше, при этом проверка подписи происходит до отдачи. Но важно: если у вас нужно выборочно отзывать доступ отдельным пользователям, дедупликация кэша делает это невозможным без смены пути файла — токен перестаёт быть элементом кэширующего ключа.

Поток кэширования подписанных ссылок: Nginx, key без токена и CDN

Согласование TTL ссылки и TTL кэша

Если используете кэш, выбирайте срок его жизни не дольше, чем живёт ссылка. Проще всего выставлять краткие Cache-Control: public, max-age=60 и TTL ссылок 60–120 секунд — клиенты будут получать кэш с короткой валидностью, а просроченные ссылки не переживут собственный срок. Для приватных одноразовых ссылок — только no-store.

При проксировании через Nginx можно применять proxy_cache_valid и proxy_cache_min_uses. Динамическую привязку TTL к expires из ссылки сделать сложно: кэш живёт по своим правилам, поэтому лучше держать его коротким.

Производительность и устойчивость больших загрузок

  • sendfile/aio: включите для отдачи с диска без лишней копировки.
  • Ranges: поддержка возобновления критична при обрывах. Проверьте, что заголовки Accept-Ranges и корректные 206 отдаются.
  • limit_rate/limit_rate_after: если боитесь забить канал одиночной загрузкой, ограничьте скорость после «прогрева» первых мегабайт.
  • open_file_cache: поможет на частых обращениях к одинаковым файловым дескрипторам.

Ротация секрета и многоключевая схема

Секрет должен быть длинным и периодически ротироваться. Чтобы не ломать уже выданные ссылки, используйте два и более ключей: кодируйте идентификатор ключа в ссылку и выбирайте секрет через map.

map $arg_kid $dl_secret {
    default            old-secret;
    v2                 new-secret;
}

location /dl/ {
    secure_link $arg_md5,$arg_expires;
    secure_link_md5 "$secure_link_expires$uri $dl_secret";
    if ($secure_link = "") { return 403; }
    if ($secure_link = "0") { return 410; }
    alias /srv/downloads/;
}

Приложение сначала выдаёт ссылки с kid=v2, а старые продолжают работать до естественного истечения.

Безопасность на практике: чек-лист

  • Не логируйте токены: используйте $uri вместо $request_uri в формате логов.
  • Не подпускайте путь от пользователя напрямую в файловую систему. Держите жёсткий префикс и alias.
  • Не используйте слабые секреты. Ротируйте ключи.
  • Выдавайте no-store для 403/410.
  • Согласовывайте TTL ссылки и кэша. При сомнениях — кэш отключить.
  • Учитывайте время загрузки больших файлов при выборе TTL.
  • Проверяйте, что формула подписи в приложении строго совпадает с Nginx (пробелы, порядок, регистр).

Отладка и типовые ошибки

Классические симптомы и решения:

  • 403 при валидной на вид ссылке: чаще всего ошибка в формуле. Проверьте, что вы используете $uri, а не $request_uri; нет лишних пробелов; совпадает кодирование (base64url, без =).
  • 410 неожиданно рано: клиент скачивает дольше, чем TTL. Увеличьте TTL или запретите возобновление (обычно не нужно).
  • Работает из кэша после истечения: внешний кэш живёт дольше ссылки. Уменьшайте max-age или отключайте кэш.
  • Не совпадает файл: проверьте alias и сопоставление пути. Не позволяйте .. и прочим обходам; используйте фиксированный префикс.

Альтернативный режим secure_link_secret

Модуль поддерживает упрощённый режим с директивой secure_link_secret, когда подпись строится только по $uri и секрету. Он удобен, но не даёт встроенного TTL — срок жизни придётся контролировать самостоятельно (например, в приложении и через переотдачу URL). Для большинства задач лучше использовать связку secure_link + secure_link_md5 с явным expires.

Резюме

Nginx secure_link даёт простой и быстрый механизм защищённых ссылок без утяжеления бэкенда. Ключевые моменты успеха — единая формула подписи, правильный выбор TTL, согласованный с кэшем, и аккуратная интеграция с файловой системой через alias или X-Accel-Redirect. Добавьте ротацию секретов, логирование без токенов, корректные коды 403/410 — и вы получите безопасную и предсказуемую систему выдачи загрузок.

Поделиться статьей

Вам будет интересно

rsync для деплоев и бэкапов: быстрые ключи, инкрементальность и контроль прав OpenAI Статья написана AI Fastfox

rsync для деплоев и бэкапов: быстрые ключи, инкрементальность и контроль прав

rsync остаётся базовым инструментом для деплоя и резервного копирования. В статье — проверенные пресеты, инкрементальные бэкапы че ...
php-fpm status и ping: включаем, мониторим и ищем узкие места OpenAI Статья написана AI Fastfox

php-fpm status и ping: включаем, мониторим и ищем узкие места

Разделы status и ping в php-fpm — быстрый способ понять здоровье пула и найти узкие места под нагрузкой. Покажу, как включить и за ...
UFW на практике: быстрые правила для веб‑сервера, SSH и rate‑limit OpenAI Статья написана AI Fastfox

UFW на практике: быстрые правила для веб‑сервера, SSH и rate‑limit

UFW позволяет быстро закрыть лишние порты, оставить доступ к SSH и сайту и включить защиту от перебора. В статье — безопасный запу ...