Коротко. 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 его роль играет псевдозаголовок :authority | Host: example.com |
Accept | Приемлемые типы контента с весами q | Accept: 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 | Клиент и платформа. Не средство защиты: подделывается одной опцией curl | User-Agent: Mozilla/5.0 (X11; Linux x86_64) |
Referer | Откуда пришли. Объём данных в нём регулирует Referrer-Policy ответа | Referer: https://example.com/catalog |
Origin | Источник кросс-доменного запроса, основа CORS. Только схема, хост и порт, без пути | Origin: https://app.example.com |
Range | Запрос куска ресурса; ответ на него — 206 | Range: bytes=0-1048575 |
If-None-Match | Условный запрос по ETag; при совпадении сервер отдаёт 304 | If-None-Match: "9f2c-6413ab21" |
If-Modified-Since | Условный запрос по дате. Игнорируется, если пришёл и If-None-Match | If-Modified-Since: Mon, 03 Feb 2026 09:00:00 GMT |

Ходовые заголовки ответа сервера: справочник
Таблица собрана под практическую задачу: увидели ответ сервера — поняли, чего в нём не хватает и чем это грозит.
| Заголовок | Назначение | Типовое значение | Чем грозит отсутствие |
|---|---|---|---|
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-Encoding | CDN отдаёт сжатое тело клиенту, не просившему сжатие, — «битый» ответ у части аудитории |
Date | Момент формирования ответа на сервере | Mon, 03 Feb 2026 09:14:22 GMT | Кэшам не от чего отсчитывать возраст ответа |
Age | Сколько секунд ответ пролежал в общем кэше | Age: 118 | Не отличить свежий ответ origin от старого ответа CDN |
Location | Куда вести при 3xx или где создан ресурс при 201 | Location: 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 или 503 | Retry-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' к запросу. Подробный разбор валидации вынесен в отдельный материал про заголовки кэширования, а стратегии уровня сайта — в статью о стратегиях веб-кэширования.

Рецепты кэширования по типу ресурса
Универсального 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.txt | public, max-age=3600 | Краулеры перечитывают их часто; долгий кэш задерживает выкатку изменений на сутки и больше |
| Ответы 404 и 410 | public, max-age=300 | Короткий кэш гасит долбёжку по несуществующим адресам, но не мешает быстро вернуть страницу |
| Ответы 5xx | no-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: OriginCDN отдаст чужому домену заголовок, выписанный на ваш. Механика разобрана в статье про 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— изоляция окна и ресурсов от чужих документов.
Полный разбор со значениями, порядком внедрения и типовыми ошибками — в статье про заголовки безопасности; быстрая оценка текущего состояния — в проверке безопасности сайта.

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, Retry-After, Location, Set-Cookie, Range
Link: preload и canonical в заголовке
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.
Set-Cookie
Тема заслуживает отдельного разбора, здесь — минимум. Каждая 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-заголовков показывает полный ответ сервера с подсветкой проблем, а разбор заголовков ответа сервера объясняет, как читать результат.

Типовые ошибки: симптом, причина, проверка, фикс
| Симптом | Причина | Проверка | Фикс |
|---|---|---|---|
| Часть пользователей видит бинарный мусор вместо страницы | Забыт Vary: Accept-Encoding, CDN отдаёт сжатое тело клиенту без сжатия | curl -sI -H 'Accept-Encoding: identity' ... | grep -i content-encoding | gzip_vary on; в nginx, сброс кэша CDN |
| Русский текст в кракозябрах | Кодировка в Content-Type не совпадает с реальной или отсутствует | curl -sI ... | grep -i content-type | Явный charset=utf-8 в Content-Type, единая кодировка файлов и БД |
| Скрипты не исполняются, в консоли ошибка про MIME-тип | nosniff плюс неверный Content-Type для .js или .css | DevTools → Network → тип ресурса | Поправить таблицу types, а не снимать nosniff |
| Правки в HTML не видны пользователям часами | HTML отдаётся с длинным max-age | curl -sI ... | grep -i -E 'cache-control|age' | max-age=0 для HTML, длинный кэш — только для хэшированной статики |
| Статика перезапрашивается на каждой странице | Нет Cache-Control и нет ETag | DevTools: размер против «disk cache» | Хэш в имени файла плюс max-age=31536000, immutable |
| После выхода из аккаунта страница достаётся кнопкой «Назад» | Использован no-cache вместо no-store | curl -sI /account/ | grep -i cache-control | Cache-Control: private, no-store |
| Заголовки безопасности видны на главной, но не на /api/ | add_header в location затёр набор из server | Сравнить curl -sI / и curl -sI /api/ | Вынести набор в snippet и подключать в каждом location |
| Заголовков нет на страницах 404 и 500 | У add_header не указан параметр always | curl -sI https://example.com/no-such-page | Добавить always ко всем add_header |
| Бесконечный редирект на HTTPS за прокси | Приложение не получает X-Forwarded-Proto | curl -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 robots | curl -sI /files/report.pdf | grep -i robots | add_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 — результаты сравнены.