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

Подписанная ссылка в Nginx защищает файл от свободного доступа: в URL передаются срок действия и контрольная подпись, а сервер проверяет их до выдачи ответа. Ниже — практический чек-лист для nginx 1.25.x и 1.27.x с модулем ngx_http_secure_link_module: от проверки сборки до теста просроченной ссылки и разбора случаев, когда подпись внезапно не совпадает.
Что именно проверяет Nginx
В простом варианте Nginx получает два параметра: md5 и expires. Первый содержит URL-safe Base64-представление MD5-подписи, второй — Unix-время, до которого ссылка считается действительной.
Подпись строится из срока действия, URI и секрета. Для запроса /downloads/report.pdf с окончанием действия 1735689600 строка для подписи будет такой:
1735689600/downloads/report.pdf YOUR_SECRET
Nginx вычисляет MD5 этой строки и сравнивает результат с параметром md5. Если подпись не совпала, переменная $secure_link будет пустой. Если подпись верна, но срок прошёл, она получит значение 0. Для действительной ссылки значение — 1.
URL-safe Base64 — вариант Base64, в котором символы + и / заменены на - и _, а завершающие знаки = обычно удалены. Это позволяет передавать подпись в URL без дополнительного экранирования.
Подписанная ссылка не отменяет утечку URL. Она ограничивает срок жизни утёкшей ссылки, но не запрещает получателю скачать файл, пока ссылка действительна.
Чек-лист перед настройкой
- Проверьте, что Nginx собран с
ngx_http_secure_link_module. - Определите точный URI, который будет подписываться. В подписи должен использоваться тот же путь, что и в переменной
$uri. - Создайте длинный секрет и храните его только на сервере. Не передавайте его в приложение клиентской части и не публикуйте в репозитории.
- Выберите время жизни ссылки. Для персональных документов обычно нужен короткий срок, для публичного дистрибутива — более длинный.
- Заранее решите, какой ответ получать при неверной и просроченной подписи: чаще всего это
403и410.
Проверить наличие модуля можно так:
nginx -V 2>&1 | tr ' ' '\n' | grep secure_link
В результате должна появиться опция, содержащая --with-http_secure_link_module. Если вывода нет, текущая сборка не включает модуль. В этом случае не добавляйте директивы наугад: установите пакет или сборку Nginx, где модуль доступен, и сначала проверьте конфигурацию через nginx -t.
Базовая конфигурация Nginx
Этот вариант рассчитан на файлы, которые лежат в каталоге /srv/private-files. Имя секрета приведено как заглушка: замените его на значение из защищённого хранилища, но не вставляйте реальный ключ в статью, репозиторий или тикет.
server {
listen 443 ssl;
server_name files.example.test;
location /downloads/ {
root /srv/private-files;
secure_link $arg_md5,$arg_expires;
secure_link_md5 "$secure_link_expires$uri YOUR_SECRET";
if ($secure_link = "") {
return 403;
}
if ($secure_link = "0") {
return 410;
}
}
}
После изменения конфигурации выполните:
nginx -t && nginx -s reload
Проверка успешна, если Nginx сообщает syntax is ok и test is successful. Само наличие директив ещё ничего не доказывает: нужна ссылка с подписью, рассчитанной для конкретного URI.
Как сгенерировать ссылку
Ниже — самостоятельный скрипт на Python 3 из стандартной библиотеки. Он повторяет формулу из конфигурации, кодирует MD5 в URL-safe Base64 и добавляет параметры к адресу. Скрипт не обращается к Nginx и не проверяет наличие файла, поэтому путь и секрет должны совпадать с серверными.
#!/usr/bin/env python3
import argparse
import base64
import hashlib
import time
from urllib.parse import urlencode
def make_signature(expires: int, uri: str, secret: str) -> str:
payload = f"{expires}{uri} {secret}".encode("utf-8")
digest = hashlib.md5(payload).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("uri", help="URI, например /downloads/report.pdf")
parser.add_argument("--secret", required=True)
parser.add_argument("--ttl", type=int, default=900)
parser.add_argument("--base-url", default="https://files.example.test")
args = parser.parse_args()
expires = int(time.time()) + args.ttl
signature = make_signature(expires, args.uri, args.secret)
query = urlencode({"md5": signature, "expires": expires})
print(f"{args.base_url}{args.uri}?{query}")
if __name__ == "__main__":
main()
Пример запуска без настоящего секрета:
python3 make_link.py /downloads/report.pdf \
--secret YOUR_SECRET \
--ttl 900 \
--base-url https://files.example.test
В результате появится URL с параметрами md5 и expires. Если открыть его до истечения 900 секунд и файл существует, Nginx должен вернуть файл. После истечения срока — ответ 410 из примера выше.
Где чаще всего ломается проверка
1. Подписывается не тот путь
Самая частая ошибка — генератор подписывает /report.pdf, а запрос приходит на /downloads/report.pdf. В формуле должен быть полный URI, который видит Nginx в $uri. Если приложение подписывает URL с процентным кодированием, пробелами или несколькими слешами, отдельно проверьте нормализацию пути.
2. Перепутан порядок частей строки
Строка expires + uri + пробел + secret должна совпадать буквально. Добавленный перевод строки, другой разделитель или пробел перед секретом дадут другой MD5. Формула в приложении и директива secure_link_md5 должны меняться вместе.
3. Base64 закодирован не в том формате
Шестнадцатеричный MD5 вроде 9e107d9d372bb6826bd81d3542a419d6 — это не тот формат, который ожидает типичная конфигурация с URL-safe Base64. Используйте сырые 16 байт MD5, затем Base64 без символов заполнения =.
4. Время взято в миллисекундах
Nginx сравнивает срок с Unix-временем в секундах. Значение из JavaScript Date.now() содержит миллисекунды и напрямую не подходит. Приведите его к секундам до формирования подписи.
5. Конфигурация загружена не тем процессом
После изменения файла проверьте не только синтаксис, но и активную конфигурацию на нужном сервере. При контейнерах и нескольких инстансах запрос может попасть на узел со старым секретом или старой формулой. В логах временно полезно видеть URI и код ответа, но не сам секрет и не полную подписанную ссылку.
Два подхода к сроку действия
В модуле есть два распространённых сценария. Первый — проверять срок и подпись в одном URL, как в примере. Второй — использовать подпись без параметра времени, когда срок действия не нужен или контролируется другим слоем. Для закрытых файлов первый вариант обычно безопаснее: ссылка автоматически перестаёт работать.
| Подход | Что проверяет Nginx | Плюс | Цена и риск |
|---|---|---|---|
Подпись плюс expires | Подпись и срок действия | Утёкшая ссылка живёт ограниченное время | Нужно синхронизировать часы и формулу генерации |
| Подпись без срока | Только подпись | Проще генератор и тестирование | Утёкшая ссылка действует, пока секрет не сменят |
| Проверка в приложении | Права пользователя, состояние сессии и бизнес-правила | Можно отзывать доступ по пользователю и событию | Запрос проходит через приложение и расходует его ресурсы |
Выбирайте встроенную проверку Nginx, если нужно быстро и дёшево закрыть крупные файлы, а права уже определены в момент выдачи ссылки. Оставляйте проверку в приложении, если доступ зависит от роли, оплаты, отзыва документа или разового разрешения. Эти подходы можно сочетать: приложение выдаёт короткую ссылку, Nginx проверяет её без загрузки файла через PHP или другой backend.
Граничные случаи и цена решения
Синхронизация времени важна на всех узлах, которые генерируют ссылки. Если часы приложения отстают, ссылка может уже считаться просроченной; если спешат, срок жизни будет короче, чем ожидалось. Проверьте NTP и используйте небольшой запас только осознанно.
Смена секрета немедленно делает недействительными все старые ссылки. Это удобно после утечки, но означает массовый отзыв доступа. Если требуется плавная ротация, одной простой директивы недостаточно: потребуется отдельная схема с двумя секретами или проверкой в приложении.
Кэш тоже влияет на поведение. Кэшированный ответ может отдаваться после истечения ссылки, если промежуточный прокси настроен неправильно. Для закрытых файлов отдельно проверьте правила CDN, браузерный Cache-Control и возможность повторного использования URL. Инвалидация кэша — отдельная тема, здесь важен сам факт: подпись не управляет уже сохранённой копией ответа.
Что проверить в проде дальше
- Создайте действительную ссылку и проверьте ответ через обычный клиент.
- Измените один символ в
md5и убедитесь, что сервер отвечает403. - Сделайте ссылку с прошлым временем и проверьте ответ
410. - Проверьте URI с вложенными каталогами, дефисами и расширениями, которые реально используются.
- Сравните системное время генератора ссылок и Nginx на всех узлах.
- Посмотрите в мониторинге долю ответов
403и410, а также размер и время выдачи файлов.
После этого отдельно настройте ротацию секрета, политику кэширования и ограничение скорости скачивания. Если ссылка должна быть привязана к пользователю, устройству или факту оплаты, не пытайтесь выразить эти правила одной MD5-строкой: перед выдачей ссылки проверяйте их в приложении.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Официальная документация Nginx: ngx_http_secure_link_module, Официальная документация Nginx: ngx_http_core_module
Частые вопросы
Можно ли использовать подписанные ссылки без отдельного модуля Nginx?
Почему Nginx возвращает 403, хотя ссылка только что создана?
Что произойдёт, если ссылка просрочена?
Можно ли отозвать одну ссылку, не меняя общий секрет?
Насколько безопасен MD5 в этой схеме?
Сработает ли эта конфигурация за CDN или reverse proxy?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.