Все уроки
Основы вебаНачальный13 мин чтенияВ моём стеке

HTTP

Методы, статус-коды, заголовки, cookie, кэширование и CORS — на живых примерах.

  • Methods
  • Status codes
  • Headers
  • Cookies
  • Caching
  • CORS
Шаг 1/6
API — как официант. GET /menu — «принесите меню» — 200 OK, вот, пожалуйста.

За 30 секунд

HTTP — это язык, на котором разговаривают браузеры, приложения и серверы. Клиент отправляет запрос с методом, путём и заголовками; сервер отвечает статус-кодом, заголовками и телом. Почти всё в вебе — от страниц до API и скачивания файлов — построено на этом простом формате сообщений, поэтому его понимание сильно упрощает отладку.

Почему я это использую

  • Я каждый день делаю API и сайты, и большинство багов становятся очевидными, как только я читаю сырые запрос и ответ.
  • Правильные статус-коды, заголовки и кэширование делают приложения быстрее и безопаснее без лишнего кода.
  • Флаги cookie и CORS — места, где прячется множество проблем с безопасностью и интеграциями.
  • Мобильные приложения, админки и веб-фронтенды общаются с моими бэкендами по HTTP, так что общее понимание экономит время всей команде.

Как это работает

01Формат сообщения

HTTP — протокол «запрос–ответ», как официант, который принимает заказ и приносит блюдо. У запроса есть стартовая строка (метод, путь, версия), заголовки (пары ключ: значение) и необязательное тело. У ответа — строка статуса (версия, код, пояснение), заголовки и необязательное тело. HTTP не хранит состояние: каждый запрос самостоятелен, а состояние, например вход в систему, переносится через cookie или токены. В HTTP/2 и HTTP/3 те же сообщения передаются бинарными фреймами, но смысл тот же, поэтому текстовая форма ниже по-прежнему лучший способ о них думать.

http
POST /api/orders HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer <token>
Content-Length: 39
 
{"productId": 42, "quantity": 2}
 
HTTP/1.1 201 Created
Location: /api/orders/1001
Content-Type: application/json
 
{"id": 1001, "status": "pending"}

02Методы и их обещания

GET читает ресурс и ничего не должен менять. HEAD — это GET без тела, удобен для проверок. POST создаёт что-то или запускает действие. PUT полностью заменяет ресурс, PATCH меняет его часть, DELETE удаляет. OPTIONS спрашивает, что разрешено, и используется в CORS preflight-запросах. Важны два свойства. Безопасные методы (GET, HEAD, OPTIONS) не меняют состояние, поэтому краулеры и prefetch могут вызывать их свободно. Идемпотентные методы (GET, HEAD, PUT, DELETE, OPTIONS) дают тот же результат при повторе, поэтому клиенты могут повторить их после таймаута. POST и PATCH по умолчанию не идемпотентны: повтор POST может создать два заказа, если сервер не использует ключ идемпотентности.

  • Никогда не меняйте данные на GET: их может запустить превью ссылки или prefetch.
  • Автоматически повторяйте только идемпотентные запросы.
  • Для неподдерживаемых методов возвращайте 405 Method Not Allowed с заголовком Allow.

03Семейства статус-кодов

Всё говорит первая цифра. 1xx — информационные (103 Early Hints позволяет серверу отправить подсказки для preload до настоящего ответа). 2xx — успех: 200 OK, 201 Created, 204 No Content. 3xx — перенаправление: 301 и 308 постоянные, 302 и 307 временные (307 и 308 сохраняют метод и тело), 304 Not Modified для кэширования. 4xx — ошибки клиента: 400 Bad Request, 401 Unauthorized (не аутентифицирован), 403 Forbidden (аутентифицирован, но нет прав), 404 Not Found, 405, 409 Conflict, 410 Gone, 413 Payload Too Large, 415 Unsupported Media Type, 422 Unprocessable Content (валидация), 429 Too Many Requests. 5xx — ошибки сервера: 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout. Правильные коды позволяют клиентам, кэшам, мониторингу и поисковикам вести себя корректно, не читая тело.

04Заголовки и cookie

Заголовки несут метаданные. В запросе: Host, Accept, Accept-Language, Authorization, Content-Type, User-Agent, Cookie. В ответе: Content-Type, Content-Length, Location, Set-Cookie, Cache-Control и заголовки безопасности — Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options: nosniff и Referrer-Policy. Cookie — это небольшие значения, которые сервер просит браузер сохранить и отправлять обратно с каждым запросом к этому сайту. Насколько они безопасны, решают флаги. Secure: отправляется только по HTTPS. HttpOnly: JavaScript не может её прочитать, что ограничивает ущерб от XSS. SameSite=Lax (значение браузера по умолчанию) блокирует отправку в большинстве кросс-сайтовых запросов, Strict блокирует во всех, None разрешает кросс-сайтовые, но требует Secure. Path, Domain и Max-Age управляют областью действия и временем жизни. Префикс __Host- принудительно требует Secure, Path=/ и отсутствие Domain.

http
HTTP/1.1 200 OK
Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=7200
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; img-src 'self' data:

05Кэширование: Cache-Control, ETag, 304

Кэширование избавляет от лишней работы. Cache-Control говорит браузерам и CDN, что делать: max-age=31536000, immutable — для версионированных ресурсов вроде app.3f9a1c.js; no-cache — для HTML, который можно хранить, но нужно каждый раз перепроверять; no-store — для чувствительных данных, которые нельзя хранить никогда; private — для ответов, предназначенных только браузеру одного пользователя; s-maxage — для общих кэшей вроде CDN; stale-while-revalidate — чтобы отдавать слегка устаревшую копию, пока в фоне идёт обновление. Валидаторы делают перепроверку дешёвой: сервер отправляет ETag (отпечаток) или Last-Modified, позже браузер присылает If-None-Match или If-Modified-Since, и если ничего не изменилось, сервер отвечает 304 Not Modified без тела. Vary сообщает кэшам, какие заголовки запроса влияют на ответ, например Accept-Encoding.

http
GET /app.css HTTP/1.1
Host: example.com
If-None-Match: "a1b2c3"
 
HTTP/1.1 304 Not Modified
ETag: "a1b2c3"
Cache-Control: no-cache

06CORS без путаницы

Браузеры соблюдают same-origin policy: страница на https://app.example.com не может читать ответы с https://api.example.com, если API этого не разрешает. CORS (Cross-Origin Resource Sharing) — это способ, которым сервер выдаёт такое разрешение через заголовки ответа. Простым запросам достаточно Access-Control-Allow-Origin. Запросы с JSON-телом, собственными заголовками вроде Authorization или методами вроде PUT и DELETE вызывают preflight: браузер сначала отправляет OPTIONS и спрашивает, разрешён ли настоящий запрос. Если используются cookie, сервер должен вернуть точный origin (не *) и Access-Control-Allow-Credentials: true. CORS соблюдают только браузеры; он не защищает ваш API от curl или других серверов, поэтому аутентификация всё равно нужна.

http
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
 
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

07Как смотреть HTTP на практике

Вкладка Network в браузере показывает каждый запрос с заголовками, таймингами, cookie и ответом. Кликните правой кнопкой по запросу и выберите Copy as cURL, чтобы воспроизвести его в терминале. curl -v печатает весь обмен; -I получает только заголовки. Для API помогают Postman, Insomnia или HTTPie, но обычный curl в ранбуке — самый переносимый вариант. При отладке сравнивайте рабочий и сломанный запросы бок о бок: обычно разницу объясняет один заголовок, cookie или статус-код. Не забывайте удалять токены и cookie, прежде чем делиться скопированным запросом с кем-либо.

bash
curl -sI https://example.com/                      # headers only
curl -v https://api.example.com/health             # full exchange
curl -s -X POST https://api.example.com/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"productId":42,"quantity":2}' -w "\n%{http_code}\n"

Когда применять

  • Проектирование любого API или веб-эндпоинта.
  • Отладка проблем с логином, сессиями, кэшированием или кросс-доменными запросами.
  • Настройка заголовков безопасности и флагов cookie для продакшен-сайта.
  • Интеграция со сторонними сервисами и вебхуками.

Когда не стоит

  • Для постоянных обновлений в реальном времени обычный запрос–ответ расточителен; используйте WebSockets или Server-Sent Events.
  • Для высоконагруженных внутренних вызовов между сервисами со строгими схемами gRPC может подойти лучше.

Альтернативы

  • WebSockets

    Двустороннее постоянное соединение для чатов, заказов в реальном времени и дашбордов.

  • Server-Sent Events

    Односторонний поток от сервера к браузеру поверх обычного HTTP.

  • gRPC

    Бинарный RPC поверх HTTP/2 со сгенерированными клиентами, распространён между сервисами.

Лучшие практики

  1. Используйте метод и статус-код, которые соответствуют тому, что произошло на самом деле.
  2. Ставьте сессионным cookie Secure, HttpOnly и SameSite=Lax (или Strict).
  3. Кэшируйте версионированные ресурсы на год с immutable; HTML перепроверяйте.
  4. Используйте no-store для ответов с персональными или платёжными данными.
  5. Разрешайте в CORS только известные origin'ы; никогда не отражайте любой origin вместе с credentials.
  6. Отправляйте HSTS, nosniff, Referrer-Policy и Content-Security-Policy.

Частые ошибки

  • Возвращать 200 с сообщением об ошибке в теле, ломая клиентов и мониторинг.
  • Путать 401 и 403.
  • Ставить Access-Control-Allow-Origin: * и ждать, что cookie будут работать, или отражать любой origin с credentials.
  • Изменять данные GET-запросами.
  • Публично кэшировать на CDN ответы, привязанные к конкретному пользователю.

Глубже

08Границы сообщений и request smuggling

В HTTP/1.1 получатель должен знать, где заканчивается тело. Content-Length задаёт размер в байтах; Transfer-Encoding: chunked отправляет тело частями, у каждой своя длина, а в конце идёт чанк нулевого размера. Если в запросе есть оба заголовка или искажённая версия одного из них, фронтовой прокси и бэкенд-сервер могут по-разному понять, где кончается запрос. Тогда атакующий может спрятать второй запрос в теле первого, и бэкенд воспримет его как начало запроса следующего пользователя. Это HTTP request smuggling, и с его помощью можно обойти правила безопасности или отравить кэш. Защита: обновляйте прокси и серверы, отклоняйте запросы с обоими заголовками или некорректными границами, по возможности используйте HTTP/2 на всём пути и избегайте самописных прокси. У HTTP/2 и HTTP/3 своё фреймирование, но даунгрейд до HTTP/1.1 на прокси возвращает риск.

09Условные запросы для безопасных параллельных изменений

Два админа открывают один и тот же товар, оба его редактируют, и второе сохранение молча перезаписывает первое: проблема потерянного обновления. HTTP решает её валидаторами. Ответ на GET содержит ETag (отпечаток версии). Клиент отправляет изменение с If-Match, равным этому ETag. Если ресурс за это время изменился, сервер отказывает с 412 Precondition Failed, а клиент перезагружает данные и сливает изменения. If-None-Match: * в PUT означает «создать, только если не существует», что защищает от случайной перезаписи. Сервер может требовать предусловия при записи через 428 Precondition Required. Внутри это удобно ложится на колонку version или проверку updated_at в одном выражении UPDATE ... WHERE version = ?, так что проверка и запись атомарны.

http
GET /api/products/42 HTTP/1.1
 
HTTP/1.1 200 OK
ETag: "v7"
 
PATCH /api/products/42 HTTP/1.1
If-Match: "v7"
Content-Type: application/json
 
{"price": 4900}
 
HTTP/1.1 412 Precondition Failed      # someone saved v8 first

10Content negotiation, Vary и range-запросы

У одного URL может быть несколько представлений. Клиент сообщает предпочтения через Accept, Accept-Language и Accept-Encoding, а сервер выбирает одно, в идеале отвечая 406, если ничего не подходит. Любой заголовок, который меняет ответ, должен быть указан в Vary, иначе кэш может отдать тело в Brotli клиенту, который понимает только gzip, или русскую страницу англоязычному посетителю. Vary также дробит кэш: Vary: User-Agent или Vary: Cookie могут сделать кэширование почти бесполезным, поэтому для языков лучше отдельные URL, а Vary по возможности ограничить Accept-Encoding. Range-запросы позволяют клиенту запросить часть файла через Range: bytes=0-1023; сервер отвечает 206 Partial Content и сообщает о поддержке через Accept-Ranges: bytes. На этом держатся перемотка в видеоплеерах и докачка в менеджерах загрузок.

bash
curl -s -o /dev/null -D - -H "Range: bytes=0-1023" https://example.com/video.mp4
# HTTP/2 206
# content-range: bytes 0-1023/48213004
# accept-ranges: bytes

11Cookie между сайтами, поддоменами и фреймами

Site и origin — разные понятия. Origin — это scheme + host + port; site — регистрируемый домен, поэтому app.example.com и api.example.com — разные origin, но один site. Правила SameSite работают с site, поэтому cookie с SameSite=Lax всё равно отправляется между вашими поддоменами. В межсайтовом контексте Lax-cookie уходят только с навигациями верхнего уровня методом GET и никогда — с fetch или POST-формами с другого сайта. Cookie с атрибутом Domain доступны всем поддоменам, включая те, что обслуживает сторонний сервис, поэтому сессионные cookie держите host-only. Сторонние контексты ограничены: Safari и Firefox по умолчанию блокируют third-party cookie, а Partitioned cookie (CHIPS) позволяют встроенному виджету хранить состояние отдельно для каждого сайта верхнего уровня. Браузеры также ограничивают размер cookie — около 4 KB на каждую, и каждая cookie отправляется с каждым запросом, так что большие cookie съедают трафик.

http
Set-Cookie: __Host-sid=8f2c...; Path=/; Secure; HttpOnly; SameSite=Lax
Set-Cookie: widget_state=abc; Path=/; Secure; SameSite=None; Partitioned

Реальные ситуации

01

Логин работает на десктопе, но не во встроенном iframe

Проблема

Виджет бронирования работает в iframe на сайтах партнёров. На основном сайте пользователи логинятся нормально, но внутри виджета сессия пропадает после каждого запроса.

Как решить

В этом контексте сессионная cookie — сторонняя, поэтому SameSite=Lax её блокирует, а некоторые браузеры блокируют полностью. Варианты: выставить cookie виджета с SameSite=None; Secure; Partitioned, чтобы она работала отдельно для каждого партнёрского сайта; либо отказаться от cookie в виджете и использовать короткоживущий токен, переданный через postMessage и отправляемый в заголовке Authorization. Не ослабляйте сессионную cookie основного сайта до SameSite=None — это снова открывает риск CSRF. Тестируйте в Safari и Firefox, они по умолчанию самые строгие.

02

Клиенты получают из кэша не тот язык

Проблема

Сайт определяет язык по Accept-Language и отдаёт русскую или английскую версию по одному и тому же URL. После подключения CDN посетители иногда получают не тот язык.

Как решить

Ответ зависит от заголовка, о котором кэш не знает. Надёжное решение — отдельные URL для каждого языка, например /en/ и /ru/, с однократным редиректом с корня на основе Accept-Language или сохранённого выбора, и теги hreflang для поисковиков. Сам редирект пометьте как private или Vary: Accept-Language, чтобы он не попадал в общий кэш. Добавить Vary: Accept-Language на каждую страницу можно, но это дробит кэш на множество вариантов, а некоторые CDN его игнорируют. После изменения сделайте purge и проверьте через curl с разными значениями Accept-Language.

03

Два сотрудника перезаписывают правки друг друга

Проблема

В админке цены в меню иногда откатываются. В логах видно два PATCH-запроса с разницей в минуту от разных пользователей, редактирующих одну и ту же позицию.

Как решить

Добавьте оптимистичную блокировку. Возвращайте ETag или номер версии с каждым GET. Требуйте If-Match в PATCH и PUT и реализуйте обновление как UPDATE ... WHERE id = ? AND version = ?, увеличивая версию. Если ни одна строка не изменилась, возвращайте 412 Precondition Failed с текущей версией. Тогда админка показывает «эту позицию изменил кто-то другой» и предлагает перезагрузить. Возвращайте 428, если клиент забыл заголовок, чтобы старые клиенты не могли молча обойти проверку.

Шпаргалка

Показать только заголовки ответа
curl -sI https://example.com/
Показать заголовки запроса и ответа
curl -sv -o /dev/null https://example.com/ 2>&1 | grep -E "^[<>]"
Проверить условный GET
curl -sI -H 'If-None-Match: "a1b2c3"' https://example.com/app.css
Проверить CORS preflight
curl -si -X OPTIONS https://api.example.com/orders -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: POST"
Скачать часть файла
curl -s -r 0-1023 -o part.bin https://example.com/file.zip
Пройти по редиректам и вывести каждый шаг
curl -sIL https://example.com/old-page | grep -iE "^HTTP|^location"
Безопасная сессионная cookie
Set-Cookie: __Host-sid=...; Path=/; Secure; HttpOnly; SameSite=Lax
Долгоживущий версионированный ассет
Cache-Control: public, max-age=31536000, immutable
Никогда не сохранять
Cache-Control: no-store

В моих реальных проектах

  • Мои бэкенды на Laravel и NestJS обслуживают веб-фронтенды, админки и приложения на React Native по HTTP/JSON, причём веб- и мобильный каналы используют общую основную логику.
  • В приложении для зоомагазина HTTP-клиент повторяет только идемпотентные методы, поэтому таймаут никогда не создаёт дубликат заказа.
  • Сайты на Next.js, над которыми я работал, используют собственный сервер с заголовками безопасности и кэширования.

Вопросы и ответы

Q1Чем 401 отличается от 403?

401 Unauthorized на самом деле означает «не аутентифицирован»: сервер не знает, кто вы, потому что токен или сессия отсутствуют, истекли или недействительны. Клиенту нужно войти или обновить учётные данные, а для схем HTTP-аутентификации ответ должен содержать заголовок WWW-Authenticate. 403 Forbidden означает, что сервер знает, кто вы, но вам это делать нельзя — например, кассир пытается открыть финансовые отчёты. Повторный вход не поможет. Для ресурсов, о существовании которых пользователь вообще не должен знать, например данных другого тенанта, лучше часто возвращать 404, а не 403, потому что так не раскрывается сам факт их существования.

Q2Какие HTTP-методы идемпотентны и почему это важно?

GET, HEAD, OPTIONS, PUT и DELETE идемпотентны: если отправить один и тот же запрос дважды, сервер окажется в том же состоянии, что и после одного. GET, HEAD и OPTIONS также безопасны, то есть ничего не должны менять. POST и PATCH по умолчанию не идемпотентны. Это важно для повторов: сеть сбоит, и клиент, не получивший ответа, не знает, обработал ли сервер запрос. Идемпотентные запросы можно повторять автоматически. Для POST-запросов вроде платежей или заказов я добавляю заголовок Idempotency-Key, чтобы сервер мог вернуть исходный результат, а не создать дубликат.

Q3Как работает браузерное кэширование с ETag и Cache-Control?

Cache-Control решает, можно ли переиспользовать ответ и как долго. С max-age браузер использует свою копию, не спрашивая сервер, пока она не истечёт. С no-cache он хранит копию, но перед использованием обязан её перепроверить. Перепроверка использует валидаторы: сервер прислал ETag, и браузер спрашивает с If-None-Match; если ресурс не изменился, сервер возвращает 304 Not Modified без тела, экономя трафик и работу. Моя обычная схема: длинный max-age с immutable для ресурсов с хэшем, no-cache с ETag для HTML и no-store для приватных данных вроде страниц аккаунта или оплаты.

Q4Что такое CORS и почему мой запрос падает только в браузере?

CORS — механизм безопасности браузера. По умолчанию скрипт с одного origin не может читать ответы с другого origin. Сервер может это разрешить, вернув Access-Control-Allow-Origin и связанные заголовки. Для непростых запросов, например с JSON-телом или заголовком Authorization, браузер сначала отправляет preflight-запрос OPTIONS, и если сервер не отвечает на него правильно, настоящий запрос так и не выполняется. curl и Postman CORS не соблюдают, поэтому там тот же вызов работает. Исправлять нужно на сервере: разрешить точный origin фронтенда, нужные методы и заголовки, а при использовании cookie — credentials.

Q5Как сделать cookie безопасными?

Ставьте Secure, чтобы cookie отправлялась только по HTTPS, HttpOnly, чтобы JavaScript не мог её прочитать и XSS-уязвимость не могла украсть сессию, и SameSite=Lax или Strict, чтобы браузер не прикреплял её к большинству кросс-сайтовых запросов, что снижает риск CSRF. Ограничивайте область действия: избегайте атрибута Domain, если он не нужен поддоменам, и подумайте о префиксе __Host-, который требует Secure, Path=/ и отсутствия Domain. Делайте время жизни коротким, меняйте ID сессии при входе и храните только непрозрачный идентификатор, а не персональные данные. Для форм, меняющих состояние, я всё равно использую CSRF-токены как дополнительный уровень защиты.

Q6Чем отличаются 301, 302, 307 и 308?

301 и 308 — постоянные редиректы: браузеры и поисковики запоминают их и передают ранжирование новому URL. 302 и 307 — временные: каноническим остаётся исходный URL. Разница внутри каждой пары — в методе. При 301 и 302 браузеры исторически превращают POST в GET на новом URL. 307 и 308 гарантируют сохранение метода и тела. Поэтому для постоянного переезда страниц, например с HTTP на HTTPS или со старого slug на новый, я использую 301 или 308; для API-эндпоинтов, принимающих POST, — 307 или 308; для краткосрочных переездов — 302 или 307.

Вопросы уровня senior

S1Как выбрать между cookie и bearer-токенами для веб-приложения и мобильного приложения?

Для браузера я предпочитаю сессионную cookie с HttpOnly, Secure и SameSite на том же site, что и приложение: JavaScript не украдёт её через XSS, браузер сам её отправляет, а серверные сессии легко отозвать. Цена — CSRF, с которым справляются SameSite плюс CSRF-токены. В мобильных приложениях cookie неудобны, поэтому я использую короткоживущие access-токены и refresh-токены в защищённом хранилище платформы. Чего я избегаю — долгоживущих токенов в localStorage браузера, потому что любой XSS может их прочитать. Многие бэкенды поддерживают оба варианта: один и тот же сервис аутентификации выдаёт сессии для веба и токены для мобильных. JWT полезны, когда другим сервисам нужно проверять личность без запроса к хранилищу, но отзыв становится сложнее, поэтому срок жизни должен быть коротким.

S2Какие компромиссы у переноса бизнес-правил в HTTP-кэширование?

HTTP-кэширование крайне эффективно, потому что его понимают браузеры, CDN и прокси, и оно не требует кода. Но оно переносит часть корректности в заголовки, которые разные слои трактуют немного по-разному. Ошибки серьёзны: публичный кэш, сохранивший приватный ответ, — это утечка данных, а длинный max-age на HTML означает, что пользователи не получат исправление, пока он не истечёт. Я держу правила простыми: immutable для ассетов с хэшем, короткий s-maxage плюс stale-while-revalidate для публичного HTML, private или no-store для всего пользовательского и purge по деплою или событиям контента. Ещё я пишу тесты, проверяющие заголовки на ключевых маршрутах, потому что регрессии в заголовках не видны при обычном функциональном тестировании.

S3Как защитить API от CSRF, если оно использует cookie?

Во-первых, используйте сессионные cookie с SameSite=Lax или Strict: браузеры не будут отправлять их в межсайтовых POST, PUT и DELETE и в fetch-запросах с других сайтов. Во-вторых, никогда не меняйте состояние через GET, ведь Lax всё ещё пропускает навигации верхнего уровня методом GET. В-третьих, добавьте защиту, не зависящую от поведения браузера: synchronizer token в формах или double-submit токен в пользовательском заголовке, например X-CSRF-Token, который другие сайты не могут выставить, не пройдя CORS. Проверка заголовков Origin или Sec-Fetch-Site по allowlist — дешёвый дополнительный слой. CORS не должен отражать произвольные origin с credentials, иначе вся схема рушится. API только на bearer-токенах не уязвимы к CSRF, но риск смещается на хранение токенов.

Глоссарий

Origin
Scheme, host и port вместе, например https://app.example.com:443; единица same-origin policy.
Site
Регистрируемый домен, например example.com; его поддомены считаются same-site. На этом понятии основаны SameSite cookie.
Идемпотентность
Повтор запроса имеет на сервере тот же эффект, что и однократная отправка.
ETag
Идентификатор версии ответа; используется для ревалидации кэша и защиты от потерянных обновлений.
Preflight
OPTIONS-запрос, который браузер отправляет перед некоторыми cross-origin запросами, чтобы спросить разрешение.
Vary
Заголовок ответа со списком заголовков запроса, меняющих ответ, чтобы кэши хранили отдельные варианты.
Chunked encoding
Способ HTTP/1.1 отправлять тело частями, когда общий размер заранее неизвестен.
Request smuggling
Атака, при которой прокси и сервер расходятся в границах запроса, и спрятанный запрос проскальзывает дальше.
206 Partial Content
Ответ на Range-запрос, содержащий только запрошенные байты файла.
CHIPS
Cookies Having Independent Partitioned State: cookie с пометкой Partitioned, хранящиеся отдельно для каждого сайта верхнего уровня.

Есть идея продукта?Давайте создадим его.

Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.