Skip to content
EN
← Все статьи

HTTP-заголовки: полный разбор заголовков запроса и ответа сервера

Коротко. HTTP-заголовки — это пары «имя: значение», которые клиент и сервер передают перед телом сообщения. Они делятся на четыре группы: заголовки запроса, заголовки ответа, общие и заголовки представления. Через них управляют кэшированием (Cache-Control, ETag), типом и кодировкой контента (Content-Type), сжатием (Content-Encoding, Vary), редиректами (Location) и безопасностью. Посмотреть заголовки сайта можно командой curl -sI, на вкладке Network в DevTools или онлайн-проверкой HTTP-заголовков.

Как устроен HTTP-обмен и где в нём заголовки

Любое HTTP-сообщение состоит из трёх частей: стартовая строка, блок заголовков и тело. Стартовая строка запроса содержит метод, путь и версию протокола; стартовая строка ответа — версию, числовой код и текстовую расшифровку. Дальше идут заголовки, по одному на строку, затем пустая строка, затем тело. В HTTP/1.1 разделителем строк служит именно пара CR+LF, а не одиночный перевод строки: сервер, который отвечает через \n, ломает строгие клиенты и прокси.

GET /api/data HTTP/1.1
Host: example.com
Accept: application/json
Accept-Encoding: gzip, br
Authorization: Bearer eyJhbGci...
User-Agent: Mozilla/5.0
If-None-Match: "9f2c-6413ab21"

Сервер отвечает своим набором заголовков. Пустая строка после них — граница, за которой начинается тело:

HTTP/1.1 200 OK
Date: Mon, 03 Feb 2026 09:14:22 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 3842
Content-Encoding: gzip
Vary: Accept-Encoding
Cache-Control: private, max-age=0, no-cache
ETag: "9f2c-6413ab21"
X-Request-Id: 4f1a9c2e-7b30-4d55-9a11-2c8f0e6d1b47

{"items":[...]}

Имена заголовков в HTTP/1.1 регистронезависимы: content-type, Content-Type и CONTENT-TYPE — один и тот же заголовок. Порядок значения не имеет. Если один заголовок встречается несколько раз, получатель имеет право склеить значения через запятую — с единственным практическим исключением Set-Cookie, который всегда обрабатывают как список отдельных строк.

Что меняется в HTTP/2 и HTTP/3

Текстовых заголовков там нет. HTTP/2 сжимает их алгоритмом HPACK, HTTP/3 — QPACK; на проводе это бинарные индексы в таблице, а не строки. Имена обязаны быть в нижнем регистре — заголовок Content-Type, отправленный приложением с большой буквы, библиотека приведёт к content-type сама, а вот бэкенд, который сравнивает имена побуквенно, на этом ломается. Стартовая строка распадается на псевдозаголовки :method, :scheme, :authority, :path в запросе и :status в ответе. Привычный Host в HTTP/2 заменён на :authority, и если приложение читает только Host, за прокси оно получит пустую строку. Заголовок Transfer-Encoding в HTTP/2 и HTTP/3 запрещён: обрамление тела делает сам протокол.

Заголовки не бесплатны. nginx по умолчанию держит на весь блок заголовков буфер порядка 8 КБ (client_header_buffer_size плюс large_client_header_buffers). Разросшийся набор cookie или гигантский Authorization легко пробивает лимит — и вместо приложения клиент получает 400 Bad Request или 431 Request Header Fields Too Large, причём в логе приложения об этом не будет ни строчки, потому что запрос до него не дошёл.

Четыре группы заголовков: запроса, ответа, общие и представления

Классификация из RFC 9110 практичнее привычного деления «запрос/ответ», потому что часть заголовков живёт в обоих направлениях.

  • Заголовки запроса описывают клиента и его пожелания к ответу: Host, User-Agent, Accept, Accept-Language, Accept-Encoding, Authorization, Cookie, Referer, Origin, Range, условные If-None-Match и If-Modified-Since.
  • Заголовки ответа описывают сервер и то, как обращаться с ответом: Server, Set-Cookie, Location, ETag, Vary, Retry-After, Accept-Ranges, Age, WWW-Authenticate.
  • Заголовки представления описывают само тело — вне зависимости от того, кто его отправил: Content-Type, Content-Length, Content-Encoding, Content-Language, Content-Location. В POST-запросе они описывают тело запроса, в ответе — тело ответа. Именно эти заголовки кэш обязан сохранить вместе с ресурсом.
  • Общие заголовки относятся к сообщению как таковому: Date, Cache-Control, Connection, Via, Trailer, Transfer-Encoding. Cache-Control — единственный, который осмысленно ходит в обе стороны: в ответе он задаёт правила хранения, в запросе клиент им просит обойти кэш.

Отдельная ось — «сквозной или до соседа». Большинство заголовков сквозные (end-to-end): прокси обязан передать их дальше без изменений. Но Connection, Transfer-Encoding, TE, Upgrade, Proxy-Authorization действуют только на одном участке (hop-by-hop) и не должны пересекать прокси. Отсюда классическая путаница: Content-Encoding: gzip — свойство самого ресурса и доезжает до браузера, а Transfer-Encoding: chunked живёт лишь между двумя соседними узлами и на следующем участке может исчезнуть.

Ходовые заголовки запроса

ЗаголовокНазначениеПример
HostИмя виртуального хоста. Обязателен в HTTP/1.1; в HTTP/2 и HTTP/3 его роль играет псевдозаголовок :authorityHost: example.com
AcceptПриемлемые типы контента с весами qAccept: application/json;q=1.0, text/html;q=0.8
Accept-LanguageПредпочитаемые языки ответаAccept-Language: ru-RU,ru;q=0.9,en;q=0.6
Accept-EncodingПоддерживаемые алгоритмы сжатияAccept-Encoding: gzip, deflate, br, zstd
AuthorizationУчётные данные. По умолчанию делает ответ некэшируемым в общих кэшахAuthorization: Bearer eyJhbGci...
CookieВсе cookie, подходящие под домен и путь, одной строкойCookie: sid=abc; theme=dark
User-AgentКлиент и платформа. Не средство защиты: подделывается одной опцией curlUser-Agent: Mozilla/5.0 (X11; Linux x86_64)
RefererОткуда пришли. Объём данных в нём регулирует Referrer-Policy ответаReferer: https://example.com/catalog
OriginИсточник кросс-доменного запроса, основа CORS. Только схема, хост и порт, без путиOrigin: https://app.example.com
RangeЗапрос куска ресурса; ответ на него — 206Range: bytes=0-1048575
If-None-MatchУсловный запрос по ETag; при совпадении сервер отдаёт 304If-None-Match: "9f2c-6413ab21"
If-Modified-SinceУсловный запрос по дате. Игнорируется, если пришёл и If-None-MatchIf-Modified-Since: Mon, 03 Feb 2026 09:00:00 GMT
Схема HTTP-сообщения: стартовая строка, блок заголовков четырёх групп и тело ответа
Структура HTTP-сообщения и четыре группы заголовков: запроса, ответа, представления и общие

Ходовые заголовки ответа сервера: справочник

Таблица собрана под практическую задачу: увидели ответ сервера — поняли, чего в нём не хватает и чем это грозит.

ЗаголовокНазначениеТиповое значениеЧем грозит отсутствие
Content-TypeТип и кодировка телаtext/html; charset=utf-8Браузер угадывает тип сам; кракозябры, скачивание вместо показа, риск XSS через загруженные файлы
Content-LengthРазмер тела в байтах после сжатияContent-Length: 3842Нет прогресса загрузки, нет байтовых диапазонов; ответ идёт как chunked
Content-EncodingКаким алгоритмом сжато телоContent-Encoding: brТрафик и время ответа вырастают кратно на тексте, HTML, CSS, JS, JSON, SVG
Cache-ControlПравила хранения ответаpublic, max-age=31536000, immutableКэш действует эвристикой: статика перезапрашивается зря, а HTML залипает на часы
ETagОтпечаток версии ресурсаETag: "9f2c-6413ab21"Нет дешёвой валидации: вместо 304 сервер каждый раз отдаёт тело целиком
Last-ModifiedДата последнего измененияMon, 03 Feb 2026 09:00:00 GMTНет запасного механизма валидации, если ETag не отдаётся
VaryПо каким заголовкам запроса различается ответVary: Accept-EncodingCDN отдаёт сжатое тело клиенту, не просившему сжатие, — «битый» ответ у части аудитории
DateМомент формирования ответа на сервереMon, 03 Feb 2026 09:14:22 GMTКэшам не от чего отсчитывать возраст ответа
AgeСколько секунд ответ пролежал в общем кэшеAge: 118Не отличить свежий ответ origin от старого ответа CDN
LocationКуда вести при 3xx или где создан ресурс при 201Location: https://example.com/newРедирект без адреса — тупик, браузер показывает пустую страницу
Set-CookieУстановка cookie на клиентеsid=abc; Path=/; Secure; HttpOnly; SameSite=LaxНет сессий; без флагов — кража cookie через XSS и утечка по HTTP
Accept-RangesПоддержка байтовых диапазоновAccept-Ranges: bytesВидео не перематывается, загрузка не возобновляется после обрыва
Retry-AfterКогда повторять после 429 или 503Retry-After: 120Клиенты и краулеры долбят сервер повторами и усугубляют аварию
LinkСвязанные ресурсы: preload, canonical, alternate</app.css>; rel=preload; as=styleТеряется предзагрузка и канонизация для не-HTML файлов
Strict-Transport-SecurityПринудительный HTTPS для доменаmax-age=31536000; includeSubDomainsПервый заход по HTTP уязвим к перехвату и подмене
X-Content-Type-OptionsЗапрет угадывания типаnosniffЗагруженный пользователем файл может быть исполнен как скрипт
Content-Security-PolicyОткуда можно грузить ресурсыdefault-src 'self'Любая XSS сразу превращается в исполнение чужого кода
ServerИмя и версия веб-сервераServer: nginxОтсутствие не вредит; вредит как раз наличие точной версии

Правило простое: если заголовок влияет на то, как клиент трактует тело, он обязан быть явным. Всё, что вы не сказали явно, за вас додумают браузер, прокси и CDN — и в трёх разных реализациях додумают по-разному.

Cache-Control: разбор всех ходовых директив

Cache-Control — главный заголовок кэширования, описанный в RFC 9111. Он принимает список директив через запятую и действует на два разных типа кэша: приватный (браузер конкретного пользователя) и общий, он же shared — CDN, обратный прокси, корпоративный кэш. Половина ошибок кэширования — это неучёт того, что директива адресована не тому кэшу.

max-age и s-maxage

max-age=N задаёт время свежести в секундах — сколько ответ можно отдавать из кэша без обращения к серверу. Отсчёт идёт не от момента получения ответа клиентом, а от значения Date с поправкой на Age: ответ, пролежавший в CDN 3000 секунд с max-age=3600, доживёт в браузере ещё 600 секунд, а не 3600.

s-maxage=N — то же самое, но только для общих кэшей, и он перебивает max-age. Это рабочая связка для HTML: браузеру запрещаем кэшировать, CDN разрешаем держать минуту и разгружать бэкенд.

Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=600

no-cache и no-store — самая частая путаница

Эти две директивы звучат похоже и означают противоположные вещи.

  • no-cacheхранить можно, но перед каждой выдачей кэш обязан сходить на сервер и подтвердить, что копия актуальна. Практически это «кэшируй, но всегда проверяй условным запросом». Ответ при этом физически лежит на диске у пользователя.
  • no-storeхранить нельзя вообще: ни на диске, ни в памяти, ни в промежуточном кэше. Именно эта директива нужна для страниц с персональными данными, банковских выписок, ответов API с чужими профилями.

Отсюда типичный провал: страницу личного кабинета закрывают no-cache, считая, что «кэша нет», а она спокойно остаётся в дисковом кэше браузера и достаётся кнопкой «Назад» после выхода из аккаунта на общем компьютере. Для приватного — только no-store.

Обратная крайность — карго-культ no-cache, no-store, must-revalidate. Достаточно одного no-store: если хранить нельзя, то и ревалидировать нечего. Лишние директивы ничего не ломают, но выдают, что стратегию не продумывали.

private и public

private — ответ адресован одному пользователю, общие кэши хранить его не должны, браузер — может. Это не про безопасность: private не мешает записать ответ на диск. public решает обратную задачу — разрешает общему кэшу сохранить ответ там, где он по умолчанию отказался бы: например, при наличии Authorization в запросе.

must-revalidate, immutable, stale-while-revalidate

must-revalidate часто описывают как «после истечения max-age обязательно сходить на сервер» — это неточно, потому что так кэш обязан поступать и без этой директивы. Настоящий смысл другой: must-revalidate запрещает отдавать просроченную копию, если сервер недоступен. Без неё кэш имеет право в аварийной ситуации отдать устаревший ответ; с ней — обязан вернуть ошибку 504. Ставьте её там, где устаревшие данные хуже ошибки: остатки на складе, баланс, цены.

immutable говорит, что тело не изменится за всё время свежести. Сама по себе директива бесполезна — она работает только в паре с большим max-age. Практический эффект узкий, но ценный: браузер не станет слать условный запрос даже при обновлении страницы по F5. Применять её можно исключительно к файлам, у которых версия зашита в имя.

stale-while-revalidate=N (RFC 5861) разрешает N секунд после истечения свежести отдавать старую копию мгновенно, обновляя её в фоне. Это лучший компромисс для HTML и лент за CDN: пользователь не ждёт бэкенд, а следующий получит уже новое. Родственная stale-if-error=N разрешает отдавать старую копию, если origin вернул 5xx, — дешёвая страховка от короткой аварии.

Реже, но встречаются: proxy-revalidate (как must-revalidate, но только для общих кэшей), no-transform (запрет прокси пережимать картинки и минифицировать текст — актуально для мобильных операторов), only-if-cached и max-age=0 в запросе (клиент требует не ходить на сервер или, наоборот, обновить копию).

Почему Expires — легаси

Expires пришёл из HTTP/1.0 и задаёт абсолютную дату протухания в формате GMT. У него два врождённых недостатка. Первый — зависимость от часов: расходятся часы на сервере или на клиенте, и срок жизни ответа уезжает на эту разницу. Второй — невозможность выразить что-то сложнее «годен до»: ни s-maxage, ни stale-while-revalidate, ни immutable в него не запишешь. Если в ответе присутствуют оба заголовка, Cache-Control: max-age побеждает, а Expires игнорируется. Отдавать его отдельно имеет смысл только ради очень старых клиентов; в nginx директива expires ставит оба заголовка сразу, так что специально ничего делать не нужно.

Приём «сбросить кэш» через Expires: 0 или дату в прошлом работает, но грубо: он делает ответ просроченным, а не запрещает хранение. Для конфиденциальных данных это не замена no-store.

ETag, Last-Modified и условные запросы: откуда берётся 304

Свежесть отвечает на вопрос «можно ли отдать из кэша не спрашивая». Валидация отвечает на другой: «копия ещё актуальна?». Валидация дешевле полной перезагрузки — по сети идёт только блок заголовков.

ETag: сильный и слабый

ETag — непрозрачная строка в кавычках, отпечаток конкретной версии ресурса. Сильный валидатор "9f2c-6413ab21" означает побайтовое совпадение. Слабый W/"9f2c" — совпадение по смыслу: тело могло измениться в мелочах, но считается эквивалентным. Разница не косметическая: байтовые диапазоны через If-Range работают только с сильным валидатором, иначе клиент рискует склеить куски двух разных версий файла.

Два практических подвоха. Первый: nginx, сжимая ответ на лету, превращает сильный ETag в слабый — добавляет префикс W/, потому что тело после gzip уже другое. Это нормально и ломает разве что самописные клиенты, сравнивающие строки буквально. Второй: Apache исторически собирал ETag из inode файла, размера и времени изменения. На кластере из нескольких серверов inode у одного и того же файла разный — и кэш промахивается на каждом переключении бэкенда. Лечится директивой FileETag MTime Size.

Last-Modified и точность в одну секунду

Last-Modified — дата в формате IMF-fixdate, всегда в GMT: Mon, 03 Feb 2026 09:00:00 GMT. Гранулярность — одна секунда, поэтому для ресурсов, меняющихся чаще раза в секунду, он бесполезен как валидатор. Это запасной механизм: если сервер отдаёт и ETag, и Last-Modified, а клиент прислал оба условия, приоритет у If-None-Match, а If-Modified-Since просто игнорируется.

Как выглядит условный запрос и ответ 304

# обычный запрос — забираем ETag
curl -sD - -o /dev/null https://example.com/assets/app.css | grep -i -E 'etag|last-modified|cache-control'
# ETag: "9f2c-6413ab21"
# Last-Modified: Mon, 03 Feb 2026 09:00:00 GMT
# Cache-Control: public, max-age=31536000, immutable

# условный запрос по ETag — ждём 304 и пустое тело
curl -sD - -o /dev/null -w 'body=%{size_download}\n' \
     -H 'If-None-Match: "9f2c-6413ab21"' \
     https://example.com/assets/app.css
# HTTP/2 304
# body=0

# условный запрос по дате
curl -sI -H 'If-Modified-Since: Mon, 03 Feb 2026 09:00:00 GMT' \
     https://example.com/assets/app.css | head -1
# HTTP/2 304

Ответ 304 Not Modified намеренно усечён: тела нет, и представленческих заголовков вроде Content-Type и Content-Length в нём тоже обычно нет. Это не поломка конфигурации. Сервер обязан прислать лишь то, что могло измениться: Date, ETag, Cache-Control, Expires, Vary. Если вы проверяете набор заголовков и удивляетесь пропаже половины — сначала посмотрите на код ответа.

Побочный эффект: аудит заголовков безопасности по ответу 304 покажет ложные пропуски. Проверяйте их на 200 — например, добавив -H 'Cache-Control: no-cache' к запросу. Подробный разбор валидации вынесен в отдельный материал про заголовки кэширования, а стратегии уровня сайта — в статью о стратегиях веб-кэширования.

Диаграмма пути ответа через кэш: свежая копия, просроченная копия, условный запрос и ответ 304
Свежесть и валидация: когда кэш отвечает сам, когда шлёт условный запрос и когда получает 304

Рецепты кэширования по типу ресурса

Универсального Cache-Control не бывает. Ниже — рабочая раскладка по типам ресурсов; она закрывает почти любой сайт.

Тип ресурсаРекомендуемый Cache-ControlПочему так
Статика с хэшем в имени (app.4f1a9c.js)public, max-age=31536000, immutableИмя меняется вместе с содержимым, поэтому протухание не нужно вовсе; immutable убирает лишние условные запросы при F5
Статика без хэша (logo.png, style.css)public, max-age=86400, must-revalidate + ETagФайл может быть перезаписан на месте, поэтому нужен короткий срок и обязательная валидация
Шрифты (.woff2)public, max-age=31536000, immutableШрифты практически неизменяемы и критичны для отрисовки. Отдавать их с CORS-заголовком обязательно: шрифт грузится как кросс-доменный ресурс
HTML публичной страницыpublic, max-age=0, s-maxage=60, stale-while-revalidate=600Браузер всегда проверяет актуальность, CDN держит копию и гасит нагрузку; пользователь не ждёт бэкенд на фоновом обновлении
HTML личного кабинетаprivate, no-storeПерсональные данные не должны попадать ни в общий кэш, ни в дисковый кэш браузера на общем компьютере
Публичный JSON API (справочники, курсы)public, max-age=60, stale-if-error=86400Данные меняются медленно; при аварии origin клиент получит вчерашние данные вместо ошибки
Приватный JSON API (профиль, заказы)private, no-storeОтвет уникален для пользователя; ошибка кэширования означает показ чужих данных
Ответ на вход и обновление токенаno-store + Pragma: no-cacheТокены и сессионные идентификаторы не должны сохраняться нигде
Пользовательские загрузки (аватары, документы)private, max-age=3600 + Content-DispositionФайл принадлежит пользователю; общий кэш не должен раздавать его по прямой ссылке
robots.txt, sitemap.xml, llms.txtpublic, max-age=3600Краулеры перечитывают их часто; долгий кэш задерживает выкатку изменений на сутки и больше
Ответы 404 и 410public, max-age=300Короткий кэш гасит долбёжку по несуществующим адресам, но не мешает быстро вернуть страницу
Ответы 5xxno-storeЗакэшированная ошибка переживает починку сервера и превращает пятиминутную аварию в часовую

Так эта раскладка выглядит в конфиге nginx. Обратите внимание: HTML и статика разведены по разным location, а заголовки выставлены с параметром always, чтобы попадать и в ответы с ошибками.

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    root /var/www/example/public;

    gzip on;
    gzip_vary on;
    gzip_types text/css text/javascript application/javascript application/json image/svg+xml;
    gzip_min_length 1024;

    etag on;

    # 1. Статика с хэшем в имени — вечный кэш
    location ~* "^/assets/.+\.[0-9a-f]{8,}\.(css|js|woff2|png|jpg|webp|svg)$" {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        add_header X-Content-Type-Options "nosniff" always;
        access_log off;
        try_files $uri =404;
    }

    # 2. Шрифты — вечный кэш плюс CORS, иначе браузер их не применит
    location ~* "\.(woff2|woff|ttf)$" {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        add_header Access-Control-Allow-Origin "*" always;
        add_header X-Content-Type-Options "nosniff" always;
    }

    # 3. Прочая статика без хэша — сутки и обязательная валидация
    location ~* "\.(css|js|png|jpg|jpeg|gif|webp|ico|svg)$" {
        add_header Cache-Control "public, max-age=86400, must-revalidate" always;
        add_header X-Content-Type-Options "nosniff" always;
    }

    # 4. HTML — браузеру не кэшировать, CDN держать минуту
    location / {
        add_header Cache-Control "public, max-age=0, s-maxage=60, stale-while-revalidate=600" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        try_files $uri $uri/ /index.php?$query_string;
    }

    # 5. Личный кабинет и API — не хранить нигде
    location ^~ /account/ {
        add_header Cache-Control "private, no-store" always;
        add_header X-Content-Type-Options "nosniff" always;
        try_files $uri /index.php?$query_string;
    }
}

Проверять конфиг перед перезагрузкой обязательно: nginx -t && systemctl reload nginx. Разбор синтаксиса, контекстов и порядка location — в отдельной статье о настройке nginx.

Согласование содержимого: Accept, Accept-Encoding, Content-Encoding и Vary

Один и тот же URL может отдавать разные представления: сжатое и несжатое, на русском и на английском, JSON и HTML. Клиент сообщает предпочтения заголовками Accept*, сервер выбирает вариант и обязан сказать, по какому признаку он выбирал.

Веса q и как их читают

Заголовки Accept, Accept-Language, Accept-Encoding принимают список с весами: Accept-Language: ru-RU,ru;q=0.9,en;q=0.6 значит «лучше всего ru-RU, затем ru, английский приму скрепя сердце». Вес по умолчанию — 1.0, q=0 означает явный отказ. На практике многие серверы веса игнорируют и берут первый подходящий вариант — если языковые версии для вас важны, проверяйте это отдельно.

Сжатие: Accept-Encoding и Content-Encoding

Браузер присылает Accept-Encoding: gzip, deflate, br, zstd, сервер отвечает Content-Encoding: br и сжатым телом. Практическая иерархия: brotli на 15–20 % компактнее gzip на тексте, zstd сравним с brotli по размеру и быстрее жмёт; gzip остаётся универсальным запасным вариантом. Браузеры предлагают brotli и zstd только по HTTPS. Сжимать нужно текстовые типы — HTML, CSS, JS, JSON, XML, SVG; повторно жать JPEG, PNG, WebP, видео и архивы бессмысленно: выигрыш нулевой, процессорное время потрачено.

Не путайте Content-Encoding с Transfer-Encoding: первый описывает сам ресурс и доезжает до клиента, второй — способ передачи между двумя соседними узлами.

Vary — заголовок, который забывают, и это дорого стоит

Vary перечисляет заголовки запроса, от которых зависит ответ. Для кэша это часть ключа: при Vary: Accept-Encoding сжатая и несжатая версии хранятся раздельно.

Забытый Vary: Accept-Encoding за CDN даёт классическую аварию. Первым за ресурсом приходит браузер с Accept-Encoding: gzip, CDN сохраняет сжатое тело и отдаёт его следующему клиенту — старому мобильному браузеру, curl-скрипту, платёжному шлюзу, — который сжатие не просил и распаковывать не будет. Он получает бинарный мусор. Симптом коварен: у большинства пользователей всё работает, ломается у меньшинства, и воспроизвести это в браузере почти невозможно.

Обратная ошибка — слишком широкий Vary. Vary: User-Agent означает отдельную копию под каждую строку User-Agent, а их в дикой природе десятки тысяч: коэффициент попаданий в кэш падает почти до нуля, CDN превращается в дорогой прокси. Vary: Cookie на сайте, где cookie ставит аналитика, делает ресурс уникальным для каждого посетителя. Vary: * означает «кэшировать нельзя никогда».

Держите в Vary ровно те заголовки, по которым ответ действительно различается. В типовом случае это Accept-Encoding, иногда плюс Accept-Language или Origin — последний обязателен, если Access-Control-Allow-Origin вы подставляете динамически из Origin запроса. Без Vary: Origin CDN отдаст чужому домену заголовок, выписанный на ваш. Механика разобрана в статье про CORS.

Content-Type, charset и рамки тела

Что происходит без Content-Type

Content-Type состоит из MIME-типа и параметров, главный из которых — charset. Полная форма для HTML: text/html; charset=utf-8. Заголовок HTTP имеет приоритет над тегом meta charset внутри документа: если сервер отдаёт charset=windows-1251, а в HTML написано utf-8, победит сервер, и русский текст превратится в кракозябры. Обратный случай тоже реален: сервер молчит о кодировке, браузер угадывает по первым байтам и на короткой странице ошибается.

Если Content-Type нет совсем, включается MIME sniffing: браузер смотрит на содержимое и решает сам. Это удобно ровно до того момента, когда пользователь загружает на сайт «картинку», внутри которой лежит HTML со скриптом, — браузер добросовестно распознаёт HTML и исполняет чужой код на вашем домене. Именно поэтому заголовок X-Content-Type-Options: nosniff считается обязательным.

У nosniff есть обратная сторона, о которой узнают в момент выката: если сервер отдаёт JS-файл с типом text/plain, браузер с nosniff откажется его исполнять, и страница просто перестанет работать. Симптом — ошибка в консоли про несовпадение MIME-типа. Лечится не снятием заголовка, а правильным types в nginx.

Полезно помнить частности: JSON всегда UTF-8, параметр charset для application/json спецификацией не определён и лишний; для скачивания вместо показа используется отдельный заголовок Content-Disposition: attachment; filename="report.pdf", а не подмена типа на application/octet-stream.

Content-Length против Transfer-Encoding: chunked

Получателю нужно понять, где кончается тело. В HTTP/1.1 есть ровно два способа: заранее объявить размер через Content-Length или передавать тело кусками с Transfer-Encoding: chunked, где конец обозначен нулевым чанком.

Content-Length — это размер после сжатия, то есть длина того, что реально идёт по проводу. Он даёт клиенту прогресс загрузки и позволяет запрашивать байтовые диапазоны. Но его нельзя выставить, пока ответ не сформирован целиком, поэтому потоковые ответы — генерируемый на лету CSV, серверные события, длинные отчёты — идут чанками. Побочный эффект: у чанкового ответа нет Content-Length, значит нет и полосы прогресса, и перемотки.

Если в одном сообщении присутствуют оба заголовка, это не просто беспорядок, а известный вектор атаки: front-end и back-end могут по-разному решить, где кончается запрос, и злоумышленник протащит второй, скрытый запрос — HTTP request smuggling. Поэтому спецификация требует: Transfer-Encoding отменяет Content-Length, а прокси обязан такое сообщение отвергнуть. В HTTP/2 и HTTP/3 проблема снята архитектурно — Transfer-Encoding там запрещён, обрамление делают фреймы протокола, а Content-Length остаётся справочным.

Заголовки безопасности: минимальная карта

Заголовки безопасности — отдельный большой пласт, и дублировать его здесь нет смысла. Держите в голове минимум и идите за деталями в специализированные материалы.

  • Strict-Transport-Security — принудительный HTTPS на срок max-age. Развёрнуто — в руководстве по HSTS.
  • Content-Security-Policy — белый список источников скриптов, стилей, картинок и фреймов. Готовую политику удобно собирать и проверять конструктором CSP.
  • X-Content-Type-Options: nosniff — запрет угадывания MIME-типа.
  • X-Frame-Options или директива frame-ancestors в CSP — защита от кликджекинга. Современный способ — именно CSP.
  • Referrer-Policy — сколько данных уходит в Referer на чужие сайты. Разумное значение по умолчанию — strict-origin-when-cross-origin.
  • Permissions-Policy — какие браузерные API разрешены странице и её фреймам.
  • Cross-Origin-Opener-Policy и Cross-Origin-Resource-Policy — изоляция окна и ресурсов от чужих документов.

Полный разбор со значениями, порядком внедрения и типовыми ошибками — в статье про заголовки безопасности; быстрая оценка текущего состояния — в проверке безопасности сайта.

Путь запроса через CDN и обратный прокси с заголовками X-Forwarded-For, Via и Age на каждом узле
За CDN и обратным прокси часть заголовков ставит не приложение, а промежуточный узел

X-заголовки: почему префикс устарел, но X-Forwarded-For жив

Префикс X- когда-то означал «экспериментальное расширение». RFC 6648 объявил эту практику вредной ещё в 2012 году: у экспериментов есть свойство приживаться, а переименование прижившегося заголовка ломает всех, кто на него завязался. Рекомендация с тех пор — сразу называть заголовок так, как он будет называться всегда, без префикса.

На практике же несколько X--заголовков стали стандартом де-факто, и отказываться от них никто не собирается.

  • X-Forwarded-For — цепочка IP-адресов клиента и всех прокси по пути: X-Forwarded-For: 203.0.113.7, 198.51.100.10. Самый левый адрес — исходный клиент, каждый следующий прокси дописывает справа.
  • X-Real-IP — нестандартная упрощённая версия: один адрес вместо цепочки. Ставится вручную в nginx.
  • X-Forwarded-Proto — схема исходного запроса, http или https. Без неё приложение за прокси, терминирующим TLS, считает соединение незащищённым и уводит пользователя в бесконечный цикл редиректов на HTTPS.
  • X-Forwarded-Host — исходное значение Host до подмены прокси.
  • X-Request-Id — сквозной идентификатор запроса. Генерируется на входе, пробрасывается во все сервисы и пишется в логи; без него разбор инцидента в распределённой системе превращается в гадание по времени.
  • X-Robots-Tag — директивы индексации на уровне HTTP, единственный способ закрыть от поиска PDF, изображение или файл выгрузки.

Стандартной заменой первой группе стал заголовок Forwarded из RFC 7239 с синтаксисом Forwarded: for=203.0.113.7; proto=https; host=example.com. Он выразительнее и умеет скрывать внутренние адреса, но поддержка в софте до сих пор неполная, поэтому в реальных конфигурациях обычно живут оба варианта.

Почему X-Forwarded-For нельзя доверять напрямую

Это обычный заголовок запроса: любой клиент может отправить его сам с любым содержимым. Приложение, которое берёт самое левое значение и считает его IP клиента, подделывается одной строкой — и вместе с ней подделываются ограничение по количеству запросов, гео-логика, бан-листы и аудит.

Правило: доверять можно только тому значению, которое дописал ваш собственный прокси, и только если запрос пришёл с известного вам адреса. В nginx это делается связкой директив.

# доверяем только своим прокси и подсетям CDN
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header   X-Forwarded-For;
real_ip_recursive on;

# и явно перезаписываем заголовки при проксировании в приложение
location /api/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Request-Id      $request_id;
}

Ключевой момент: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for дописывает адрес соседа к существующей цепочке, а set_real_ip_from заставляет nginx игнорировать значения, пришедшие не от доверенных узлов. Подробности и разбор подделки — в материале про X-Forwarded-For.

Server и X-Powered-By: бесплатная подсказка атакующему

Точная версия в Server: nginx/1.24.0 или X-Powered-By: PHP/8.1.2 сама по себе не уязвимость, но экономит время сканеру: не нужно определять стек, достаточно свериться со списком известных уязвимостей этой версии. Убирается это дёшево: server_tokens off; в nginx скрывает номер версии (полностью убрать имя можно только модулем headers_more), expose_php = Off в php.ini убирает X-Powered-By, ServerTokens Prod и ServerSignature Off делают то же для Apache. Не забудьте про заголовки фреймворков — X-AspNet-Version, X-Generator, X-Drupal-Cache — их обычно снимают отдельно.

Link из RFC 8288 переносит связи между ресурсами на уровень HTTP. Два ходовых применения. Первое — предзагрузка критичных ресурсов: браузер узнаёт о шрифте или CSS до того, как разберёт HTML, что заметно улучшает метрику LCP. Второе — канонизация не-HTML: у PDF, изображения или XML нет места для тега link rel="canonical", и заголовок остаётся единственным способом указать канонический адрес.

# nginx: canonical и запрет индексации для файлов выгрузки
location ^~ /files/ {
    add_header X-Robots-Tag "noindex, nofollow" always;
    add_header Cache-Control "public, max-age=3600" always;
}

location = /docs/price.pdf {
    add_header Link '<https://example.com/price>; rel="canonical"' always;
    add_header X-Robots-Tag "noarchive" always;
}

# предзагрузка шрифта и главного стиля для HTML-страниц
location / {
    add_header Link '</assets/inter.woff2>; rel=preload; as=font; type="font/woff2"; crossorigin' always;
    add_header Cache-Control "public, max-age=0, s-maxage=60" always;
}

Проверить, что X-Robots-Tag действительно доехал, проще всего одной командой: curl -sI https://example.com/files/report.xlsx | grep -i robots.

Retry-After при 429 и 503

Retry-After сообщает клиенту, когда осмысленно повторить попытку. Значение — либо число секунд (Retry-After: 120), либо HTTP-дата. Уместен при 429 Too Many Requests, при 503 Service Unavailable во время планового обслуживания и при 301, если ресурс переехал временно. Без него клиенты и краулеры повторяют запросы по своим правилам — обычно агрессивно — и добивают сервер, который и так на пределе. Поисковые роботы понимают 503 с Retry-After как «зайти позже», а не как «страница умерла», и это спасает индексацию во время обслуживания.

Location и редиректы

Location обязателен для всех кодов 3xx и для 201 Created. Ходовые ошибки: адрес с кириллицей без процентного кодирования, потерянные при редиректе параметры запроса, цепочки из трёх и более переходов и, самое коварное, редирект на HTTP внутри HTTPS-сайта — он обнуляет смысл HSTS для этого перехода. Отдельная тема — циклы: example.com ведёт на www.example.com, а тот обратно. Проверять цепочку целиком удобно через curl -sIL или проверку редиректов; смысл кодов разобран в статье про коды состояния HTTP.

Тема заслуживает отдельного разбора, здесь — минимум. Каждая cookie ставится своим заголовком Set-Cookie; склеивать их через запятую нельзя. Обязательные атрибуты для сессионных cookie: Secure (только по HTTPS), HttpOnly (недоступна из JavaScript, гасит кражу через XSS), SameSite=Lax или Strict (защита от CSRF), явные Path и срок жизни. Практический нюанс: наличие cookie в запросе почти всегда делает ответ некэшируемым для CDN, поэтому статику отдают с домена или пути, куда cookie не отправляются. Посмотреть, что реально ставит сайт и с какими флагами, можно проверкой cookie.

Range и Accept-Ranges

Accept-Ranges: bytes в ответе означает, что сервер умеет отдавать куски файла. Клиент запрашивает диапазон заголовком Range: bytes=0-1048575 и получает 206 Partial Content с Content-Range: bytes 0-1048575/52428800. На этом держатся перемотка видео, докачка после обрыва и параллельная загрузка больших файлов.

curl -sD - -o /dev/null -r 0-1023 https://example.com/video/demo.mp4
# HTTP/2 206
# accept-ranges: bytes
# content-range: bytes 0-1023/52428800
# content-length: 1024

Типичная поломка: nginx, сжимающий ответ на лету, отключает поддержку диапазонов — размер тела заранее неизвестен. Для видео и крупных архивов сжатие бесполезно, поэтому просто исключите их типы из gzip_types. Второй источник проблем — Accept-Ranges: none от промежуточного прокси или самописного обработчика загрузок: перемотка в плеере перестаёт работать, хотя файл открывается.

Заголовки за CDN и обратным прокси

Как только перед сайтом появляется CDN или обратный прокси, ответ, который видит браузер, перестаёт быть ответом приложения. Часть заголовков добавлена, часть переписана, часть удалена.

Что добавляет промежуточный узел

  • Age — сколько секунд ответ пролежал в кэше. Age: 0 означает, что ответ только что взят с origin. Ненулевой Age при жалобе «сайт показывает старые цены» — сразу объясняет причину.
  • Via — цепочка прокси, через которые прошёл ответ.
  • X-Cache, X-Cache-Status, CF-Cache-Status и подобные — попадание или промах кэша. Имена вендорские, смысл один: HIT, MISS, EXPIRED, BYPASS, STALE.
  • Server нередко подменяется на имя CDN, и определить реальный веб-сервер по нему уже нельзя.

Кто главнее: origin или CDN

Общий кэш обязан подчиняться Cache-Control от origin, но приоритет директив свой: s-maxage перебивает max-age, private запрещает хранение в общем кэше, no-store запрещает хранение вообще. При этом почти любой CDN позволяет переопределить правила своей панелью — и тогда пользователь видит поведение, которого нет ни в одном конфиге сервера. При разборе таких случаев первым делом обойдите CDN и спросите origin напрямую:

# идём мимо CDN прямо на origin по его IP
curl -sI --resolve example.com:443:203.0.113.25 https://example.com/

# и сравниваем с тем, что отдаёт CDN
curl -sI https://example.com/ | grep -i -E 'cache-control|age|x-cache|vary|server'

Ловушка дублирующихся заголовков и nginx add_header

Самая дорогая ошибка в этом разделе — не заголовок, а поведение nginx. Директивы add_header наследуются с уровня выше только если на текущем уровне нет ни одной такой директивы. Достаточно добавить один add_header внутри location — и весь набор из server и http для этого location исчезнет. Заголовки безопасности, аккуратно прописанные в server, молча пропадут на самой важной странице, а сайт продолжит работать: ни ошибки, ни предупреждения в логе.

# НЕПРАВИЛЬНО: в /api/ останется только Cache-Control,
# HSTS и nosniff из server-контекста пропадут
server {
    add_header Strict-Transport-Security "max-age=31536000" always;
    add_header X-Content-Type-Options "nosniff" always;

    location /api/ {
        add_header Cache-Control "no-store" always;   # затирает весь набор выше
    }
}

# ПРАВИЛЬНО: общий набор в отдельном файле, подключается в каждый location
# /etc/nginx/snippets/sec-headers.conf:
#   add_header Strict-Transport-Security "max-age=31536000" always;
#   add_header X-Content-Type-Options "nosniff" always;
server {
    include snippets/sec-headers.conf;

    location /api/ {
        include snippets/sec-headers.conf;
        add_header Cache-Control "no-store" always;
    }
}

Второй нюанс той же директивы: без параметра always заголовок добавляется только к ответам из ограниченного списка кодов — 200, 201, 204, 206 и часть 3xx. Страницы ошибок 403, 404 и 500 остаются без заголовков безопасности. Поэтому always стоит писать по умолчанию.

Отдельно проверьте, не добавляет ли тот же заголовок приложение. Если CORS-заголовок ставят и nginx, и PHP, браузер получит Access-Control-Allow-Origin дважды и откажется от запроса с ошибкой про несколько значений. Заголовки, которые обязаны быть в единственном экземпляре — Access-Control-Allow-Origin, Content-Type, Content-Length, Location, — должен ставить ровно один слой.

Как посмотреть заголовки сайта: curl, DevTools и онлайн-проверка

curl: рабочий минимум

# 1. Быстрый просмотр — HEAD-запрос
curl -sI https://example.com/

# 2. Честный GET: заголовки в stdout, тело в /dev/null
curl -sD - -o /dev/null https://example.com/

# 3. Вся цепочка редиректов с заголовками каждого шага
curl -sIL https://example.com | grep -i -E '^HTTP/|^location'

# 4. Проверить сжатие: что придёт при запросе brotli
curl -sD - -o /dev/null -H 'Accept-Encoding: br, gzip' https://example.com/ \
  | grep -i -E 'content-encoding|vary|content-length'

# 5. Сравнить ответ с сжатием и без — ловим забытый Vary
curl -sI -H 'Accept-Encoding: gzip' https://example.com/ | grep -i content-encoding
curl -sI -H 'Accept-Encoding: identity' https://example.com/ | grep -i content-encoding

# 6. Заставить сервер ответить 200, а не 304, при аудите заголовков
curl -sD - -o /dev/null -H 'Cache-Control: no-cache' https://example.com/

# 7. Код ответа, размер тела и тайминги одной строкой
curl -s -o /dev/null -w 'code=%{http_code} size=%{size_download} ttfb=%{time_starttransfer}\n' \
     https://example.com/

# 8. Посмотреть, что отдаёт конкретный бэкенд мимо балансировщика
curl -sI --resolve example.com:443:203.0.113.25 https://example.com/

Про curl -I нужно помнить одну вещь: это HEAD-запрос, а не GET. Часть приложений обрабатывает HEAD отдельной веткой кода, часть CDN не кэширует его так же, как GET, а Content-Length в ответе на HEAD может отсутствовать. Если результат выглядит странно — перепроверьте через curl -sD - -o /dev/null, это настоящий GET с отброшенным телом.

DevTools

Вкладка Network, клик по запросу, панель Headers. Полезные детали: переключатель «view source» показывает заголовки в исходном виде, без причёсывания браузером; галка «Disable cache» отключает кэш и заставляет сервер отдать полный 200; фильтр по типу ресурса помогает быстро найти запрос, который отдаётся без сжатия. Надпись «Provisional headers are shown» означает, что запрос до сети не дошёл — он взят из кэша, отменён или заблокирован расширением; заголовкам в этот момент верить нельзя.

Онлайн-проверка

Когда curl под рукой нет или нужно посмотреть ответ снаружи вашей сети — быстрее онлайн-инструмент. Проверка HTTP-заголовков показывает полный ответ сервера с подсветкой проблем, а разбор заголовков ответа сервера объясняет, как читать результат.

Терминал с выводом curl и панель DevTools Network рядом: сравнение заголовков ответа
Диагностика заголовков: curl для точных проверок и DevTools для быстрого взгляда

Типовые ошибки: симптом, причина, проверка, фикс

СимптомПричинаПроверкаФикс
Часть пользователей видит бинарный мусор вместо страницыЗабыт Vary: Accept-Encoding, CDN отдаёт сжатое тело клиенту без сжатияcurl -sI -H 'Accept-Encoding: identity' ... | grep -i content-encodinggzip_vary on; в nginx, сброс кэша CDN
Русский текст в кракозябрахКодировка в Content-Type не совпадает с реальной или отсутствуетcurl -sI ... | grep -i content-typeЯвный charset=utf-8 в Content-Type, единая кодировка файлов и БД
Скрипты не исполняются, в консоли ошибка про MIME-типnosniff плюс неверный Content-Type для .js или .cssDevTools → Network → тип ресурсаПоправить таблицу types, а не снимать nosniff
Правки в HTML не видны пользователям часамиHTML отдаётся с длинным max-agecurl -sI ... | grep -i -E 'cache-control|age'max-age=0 для HTML, длинный кэш — только для хэшированной статики
Статика перезапрашивается на каждой страницеНет Cache-Control и нет ETagDevTools: размер против «disk cache»Хэш в имени файла плюс max-age=31536000, immutable
После выхода из аккаунта страница достаётся кнопкой «Назад»Использован no-cache вместо no-storecurl -sI /account/ | grep -i cache-controlCache-Control: private, no-store
Заголовки безопасности видны на главной, но не на /api/add_header в location затёр набор из serverСравнить curl -sI / и curl -sI /api/Вынести набор в snippet и подключать в каждом location
Заголовков нет на страницах 404 и 500У add_header не указан параметр alwayscurl -sI https://example.com/no-such-pageДобавить always ко всем add_header
Бесконечный редирект на HTTPS за проксиПриложение не получает X-Forwarded-Protocurl -sIL ... | grep -i -E '^HTTP/|location'proxy_set_header X-Forwarded-Proto $scheme; и доверие прокси в приложении
В логах у всех запросов один и тот же IPНе настроен real_ip, виден адрес проксиСравнить $remote_addr и X-Forwarded-For в логеset_real_ip_from плюс real_ip_header X-Forwarded-For
Браузер ругается на несколько значений Access-Control-Allow-OriginЗаголовок ставят и веб-сервер, и приложениеcurl -sD - -o /dev/null -H 'Origin: https://app.example.com' ...Оставить ровно один слой, отвечающий за CORS
Видео не перематываетсяНет Accept-Ranges: bytes или включено сжатие на летуcurl -sD - -o /dev/null -r 0-1023 ...Исключить видео из gzip_types, отдавать файл статикой
PDF и выгрузки попали в поискНет X-Robots-Tag: у файла нет места для meta robotscurl -sI /files/report.pdf | grep -i robotsadd_header X-Robots-Tag "noindex" always; на каталоге файлов
Половина заголовков «пропала» при проверкеСервер ответил 304, а не 200Посмотреть первую строку ответаПовторить запрос с -H 'Cache-Control: no-cache'

Как проверить свой сайт

  • Проверка HTTP-заголовков — полный ответ сервера, кэширующая группа, Content-Type, Vary, сжатие и подсветка спорных значений.
  • Проверка безопасности сайта — HSTS, CSP, nosniff, Referrer-Policy и остальной набор защитных заголовков с оценкой.
  • Проверка скорости — покажет, где длинный Cache-Control и сжатие реально экономят время загрузки, а где ресурсы тянутся заново.
  • Проверка редиректов — вся цепочка Location с кодами: находит циклы, лишние промежуточные переходы и падения на HTTP.
  • Проверка cookie — какие cookie ставит сайт заголовком Set-Cookie и есть ли у них Secure, HttpOnly и SameSite.
  • Проверка CORS — что отвечает сервер на preflight и совпадают ли Access-Control-* с тем, что ожидает фронтенд.

Частые вопросы

Чем no-cache отличается от no-store?

no-cache разрешает хранить ответ, но требует проверять его на сервере перед каждой выдачей — копия при этом физически лежит в кэше браузера. no-store запрещает сохранять ответ где бы то ни было. Для страниц с персональными данными подходит только no-store: с no-cache страница личного кабинета останется на диске и достанется кнопкой «Назад» после выхода из аккаунта.

Почему на ответе 304 половина заголовков пропала?

Так задумано. 304 Not Modified не имеет тела, поэтому заголовки, описывающие тело — Content-Type, Content-Length, Content-Encoding, — в него не включают. Сервер присылает только то, что могло измениться: Date, ETag, Cache-Control, Vary. Если вы проверяете заголовки безопасности, добейтесь ответа 200: добавьте к запросу -H 'Cache-Control: no-cache'.

Нужен ли Expires, если уже есть Cache-Control?

Нет. При наличии обоих заголовков кэши используют Cache-Control: max-age и игнорируют Expires. Последний зависит от синхронности часов и не умеет выражать s-maxage, immutable и stale-while-revalidate. Отдельно настраивать его не нужно: директива expires в nginx и так выставляет оба заголовка.

После добавления одного add_header пропали остальные заголовки. Почему?

Это штатное поведение nginx: add_header наследуется с уровня выше только тогда, когда на текущем уровне нет ни одной такой директивы. Один add_header в location отменяет весь набор из server. Решение — вынести общие заголовки в отдельный файл и подключать его через include в каждом location, где добавляются свои.

Можно ли доверять X-Forwarded-For?

Только тому значению, которое дописал ваш собственный прокси. Это обычный заголовок запроса, клиент отправляет его сам с любым содержимым, поэтому определять по нему IP «в лоб» — значит открыть обход ограничений по количеству запросов и бан-листов. В nginx нужен список доверенных адресов через set_real_ip_from вместе с real_ip_header X-Forwarded-For.

Как посмотреть заголовки сайта без curl?

Откройте DevTools, вкладку Network, перезагрузите страницу, кликните по первому запросу и смотрите панель Headers — там есть и запрос, и ответ. Если нужен взгляд снаружи вашей сети или проверка без браузера, воспользуйтесь онлайн-проверкой HTTP-заголовков.

Почему браузер скачивает файл вместо того, чтобы показать его?

Две типовые причины. Либо сервер отдаёт Content-Type: application/octet-stream вместо реального типа — так бывает, когда расширения нет в таблице types. Либо в ответе присутствует Content-Disposition: attachment, который прямо предписывает скачивание. Проверяется одной командой curl -sI; лечится правкой типа или сменой attachment на inline.

Чеклист заголовков

  • У каждого ответа есть явный Content-Type с charset для текстовых типов.
  • HTML отдаётся с max-age=0, хэшированная статика — с max-age=31536000, immutable.
  • Персональные страницы и приватные ответы API закрыты no-store, а не no-cache.
  • Есть ETag или Last-Modified, и условный запрос действительно возвращает 304.
  • При включённом сжатии отдаётся Vary: Accept-Encoding; в Vary нет User-Agent и Cookie.
  • Сжимаются только текстовые типы; видео и изображения из gzip_types исключены.
  • Набор заголовков безопасности одинаков на главной, во внутренних разделах и на страницах ошибок.
  • Все add_header написаны с параметром always, общий набор вынесен в подключаемый файл.
  • Точные версии в Server и X-Powered-By скрыты.
  • За прокси приложение получает X-Forwarded-Proto, а X-Forwarded-For принимается только от доверенных адресов.
  • Файлы выгрузок и служебные каталоги закрыты через X-Robots-Tag.
  • Ответы 429 и 503 содержат Retry-After.
  • Ни один обязательно-одиночный заголовок не дублируется двумя слоями инфраструктуры.
  • Проверка сделана и напрямую на origin, и через CDN — результаты сравнены.

Проверьте ваш сайт прямо сейчас

Проверить HTTP-статус сайта →
Другие статьи: HTTP
HTTP
Полный жизненный цикл HTTP-запроса: от URL до отрендеренной страницы
16.03.2026 · 557 просм.
HTTP
HTTP коды ответов: полный справочник с примерами
10.03.2025 · 356 просм.
HTTP
Ошибка 403 Forbidden: 8 способов исправить
15.04.2026 · 299 просм.
HTTP
Server-Sent Events vs WebSockets: выбор технологии реального времени
16.03.2026 · 261 просм.