Что такое hotlink и почему «просто запретить» не всегда работает
Hotlink (хотлинк) — это когда чужой сайт вставляет ваши изображения или файлы по прямому URL, а трафик, CPU и дисковый I/O оплачиваете вы. В логах это выглядит как обычный запрос к .jpg/.png/.mp4, но источник часто палится заголовком Referer (или его отсутствием).
Типовой ущерб от хотлинка:
- рост исходящего трафика и упирание в лимиты;
- всплески RPS по статике, очереди на диске, рост времени ответа;
- выгорание кешей (особенно на крупных «редких» файлах);
- репутационные проблемы (ваш контент показывают на сомнительных площадках).
Самый частый путь защиты — проверка Referer (в Nginx это делается директивой valid_referers). Но важно понимать: Referer необязателен и легко подделывается клиентом. Поэтому правила по рефереру — это «срезать массовый хотлинк и снизить шум», а не криптографическая гарантия доступа.
Быстрый чек-лист перед настройкой защиты
Перед тем как резать запросы, проверьте, какие легитимные сценарии вы можете случайно сломать:
- Мессенджеры, агрегаторы, почтовики часто ходят без
Refererили с «обрезанным» реферером. - Privacy-режимы браузеров и политика
Referrer-Policyмогут отправлять только origin или пустой заголовок. - CDN/прокси/кеши иногда меняют набор заголовков и нюансы запроса.
- Поддомены:
cdn.example.comиwww.example.com— разные хосты, их нужно учитывать явно. - Прямые скачивания (когда вы сами даёте URL) по определению часто будут «без реферера».
Практика: начинайте с режима «наблюдения» — логируйте подозрительные рефереры, но не блокируйте. Через 1–3 дня у вас будет список легитимных исключений.

Nginx hotlink protection через valid_referers: базовый рабочий вариант
В Nginx проверка referer делается директивой valid_referers. Она выставляет встроенную переменную $invalid_referer: если реферер не прошёл проверку — переменная будет непустой (обычно 1).
Пример: защищаем изображения и отдаём 403 всем, кто не пришёл с наших доменов. При этом разрешаем пустой referer (иначе вы сломаете часть прямых открытий и превью).
server {
listen 80;
server_name example.com www.example.com;
location ~* \.(jpg|jpeg|png|gif|webp|avif)$ {
valid_referers none blocked example.com *.example.com;
if ($invalid_referer) {
return 403;
}
expires 30d;
add_header Cache-Control "public";
}
}
Что здесь важно:
none— разрешает запросы без заголовкаReferer.blocked— разрешает «скрытый» referer (когда заголовок есть, но пустой/обрезанный в некоторых режимах).example.com *.example.com— разрешаем основной домен и поддомены.- Регулярка в
location— только статика по расширениям, а не весь сайт.
Если вы держите сайты на виртуальном хостинге, такой фильтр часто даёт быстрый эффект: меньше лишнего трафика и меньше нагрузки на дисковую подсистему без сложных доработок приложения.
Вместо 403: редирект на «заглушку»
Иногда удобнее не запрещать, а подменять картинку (например, «hotlinking is not allowed»). Тогда вместо 403 делаем редирект на локальный файл.
location ~* \.(jpg|jpeg|png|gif|webp|avif)$ {
valid_referers none blocked example.com *.example.com;
if ($invalid_referer) {
return 302 /hotlink-placeholder.png;
}
}
location = /hotlink-placeholder.png {
expires 5m;
add_header Cache-Control "public";
}
Замечание: «заглушка» тоже станет популярным объектом. Дайте ей отдельный короткий кеш и следите за RPS.
Несколько доменов и окружений (prod + staging)
Если у вас отдельные домены под маркетинг/документацию/статику, перечисляйте их явно. Пример для продакшена и стейджинга:
valid_referers none blocked example.com *.example.com example.net *.example.net staging.example.com;
Типичные проблемы valid_referers: почему хотлинк всё равно бывает
Проверка referer — это фильтр, а не «замок». Вот что обычно обходят или ломают такие правила:
- Подмена заголовка: любой скрипт/бот может отправить нужный
Referer. - Отсутствие referer: если вы запрещаете
none, вы режете часть законных клиентов (превью, приложения, некоторые браузеры). - Ложные срабатывания при агрессивной
Referrer-Policy: реферер бывает только origin без path — это нормально. - Кэширование: если вы отдаёте заглушку или
403и это попало в промежуточный кеш, можно поймать «плавающие» эффекты у реальных пользователей.
Если вам нужно именно ограничение доступа к файлу (а не просто защита от массовых встраиваний), используйте токены/подписи: заголовок Referer не предназначен для контроля доступа.
Отдельно проверьте, не теряете ли вы реферер на редиректах и миграциях. При переезде домена и смене протокола полезно держать под рукой план и контрольные точки из материала про миграцию сайта без простоя.
Усиление: signed cookies в Nginx для реального контроля доступа к статике
Когда задача — выдавать статику только тем, кто «получил право» (после авторизации/оплаты/выдачи временного доступа), лучше использовать подпись. В экосистеме Nginx чаще всего встречаются:
- подпись URL (secure link) — токен в query string;
- signed cookies — токен в cookie (обычно удобнее для браузера и медиаплееров, меньше светится в URL и логах).
Ниже — пример логики с использованием механизма secure link, где подпись и срок жизни передаются в cookie. Nginx проверяет подпись и TTL и только после этого отдаёт файл из защищённой директории.

Схема signed cookies (общая логика)
- Пользователь обращается к приложению (backend) и проходит проверку (логин, право доступа, лимиты).
- Backend выставляет cookies:
sl_sig(подпись) иsl_exp(время истечения). - Пользователь запрашивает файл
/protected/file.mp4. - Nginx проверяет, что cookies валидны для этого URI и времени.
- Если валидны — отдаёт файл; если нет —
403или410.
Конфигурация Nginx: проверка подписи из cookie
В этом примере подпись считается как MD5 от «expires + uri + secret», затем кодируется в URL-safe Base64. Это распространённый формат для secure_link. Секрет храните на сервере (не в репозитории) и ротируйте.
server {
listen 80;
server_name example.com;
location /protected/ {
secure_link $cookie_sl_sig,$cookie_sl_exp;
secure_link_md5 "$secure_link_expires$uri my_secret_string";
if ($secure_link = "") {
return 403;
}
if ($secure_link = "0") {
return 410;
}
expires 10m;
add_header Cache-Control "private";
try_files $uri =404;
}
}
Как это читать:
secure_linkполучает пару значений «подпись, expires» из cookies.secure_link_md5задаёт, как вычислять ожидаемую подпись.$secure_link = ""— подпись не совпала или параметров нет.$secure_link = "0"— подпись корректная, но TTL истёк (удобно возвращать410 Gone).
Как backend должен выставить signed cookies (псевдологика)
Nginx сам cookies не «подписывает» — это делает ваше приложение. Алгоритм должен совпадать с secure_link_md5. Упрощённо:
- Берёте
expires(unix timestamp), например «текущее время + 600 секунд». - Берёте
uri, например/protected/video.mp4. - Считаете MD5 от строки
expires + uri + secret. - Кодируете результат в Base64 URL-safe без
=(как ожидает Nginx secure_link). - Ставите cookies
sl_sigиsl_expна ваш домен и нужный path (/protected/).
Критичные детали, на которых чаще всего «сыпется» внедрение:
- URI должен совпадать побайтно с тем, что видит Nginx: регистр, слеши, кодирование.
- Если у вас есть
rewriteили нормализация URL, заранее решите, что именно подписываете: исходный запрос или конечный$uri. - При раздаче через CDN часто проще подписывать URL, а не cookie: не каждый CDN/прокси корректно «протащит» cookies до origin.
Для таких сценариев удобнее держать статику на отдельном Nginx-инстансе и масштабировать его независимо от приложения — обычно это проще сделать на VDS, чем на общем окружении.
Комбинация подходов: referer как «шумодав», cookies как контроль доступа
На практике хорошо работает двухслойная модель:
- valid_referers — на публичных ассетах, где важно отсечь массовый хотлинк и снизить трафик (часто с
none/blocked). - signed cookies — на приватной раздаче (платный контент, личные файлы, временный доступ), где нужна реальная авторизация на уровне Nginx.
Например, для /assets/ оставляете мягкую защиту по referer, а для /protected/ включаете строгую проверку подписи и запрет раздачи без cookie.
Отладка: как понять, почему запрос заблокирован
Для hotlink-защиты диагностика важнее «идеального правила». Удобный приём — отдельный лог на защищаемую локацию и формат с нужными полями.
log_format hotlink '$remote_addr $host "$request" $status ref="$http_referer" invref="$invalid_referer" sig="$cookie_sl_sig" exp="$cookie_sl_exp" ua="$http_user_agent"';
server {
location ~* \.(jpg|jpeg|png|gif|webp|avif)$ {
access_log /var/log/nginx/hotlink.log hotlink;
valid_referers none blocked example.com *.example.com;
if ($invalid_referer) { return 403; }
}
location /protected/ {
access_log /var/log/nginx/protected.log hotlink;
secure_link $cookie_sl_sig,$cookie_sl_exp;
secure_link_md5 "$secure_link_expires$uri my_secret_string";
if ($secure_link = "") { return 403; }
if ($secure_link = "0") { return 410; }
}
}
Что смотреть в логах:
- реальный
ref(мобильные приложения и превью часто удивляют); invref— отрабатывает лиvalid_referersкак вы ожидаете;- наличие cookie
sl_sig/sl_expна запросах к/protected/; - цепочки редиректов: иногда реферер теряется именно на переходе.
Рекомендации по безопасности и эксплуатации
Про if внутри location
В примерах используется if внутри location осознанно: сценарий с return относится к безопасным и понятным. Если хотите полностью уйти от if, делайте через map и проверку переменной, но для защиты от хотлинка это часто лишняя сложность.
Ротация секретов для signed cookies
Секрет в secure_link_md5 стоит периодически менять. Минимально практичный подход — поддерживать «старый» и «новый» секрет на период миграции (например, 1–2 TTL), чтобы не выбивать активные сессии. Важно заранее спланировать окно ротации и критерии успешного перехода по логам.
Коды ответа: 403 vs 404 vs 410
403— «нет прав», удобно для диагностики.404— скрывает факт существования файла (иногда полезно против сканирования).410— отличный сигнал «ссылка/доступ были валидными, но истекли» для подписей с TTL.
Готовые минимальные рецепты
Рецепт 1: простая защита картинок по referer
location ~* \.(jpg|jpeg|png|gif|webp|avif)$ {
valid_referers none blocked example.com *.example.com;
if ($invalid_referer) { return 403; }
expires 30d;
}
Рецепт 2: строгая раздача /protected/ только по signed cookies
location /protected/ {
secure_link $cookie_sl_sig,$cookie_sl_exp;
secure_link_md5 "$secure_link_expires$uri my_secret_string";
if ($secure_link = "") { return 404; }
if ($secure_link = "0") { return 410; }
add_header Cache-Control "private";
try_files $uri =404;
}
Итог
Если нужно быстро прикрыть утечки трафика от массового хотлинка — начинайте с valid_referers и аккуратно решите, разрешать ли none/blocked. Это дешёвый и понятный фильтр.
Если требуется контроль доступа «по-настоящему» (TTL, привязка к URI, управляемость на стороне приложения) — используйте signed cookies (или подпись URL) и проверяйте подпись на уровне Nginx. Такой подход лучше переживает подмену заголовков и даёт предсказуемый доступ к защищённой статике.


