Новинка Виртуальный VDS сервер в Нидерландах от 390р
Выберите продукт

Защищённая выдача файлов: 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-сервер
Виртуальные серверы с быстрым запуском и гибкой конфигурацией от 390₽ / мес
Доступные локации
Россия Нидерланды

Интеграция с приложением через 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 — и вы получите безопасную и предсказуемую систему выдачи загрузок.

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

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

Как безопасно выполнить bootstrap MariaDB Galera после полного останова

Как безопасно выполнить bootstrap MariaDB Galera после полного останова

После одновременной остановки всех узлов Galera обычного запуска MariaDB недостаточно. Сначала найдите сервер с последней транзакц ...
CoreDNS в Kubernetes: исправляем CrashLoopBackOff, SERVFAIL и DNS-петлю

CoreDNS в Kubernetes: исправляем CrashLoopBackOff, SERVFAIL и DNS-петлю

Разберём, почему CoreDNS перезапускается или отвечает SERVFAIL, как обнаружить замкнутую пересылку DNS-запросов, проверить доступн ...
Apache Kafka KRaft на VDS: отдельные controller и broker

Apache Kafka KRaft на VDS: отдельные controller и broker

Соберём новый Kafka-кластер без ZooKeeper на шести Linux VDS: три узла controller образуют отказоустойчивый metadata quorum, а три ...