HTTP-заголовками (HTTP headers) называют пары Имя: значение в начале каждого запроса и ответа, через которые клиент и сервер передают дополнительную информацию о сообщении. Из заголовков запроса сервер узнаёт, к какому сайту обращаются, какой формат данных примет клиент и кто он такой; из заголовков ответа браузер получает тип и размер тела, правила кеширования, куки и требования к авторизации.
Примеры собраны в один файл headers_demo.py на Python 3.14, только стандартная библиотека: сервер на http.server и клиент на http.client работают на вашем компьютере без интернета.
- Как устроен HTTP-запрос и где в нём заголовки
- Виды HTTP-заголовков
- Пример: сервер, который показывает заголовки
- Заголовки запроса и ответа на практике
- Content-Type и Content-Length
- Кеширование: Cache-Control, ETag и 304 Not Modified
- Authorization и ответ 401
- Коды ответа и связанные с ними заголовки
- CORS и заголовки безопасности
- Как посмотреть HTTP-заголовки
- Инструменты разработчика в браузере
- curl
- Из кода на Python
- Типичные ошибки с заголовками
- Частые вопросы об HTTP-заголовках
- Важен ли регистр в именах HTTP-заголовков?
- Какие заголовки обязательны?
- Чем заголовки отличаются от параметров запроса?
- Сколько заголовков может быть в запросе?
- Как добавить свой заголовок в ответ сервера?
Как устроен HTTP-запрос и где в нём заголовки
Сообщение HTTP/1.1 состоит из стартовой строки, заголовков, пустой строки и необязательного тела сообщения. Так выглядит запрос, который клиент из нашего примера отправил серверу:
GET /echo HTTP/1.1
Host: 127.0.0.1:8000
Accept-Encoding: identity
User-Agent: headers-demo/1.0
X-Request-Id: 42
Стартовая строка запроса содержит метод, путь к ресурсу (часть URL после домена) и версию протокола. У ответа сервера на её месте строка статуса: HTTP/1.1 200 OK, то есть версия, код ответа и короткое пояснение. Каждый заголовок заканчивается парой символов \r\n, а пустая строка сообщает, что заголовки закончились и дальше идёт тело. У этого GET-запроса тела нет, и сообщение на ней кончается; тело есть, например, у POST с данными формы или JSON.
Правила записи такие:
- имя заголовка не зависит от регистра:
Content-Type,content-typeиCONTENT-TYPEозначают одно и то же; - между именем и двоеточием пробела быть не может, сервер обязан ответить на такой запрос кодом 400 Bad Request; пробел после двоеточия допустим;
- в HTTP/1.1 клиент обязан передать заголовок
Host; запрос без него или с двумяHostсервер отклоняет с кодом 400; - несколько заголовков с одинаковым названием получатель может склеить в один через запятую; исключение составляет
Set-Cookie: в его значении бывают запятые (например, в дате атрибутаExpires), и склейка изменила бы смысл.
В HTTP/2 сообщения передаются в двоичных кадрах, а имена заголовков обязаны быть в нижнем регистре: вместо Content-Type там передаётся content-type.

Виды HTTP-заголовков
Деление на общие заголовки (general headers), заголовки запроса (request headers), ответа (response headers) и сущности пришло из ранних спецификаций HTTP/1.0 и HTTP/1.1. Действующий стандарт 2022 года вместо «сущности» говорит о представлении: Content-Type, Content-Encoding, Content-Length, Last-Modified, ETag и др. описывают данные в теле и встречаются в обоих направлениях.
| Заголовок | Где бывает | Что сообщает |
|---|---|---|
Host | запрос | домен и порт сервера; по нему один сервер обслуживает несколько сайтов |
User-Agent | запрос | какая программа отправила запрос |
Accept | запрос | какие типы контента клиент готов принять, например text/html или application/xml |
Accept-Encoding | запрос | какие алгоритмы сжатия клиент понимает, например gzip |
Accept-Language | запрос | предпочтительные языки ответа |
Authorization | запрос | учётные данные для доступа, например токен |
Cookie | запрос | куки, которые браузер сохранил для этого сайта |
Referer | запрос | адрес страницы, с которой пришёл переход (имя написано с ошибкой, так в стандарте) |
If-None-Match | запрос | ETag копии, которая уже есть у клиента |
Content-Type | запрос и ответ | тип данных в теле и кодировка, например text/html; charset=utf-8 |
Content-Length | запрос и ответ | размер тела в байтах |
Content-Encoding | запрос и ответ | каким алгоритмом сжато тело для передачи |
Server | ответ | какое программное обеспечение отвечает |
Date | ответ | время создания ответа по GMT |
Cache-Control | ответ (бывает и в запросе) | правила кеширования |
ETag, Last-Modified | ответ | версия ресурса и дата его последнего изменения |
Location | ответ | URI созданного ресурса или адрес перенаправления |
Set-Cookie | ответ | куки, которую сервер просит сохранить |
WWW-Authenticate | ответ | какой способ аутентификации требует сервер |
Vary | ответ | от каких заголовков запроса зависит ответ |
Пример: сервер, который показывает заголовки
Сервер ниже знает три адреса: /echo возвращает заголовки запроса, которые он получил, /page отдаёт HTML-страницу с ETag, /private пускает только с токеном. Начало файла headers_demo.py:
# headers_demo.py, часть 1: учебный сервер (Python 3.14)
import hashlib
import http.client
import threading
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PAGE = "<h1>Привет!</h1>".encode("utf-8")
PAGE_ETAG = '"' + hashlib.sha256(PAGE).hexdigest()[:16] + '"'
TOKEN = "secret-123"
PORT = 8000
class DemoHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # по умолчанию http.server отвечает как HTTP/1.0
def log_message(self, format, *args):
pass # журнал запросов выключен, чтобы не мешал выводу
def send_body(self, status, body, content_type, extra_headers=()):
self.send_response(status) # строка статуса, Server и Date
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
for name, value in extra_headers:
self.send_header(name, value)
self.end_headers() # пустая строка: заголовки закончились
self.wfile.write(body)
def do_GET(self):
if self.path == "/echo":
lines = [f"{name}: {value}" for name, value in self.headers.items()]
lines.append("x-request-id = " + str(self.headers.get("x-request-id")))
text = "\n".join(lines).encode("utf-8")
self.send_body(200, text, "text/plain; charset=utf-8")
elif self.path == "/page":
if self.headers.get("If-None-Match") == PAGE_ETAG:
self.send_response(304)
self.send_header("ETag", PAGE_ETAG)
self.send_header("Cache-Control", "no-cache")
self.end_headers()
else:
self.send_body(200, PAGE, "text/html; charset=utf-8",
[("ETag", PAGE_ETAG), ("Cache-Control", "no-cache")])
elif self.path == "/private":
if self.headers.get("Authorization") == f"Bearer {TOKEN}":
self.send_body(200, b'{"user": "anna"}', "application/json")
else:
self.send_body(401, b'{"error": "no token"}', "application/json",
[("WWW-Authenticate", 'Bearer realm="demo"')])
else:
self.send_body(404, b"not found", "text/plain; charset=utf-8")
server = ThreadingHTTPServer(("127.0.0.1", PORT), DemoHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
Сервер работает в фоновом потоке, поэтому клиент живёт в том же файле. Если порт 8000 занят, поменяйте значение PORT: клиент в частях 2 и 5 берёт его оттуда же. Ошибки о занятом порте в Windows можно не дождаться: второй ThreadingHTTPServer на том же адресе запустился у нас без исключения.
Без строки protocol_version = "HTTP/1.1" модуль http.server отвечает версией HTTP/1.0 и закрывает соединение после каждого ответа. С ней сервер поддерживает постоянное соединение (keep-alive), и тогда, по документации модуля, точный Content-Length обязателен в каждом ответе: по нему клиент определяет, где кончается тело.
Заголовки запроса и ответа на практике
Следующий фрагмент отправляет запрос со своим User-Agent и нестандартным заголовком-идентификатором X-Request-Id, печатает строку статуса, заголовки ответа и тело ответа, то есть то, что получил сервер:
# часть 2: запрос и ответ целиком
conn = http.client.HTTPConnection("127.0.0.1", PORT)
conn.request("GET", "/echo", headers={"User-Agent": "headers-demo/1.0",
"X-Request-Id": "42"})
response = conn.getresponse()
print(response.status, response.reason)
for name, value in response.getheaders():
print(f"{name}: {value}")
print()
print(response.read().decode("utf-8"))
Вывод (дата в строке Date у вас будет своя):
200 OK
Server: BaseHTTP/0.6 Python/3.14.3
Date: Mon, 12 Oct 2026 09:19:05 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 110
Host: 127.0.0.1:8000
Accept-Encoding: identity
User-Agent: headers-demo/1.0
X-Request-Id: 42
x-request-id = 42
Мы передали два заголовка, а сервер получил четыре. Host и Accept-Encoding: identity модуль http.client добавил сам; identity значит «без сжатия». Последний пункт вывода показывает, что заголовок, отправленный как X-Request-Id, сервер нашёл по имени x-request-id: объект self.headers ищет без учёта регистра. Сравнивать имена заголовков через == со строкой в своём коде не стоит, приводите их к одному регистру.
Заголовок ответа Server выдал версию Python с точностью до патча. Стандарт советует не отдавать в нём лишних подробностей: по точной версии проще подобрать известную уязвимость. В http.server это значение собирают атрибуты server_version и sys_version, и документация прямо предлагает переопределить их в своём классе. Для продакшена сам модуль не рекомендуют: в нём только базовые проверки безопасности.

Content-Type и Content-Length
Content-Type определяет, как получателю понимать тело: text/html браузер покажет как веб-страницу, image/png как изображение, text/plain выведет как текст, а application/json используется для данных API. Параметр charset=utf-8 задаёт кодировку текста. Если сервер не прислал тип, получатель вправе считать тело произвольными байтами (application/octet-stream) или попытаться угадать тип по содержимому. Такое угадывание называют MIME sniffing: оно может сделать неверный вывод о данных и создать дополнительный риск для безопасности пользователя. Тип лучше указать явно.
Content-Length считается в байтах. Строка <h1>Привет!</h1> из нашего сервера занимает 16 символов, но в UTF-8 каждая кириллическая буква весит 2 байта, и тело получается 22 байта. Если посчитать len() у строки до encode(), клиент оборвёт ответ раньше конца. Поэтому в send_body() длина считается от body уже после encode().
Тело можно передавать и без заранее известной длины, кусками: тогда вместо Content-Length ставят Transfer-Encoding: chunked. Оба заголовка в одном сообщении стоять не могут.

Кеширование: Cache-Control, ETag и 304 Not Modified
Третий фрагмент запрашивает страницу дважды. В первый раз клиент сохраняет ETag, во второй присылает его в заголовке If-None-Match:
# часть 3: ETag и условный запрос
conn.request("GET", "/page")
first = conn.getresponse()
page = first.read()
etag = first.getheader("ETag")
print(first.status, first.getheader("Content-Type"), len(page), "байт", etag)
conn.request("GET", "/page", headers={"If-None-Match": etag})
second = conn.getresponse()
print(second.status, second.reason, len(second.read()), "байт")
print("Cache-Control:", second.getheader("Cache-Control"))
200 text/html; charset=utf-8 22 байт "a0088565a97f7f73"
304 Not Modified 0 байт
Cache-Control: no-cache
На второй запрос сервер ответил кодом 304 Not Modified и пустым телом: копия у клиента актуальна, пересылать страницу заново не нужно. В ответе 304 сервер обязан повторить те заголовки, которые отправил бы в 200: ETag, Cache-Control, Date, Vary, Expires, Content-Location. Наш сервер повторяет ETag и Cache-Control, Date добавляет send_response().
Вместо метки можно использовать дату: сервер отдаёт Last-Modified, клиент присылает её обратно в If-Modified-Since, и если ресурс с тех пор не менялся, ответ тоже будет 304. Когда в запросе есть оба заголовка, If-Modified-Since сервер обязан игнорировать.
Сравнение через == в примере упрощено. Настоящий If-None-Match может содержать список меток через запятую, звёздочку и слабые метки с приставкой W/, и сравнивать их стандарт велит по слабому правилу. В рабочем проекте эту логику берёт на себя фреймворк или веб-сервер.
Основные директивы Cache-Control в ответе:
| Директива | Что значит |
|---|---|
max-age=3600 | ответ свежий 3600 секунд, после этого устаревает |
no-cache | хранить можно, но перед каждым использованием копию надо проверить у сервера |
no-store | не сохранять ни запрос, ни ответ ни в браузере, ни в прокси |
private | общий кеш (прокси, CDN) хранить не должен, браузер пользователя может |
public | явно разрешает кешировать ответ, даже если иначе было бы нельзя: например, общему кешу ответ на запрос с Authorization |
no-cache легко спутать с no-store, но хранить он не запрещает, только отдавать копию без проверки. Такой режим у страницы из примера, и проверка дала 304. Для ответов с личными данными нужен no-store, но и он не гарантия конфиденциальности: кеш, который не следует стандарту, директиву может проигнорировать.
Заголовок Expires задаёт дату, после которой ответ устаревает, в формате Fri, 01 Jan 2027 00:00:00 GMT. Если в ответе есть max-age, получатель обязан Expires игнорировать: его значение нужно только программам, которые Cache-Control не понимают. Для новых проектов хватает одного Cache-Control. Файлам, в имени которых есть хеш содержимого (app.3f9a1c.js), можно ставить max-age=31536000, это год в секундах: при изменении файла меняется имя, а с ним и адрес, и браузер запросит новый ресурс.

Authorization и ответ 401
Четвёртый фрагмент обращается к закрытому ресурсу /private дважды: без заголовка Authorization и с токеном.
# часть 4: Authorization и ответ 401
for auth in ({}, {"Authorization": "Bearer secret-123"}):
conn.request("GET", "/private", headers=auth)
reply = conn.getresponse()
print(reply.status, reply.getheader("WWW-Authenticate"), reply.read().decode("utf-8"))
401 Bearer realm="demo" {"error": "no token"}
200 None {"user": "anna"}
Ответ 401 Unauthorized сервер по стандарту обязан сопроводить заголовком WWW-Authenticate, в котором назван хотя бы один способ входа. Здесь это схема Bearer. Клиент повторил запрос с заголовком Authorization: Bearer secret-123 и получил 200; во втором ответе WWW-Authenticate нет, поэтому getheader() вернул None.
Bearer-токеном может воспользоваться любой, у кого он оказался. Передают его только по HTTPS и не кладут в адрес. Это может быть, например, JWT токен, который сервер выдал после входа. В REST API токен обычно передают в заголовке Authorization с каждым запросом. Если учётные данные есть, но прав не хватает, правильный код 403 Forbidden: сервер понял запрос и отказывается его выполнять.

Коды ответа и связанные с ними заголовки
К некоторым кодам ответа стандарт требует или советует определённый заголовок.
| Код | Заголовок | Насколько обязателен |
|---|---|---|
| 201 Created | Location с адресом нового ресурса | если его нет, новым ресурсом считается адрес запроса |
| 301 Moved Permanently, 302 Found | Location с новым адресом | серверу следует его прислать (SHOULD) |
| 304 Not Modified | ETag, Cache-Control, Date и др., как в 200 | обязателен (MUST) |
| 401 Unauthorized | WWW-Authenticate | обязателен (MUST) |
| 405 Method Not Allowed | Allow со списком методов | обязателен (MUST) |
| 429 Too Many Requests | Retry-After | можно (MAY) |
| 503 Service Unavailable | Retry-After, через сколько повторить | можно (MAY) |
Для 400 Bad Request, 403 Forbidden, 404 Not Found и 500 Internal Server Error стандарт отдельных заголовков не требует. При ошибках 4xx и 5xx серверу следует объяснить причину в теле ответа (кроме ответа на HEAD).
CORS и заголовки безопасности
Отдельная группа заголовков управляет тем, что браузер разрешит странице. Браузер добавляет к запросу на чужой домен заголовок Origin, а сервер отвечает Access-Control-Allow-Origin с этим источником или звёздочкой. Нет нужного ответа, и скрипт данных не получит, хотя простой запрос до сервера дошёл. Без отдельного разрешения скрипту видны только заголовки ответа Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified и Pragma. Подробно механизм и предварительный запрос OPTIONS описаны в статье что такое CORS.
Некоторые заголовки JavaScript в браузере выставить не может: Host, Cookie, Origin, Referer, Content-Length, Connection, Accept-Encoding и др., а также все, что начинаются с Sec- и Proxy-. Их задаёт сам браузер. Поэтому подделать Origin из fetch() нельзя, а из curl или Python можно, и сервер не должен считать его доказательством.
Минимальный набор заголовков безопасности для сайта на HTTPS:
Strict-Transport-Security: max-age=31536000велит браузеру год ходить на сайт только по HTTPS. Пришедший по обычному HTTP заголовок браузер игнорирует.Content-Security-Policy: default-src 'self'ограничивает, откуда странице можно загружать скрипты, изображения и другие ресурсы. Это второй слой защиты от XSS-атак, экранирование вывода он не заменяет.X-Content-Type-Options: nosniffзапрещает браузеру использовать как скрипт ответ без JavaScript-типа и как стиль ответ безtext/css.Set-Cookieс атрибутамиSecure(куки уходит только по защищённому соединению) иHttpOnly(скрипты страницы её не прочитают).
Префикс X- у своих заголовков (X-Request-Id из нашего примера) остался по привычке: с 2012 года для новых имён его использовать не рекомендуют. Приставка X- сама не делает заголовок нестандартным: X-Content-Type-Options входит в стандарт, по которому работают браузеры.

Как посмотреть HTTP-заголовки
Инструменты разработчика в браузере
По справке браузера, в Chrome инструменты разработчика открываются клавишей F12 или сочетанием Ctrl+Shift+I в Windows и Linux. Дальше вкладка Network: выберите запрос в таблице, и на вкладке Headers будут разделы General, Response Headers и Request Headers.

curl
В командной строке curl -I адрес отправляет запрос HEAD и печатает только заголовки ответа, curl -i адрес печатает заголовки вместе с телом, а curl -v адрес показывает и отправленный запрос: его строки начинаются с >, строки ответа с <. Проверим на встроенном сервере Python: в одном окне запустите python -m http.server 8001 --bind 127.0.0.1, в другом curl -I http://127.0.0.1:8001/.
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.14.3
Date: Sun, 11 Oct 2026 08:18:47 GMT
Content-type: text/html; charset=utf-8
Content-Length: 256
Дата и длина у вас будут другими: длина зависит от того, какие файлы лежат в папке. Content-type с маленькой буквой так пишет этот модуль; регистр в имени заголовка роли не играет. Версия ответа HTTP/1.0 по той же причине, что и в нашем сервере без protocol_version. С -v curl показал свой запрос: GET / HTTP/1.1, Host: 127.0.0.1:8001, User-Agent: curl/8.13.0 и Accept: */*.
Из кода на Python
Конец файла смотрит, что отправляет urllib, и пробует подсунуть в значение заголовка перевод строки:
# часть 5: что добавляет urllib и чего не пропустит http.client
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/echo") as answer:
print(answer.read().decode("utf-8"))
try:
conn.request("GET", "/echo", headers={"X-User": "anna\r\nX-Admin: 1"})
except ValueError as error:
print("ValueError:", error)
conn.close()
server.shutdown()
Accept-Encoding: identity
Host: 127.0.0.1:8000
User-Agent: Python-urllib/3.14
Connection: close
x-request-id = None
ValueError: Invalid header value b'anna\r\nX-Admin: 1'
urllib без спроса добавил User-Agent: Python-urllib/3.14 и Connection: close. Серверы часто используют User-Agent, чтобы искать проблемы совместимости, подстраивать ответ под ограничения клиента и собирать статистику. В запросах к чужому API его лучше задать явно. X-Request-Id в этом запросе нет, и сервер получил None.
Вторая попытка не ушла в сеть вовсе: http.client отказался отправить значение с \r\n и бросил ValueError. Если бы такое значение прошло, сервер увидел бы лишний заголовок X-Admin: 1. Это внедрение заголовков (header injection). Данные пользователя вставляйте в заголовок только после проверки и не надейтесь на библиотеку.
Типичные ошибки с заголовками
Content-Lengthпосчитан по числу символов, и ответ обрезается на кириллице.- Один и тот же заголовок отдают и приложение, и веб-сервер перед ним. Две строки
Access-Control-Allow-Originв одном ответе браузер не принимает, и запрос падает с ошибкой CORS. - Сервер выбирает ответ по
Accept-EncodingилиOrigin, но не ставитVary, и прокси может отдать одному клиенту копию, сжатую или разрешённую для другого.

Частые вопросы об HTTP-заголовках
Важен ли регистр в именах HTTP-заголовков?
Нет: Content-Type и content-type означают одно и то же. Со значениями сложнее, правило своё у каждого заголовка: схема Bearer в Authorization регистр не различает, а сам токен наш сервер сравнивает через ==, посимвольно. Секреты в рабочем коде лучше сравнивать через hmac.compare_digest(): это снижает уязвимость к атакам по времени.
Какие заголовки обязательны?
В запросе HTTP/1.1 обязателен Host, а у запроса с телом нужен Content-Length или Transfer-Encoding: без них тело считается пустым. В ответе Date обязан ставить сервер с часами при кодах 2xx, 3xx и 4xx, а Content-Type следует ставить у сообщения с телом, если тип известен. Заголовки к отдельным кодам собраны в таблице выше.
Чем заголовки отличаются от параметров запроса?
Параметры запроса (query string) стоят в адресе после ?, например /search?q=http, и могут попасть в историю браузера и журналы веб-сервера. Заголовки передаются отдельно от адреса. Поэтому токены и пароли отправляют в заголовке Authorization, а не в параметрах.
Сколько заголовков может быть в запросе?
Стандарт не задаёт ни количества, ни длины отдельного заголовка, ни общего размера. Лимит ставит конкретный сервер или прокси, и на слишком большие заголовки он обязан ответить кодом из группы 4xx. В размер входят и куки: браузер отправляет их в заголовке Cookie.
Как добавить свой заголовок в ответ сервера?
В http.server вызовите self.send_header("Имя", "значение") между send_response() и end_headers(), как в начале примера. В nginx то же делает директива add_header, но без параметра always она добавляет заголовок только к ответам с кодами 200, 201, 204, 206, 301, 302, 303, 304, 307 и 308: на 404 или 500 его не будет. Как проверять заголовки входящих уведомлений от чужого сервиса, разобрано в статье что такое webhook.








