Когда нужно отдать пользователю файл, но нельзя просто открыть прямую ссылку, на помощь приходит модуль 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.
Генерация подписи на стороне приложения
Формула должна совпадать с тем, что вы передаёте в secure_link_md5
. В примере выше это строка "$secure_link_expires$uri very-strong-secret"
. Здесь $uri
— ровно тот путь, который придёт к Nginx: без домена, без аргументов, с ведущим слешем.
Порядок действий:
- Определить срок истечения:
expires = now + ttl_seconds
. - Собрать строку:
data = expires + uri + secret
. - Вычислить MD5 от
data
в бинарном виде. - Закодировать в base64url без символа
=
на конце. - Сформировать ссылку с параметрами
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";
}

Интеграция с приложением через 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/;
}
Такой подход позволяет хранить одну копию контента в кэше, при этом проверка подписи происходит до отдачи. Но важно: если у вас нужно выборочно отзывать доступ отдельным пользователям, дедупликация кэша делает это невозможным без смены пути файла — токен перестаёт быть элементом кэширующего ключа.
Согласование 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 — и вы получите безопасную и предсказуемую систему выдачи загрузок.