Событие DOM проходит три фазы: погружение (capture, от window вниз к цели), цель (target) и всплытие (bubble, обратно вверх к window). Делегирование вешает один обработчик на общего предка и через event.target определяет, по какому потомку кликнули, вместо обработчика на каждом потомке.
addEventListener(type, fn) слушает фазу всплытия; чтобы слушать погружение, нужно передать { capture: true }.
stopPropagation() останавливает событие на пути к следующим элементам; preventDefault() отменяет действие браузера по умолчанию (переход по ссылке, отправку формы) и распространение не останавливает. Это независимые вещи.
Зачем делегировать: меньше обработчиков, и элементы, добавленные позже, обрабатываются автоматически. Нужен target.closest('button'), потому что клик может попасть во вложенный <span> или иконку.
Некоторые события не всплывают: focus, blur, mouseenter, mouseleave. Вместо них используют focusin/focusout или фазу погружения.
React тоже делегирует: он вешает свои обработчики на корневой контейнер, а не на каждый элемент.
ReactMiddle
Веб-платформа и безопасность
Как браузер рисует страницу, DOM и события, CSS-раскладка, доступность, HTTP и кэширование, XSS, CSP и CORS, cookies, токены и OAuth, хранилища
Закрыто вопросов
0 / 23
- 0Повторить
- 15Часто спрашивают
Вопросы
Обычный <script> останавливает разбор HTML, пока не загрузится и не выполнится. async загружается параллельно и выполняется сразу по прибытии, в любом порядке; defer загружается параллельно и выполняется в порядке документа после окончания разбора, прямо перед DOMContentLoaded.
**defer для кода приложения, которому нужен DOM или который зависит от другого скрипта.
async для независимых скриптов вроде аналитики, где порядок не важен.
type="module"** по умолчанию ведёт себя как defer; с async модуль выполняется, как только готов он сам и его импорты. Vite генерирует именно module-скрипты.
Блокирующий скрипт в <head> задерживает первую отрисовку, поэтому раньше советовали ставить скрипты в конец <body>.
Скрипт, вставленный из JavaScript (document.createElement('script')), ведёт себя как async.
Каскад сравнивает по очереди: источник стилей и !important, каскадные слои (@layer), специфичность и в конце порядок в коде, где побеждает более позднее правило. Специфичность считает сначала id, потом классы, атрибуты и псевдоклассы, потом имена элементов и псевдоэлементы.
Специфичность сравнивают как тройку: у #nav a она (1,0,1), у .menu .link (0,2,0), поэтому селектор с id побеждает, сколько бы классов ни было у второго.
Инлайновый style бьёт любой селектор; !important бьёт инлайновые стили, и его стоит использовать редко, потому что перебить его можно только другим !important.
:where() даёт нулевую специфичность; :is() и :not() берут специфичность самого специфичного аргумента.
**@layer**: правило из слоя, объявленного позже, бьёт любое правило из более раннего слоя независимо от специфичности, а стили вне слоёв бьют все слои. Так ресет или стили сторонней библиотеки ставят ниже собственного кода.
CSS Modules, Tailwind и BEM держат специфичность плоской (по одному классу), и это практичный способ избежать войн специфичности.
Flexbox раскладывает элементы в одном измерении (строка или колонка), и раскладку определяет размер контента; Grid раскладывает строки и колонки сразу, а дорожки задаёт контейнер. Тулбары и списки чипов делают на Flexbox, раскладку страницы и галереи карточек на Grid.
Flexbox: justify-content по главной оси, align-items по поперечной, gap между элементами, flex: 1, чтобы занять свободное место. Элементы переносятся через flex-wrap, но каждая строка независима, поэтому колонки не выравниваются.
Grid: grid-template-columns, grid-template-areas, единицы fr, элементы на несколько ячеек. Колонки выравниваются между строками.
Адаптивность без медиазапросов: repeat(auto-fill, minmax(240px, 1fr)) вмещает столько колонок, сколько позволяет ширина.
Subgrid, который с 2023 года поддерживают все основные браузеры, позволяет вложенным элементам выравниваться по дорожкам родителя, например заголовкам карточек в одном ряду.
Их совмещают: страница на Grid, у которой шапка сделана строкой на Flexbox.
localStorage и sessionStorage синхронно хранят строки, около 5 МБ на origin; первый живёт постоянно, второй пока открыта вкладка. Cookies крошечные (около 4 КБ) и уходят на сервер с каждым подходящим запросом; IndexedDB служит асинхронной базой для больших структурированных данных.
**localStorage**: переживает перезапуск, общий для всех вкладок origin и генерирует событие storage в других вкладках. Он синхронный, поэтому большое чтение блокирует главный поток.
**sessionStorage: свой у каждой вкладки; перезагрузка его сохраняет, закрытие вкладки очищает.
Cookies**: единственное хранилище, которое сервер может записать и прочитать; ими управляют HttpOnly, Secure, SameSite и Expires. Каждая cookie добавляет байты к каждому запросу.
IndexedDB: сотни мегабайт, индексы и транзакции, доступна в service worker. Сырой API неудобный, поэтому берут idb или Dexie. Cache Storage хранит HTTP-ответы для офлайна.
Всё, что может прочитать JavaScript, может прочитать и XSS-нагрузка, поэтому секретам не место в localStorage. Safari к тому же может очистить хранилище, записанное скриптом, если пользователь не заходил на сайт семь дней.
GET читает, POST создаёт ресурс или запускает действие, PUT заменяет ресурс телом запроса, PATCH меняет его часть, DELETE удаляет. Безопасные методы (GET, HEAD, OPTIONS) ничего не меняют; идемпотентные (безопасные плюс PUT и DELETE) при повторе оставляют то же состояние.
Идемпотентность касается состояния сервера, а не ответа: второй DELETE /orders/7 может вернуть 404, но заказа нет в любом случае. Второй POST /orders создаёт второй заказ.
**PUT или PATCH**: PUT отправляет ресурс целиком, поэтому поле, которое вы не передали, очищается; PATCH отправляет только изменения. Идемпотентность PATCH не гарантирована: { "op": "increment" } не идемпотентен.
Повторы: идемпотентный запрос можно безопасно отправить ещё раз после таймаута; для POST нужен ключ идемпотентности, чтобы сервер отбросил дубль.
**Никогда не меняйте состояние в GET**: браузеры и CDN кешируют ответы на GET, а префетчеры, превью ссылок и краулеры отправляют GET без спроса. GET /logout или GET /delete?id=7 сработает случайно и станет лёгкой целью для CSRF.
Первая цифра задаёт класс: 2xx успех, 3xx редирект, 4xx проблема с запросом, 5xx сбой на сервере. 401 означает, что сервер не знает, кто вы, а 403 означает, что знает, но отказывает; 301 означает постоянный редирект, 302 временный, а 304 сообщает, что ваша копия в кеше ещё актуальна.
2xx: 201 Created после POST; 204 No Content для сохранения или удаления без тела, поэтому res.json() на нём бросает ошибку.
3xx: 301 браузеры кешируют, а поисковики переносят страницу на новый URL; 302 не запоминается. 307 и 308 образуют ту же пару, но сохраняют метод и тело, тогда как 302 после POST обычно превращается в GET. 304 Not Modified отвечает на условный запрос и приходит без тела.
401 или 403: на 401 обновите токен или отправьте пользователя на вход; на 403 покажите «нет доступа», потому что повторный вход не поможет.
Другие 4xx: 400 некорректный запрос, 404 не найдено, 409 конфликт (кто-то другой изменил запись), 422 ошибки валидации, которые показывают у полей, 429 слишком много запросов (ждите Retry-After).
5xx: 500 означает баг на сервере; 502, 503 и 504 приходят от прокси или балансировщика, когда бэкенд лежит, перегружен или медленный. Их повторяют с экспоненциальной задержкой, а большинство 4xx повторится с той же ошибкой.
HTTP/2 передаёт много запросов параллельно по одному TCP-соединению со сжатыми заголовками, а HTTP/3 переносит ту же модель на QUIC поверх UDP, поэтому один потерянный пакет больше не тормозит все потоки. Шардирование доменов теперь вредит, а число файлов больше не повод для одного большого бандла.
HTTP/1.1: один запрос за раз на соединение и около шести соединений на хост, поэтому запросы стоят в очереди за медленным. Отсюда старые приёмы: один огромный бандл, спрайты, встроенные картинки, дополнительные хосты (static1., static2.).
Мультиплексирование в HTTP/2: множество потоков чередуется в одном соединении, а сжатие HPACK отправляет повторяющиеся заголовки вроде cookie один раз, а не с каждым запросом.
Слабое место HTTP/2: он всё ещё работает поверх TCP, поэтому один потерянный пакет задерживает все потоки, пока его не перешлют, и это заметно в мобильных сетях. Chrome убрал Server Push в 2022 году в пользу 103 Early Hints и preload.
HTTP/3: QUIC восстанавливает каждый поток после потерь независимо, встраивает TLS 1.3 в рукопожатие (один round trip, ноль при возобновлении) и сохраняет соединение, когда телефон переключается с Wi-Fi на мобильную сеть.
Шардирование доменов теперь вредит: каждый лишний хост стоит DNS, TCP и TLS и дробит единственное приоритизированное соединение.
Бандлинг: число файлов больше не вынуждает делать один огромный бандл, но сотни крошечных модулей всё равно хуже сжимаются и требуют больше запросов. Делите код по роутам и вендорам, чтобы деплой инвалидировал меньше кеша.
Скрипт переиспользуется год без запросов; index.html сохраняется, но перепроверяется перед каждым использованием ценой ответа 304; /api/me не сохраняется никогда; список товаров берётся из кеша 60 секунд, потом ещё до 300 секунд отдаётся устаревшим, пока фоновый запрос его обновляет.
**max-age с immutable**: пока ответ свежий, браузер вообще не обращается к серверу, а immutable запрещает перепроверку даже при перезагрузке. Это безопасно только потому, что хеш в имени файла меняется с каждой сборкой.
**no-cache не означает «не кешировать»**: ответ сохраняется, но перед каждым использованием браузер отправляет If-None-Match: "v42". Если ETag совпадает, сервер отвечает 304 Not Modified без тела: вы платите за round trip, а не за загрузку.
**no-store** и есть настоящее «не хранить»: в HTTP-кеш ничего не попадает. Его ставят на личные и чувствительные данные. private устроен иначе: браузер может сохранить ответ, нельзя только CDN и прокси.
**stale-while-revalidate=300: с 60-й по 360-ю секунду после ответа устаревшая копия используется сразу, а фоновый запрос обновляет кеш; после 360 секунд следующее использование ждёт сеть.
Отсутствие Cache-Control** не означает «без кеша»: при заголовке Last-Modified браузеры сами угадывают срок жизни (часто 10% от времени с последнего изменения), и так старый index.html залипает на часы. Всегда задавайте заголовок явно.
Браузер находит IP домена через DNS, открывает соединение TCP или QUIC, согласует TLS, отправляет HTTP-запрос и получает HTML. Затем он разбирает HTML, загружает CSS, скрипты и картинки, строит DOM и CSSOM и выполняет layout, paint и композитинг, чтобы показать пиксели.
Сеть
Сначала кеши: HSTS может поднять URL до https; service worker или свежая запись HTTP-кеша могут ответить вообще без сети.
DNS: кеш браузера, кеш ОС, затем резолвер; ответом будет IP-адрес.
Соединение: TCP-рукопожатие плюс рукопожатие TLS 1.3 или HTTP/3 поверх QUIC, который объединяет оба. Соединение переиспользуется для следующих запросов.
Запрос и ответ: GET с cookies и заголовками; ответом может быть редирект (301, 302) или ответ с узла CDN.
Рендеринг
Парсер строит DOM постепенно, а preload scanner заранее начинает загружать CSS, скрипты и картинки, которые видит впереди.
CSS блокирует отрисовку, классический <script> блокирует разбор.
Из DOM и CSSOM получается render tree, затем layout, paint и composite дают первый кадр. DOMContentLoaded срабатывает, когда HTML разобран, load, когда загрузились все ресурсы.
В SPA с клиентским рендером
HTML часто оказывается пустой оболочкой, и контент ждёт, пока JS-бандл загрузится и выполнится. Эта задержка служит главным аргументом за SSR или пререндер.
Браузер разбирает HTML в DOM, а CSS в CSSOM, объединяет их в render tree из видимых узлов, вычисляет размер и положение каждого блока (layout), рисует пиксели каждого слоя (paint) и отдаёт GPU собрать слои в итоговый кадр (composite).
CSS блокирует отрисовку: ничего не рисуется, пока CSSOM не готов, чтобы не было вспышки нестилизованного контента. Критический CSS стоит держать маленьким, а остальное загружать позже.
Скрипты блокируют разбор: классический скрипт может читать или менять DOM и стили, поэтому разбор его ждёт. Помогают defer или type="module".
Render tree: элементы с display: none в него не попадают; элементы с visibility: hidden всё равно занимают место.
После первого кадра тот же конвейер запускается при каждом изменении: изменение геометрии перезапускает layout, смена цвета только paint, а transform или opacity может обработать один композитор.
Как измерить: панель Performance в DevTools показывает каждый шаг; FCP и LCP отражают то, что чувствует пользователь.
Reflow (layout) пересчитывает размер и положение элементов и стоит дорого; repaint перерисовывает пиксели без изменения геометрии. Layout thrashing возникает, когда код в цикле чередует запись в DOM и чтение layout, и каждое чтение вызывает синхронный reflow.
Что вызывает reflow: изменение width, height, margin, top или размера шрифта, добавление и удаление элементов, ресайз окна.
Только repaint: color, background-color, visibility.
Только композитор: transform и opacity у элемента со своим слоем, поэтому анимации стоит строить на них.
Принудительный синхронный layout: чтение offsetHeight, getBoundingClientRect(), scrollTop или getComputedStyle() сразу после записи заставляет браузер посчитать layout немедленно, а не один раз за кадр.
Исправление: сначала все чтения, потом все записи, или записи в requestAnimationFrame. Панель Performance помечает проблему как «Forced reflow».
outer capture, button, outer bubble, document: обработчики погружения срабатывают по пути вниз от window к цели, затем обработчики самой цели, затем обработчики всплытия по пути обратно вверх.
Порядок регистрации между фазами не важен: outer capture добавлен вторым, но срабатывает первым, потому что погружение идёт до того, как событие дойдёт до цели.
Внутри одного элемента и одной фазы обработчики срабатывают в порядке добавления.
На самой цели обработчики с capture срабатывают раньше остальных во всех современных браузерах, как требует спецификация.
event.stopPropagation() в обработчике кнопки отменил бы outer bubble и document; outer capture к тому моменту уже сработал.
event.eventPhase показывает фазу: 1 погружение, 2 на цели, 3 всплытие.
z-index упорядочивает элементы только внутри одного контекста наложения (stacking context). Если предок создаёт свой контекст (transform, opacity меньше 1, filter, position с z-index, isolation), его потомки не поднимутся выше соседей этого предка при любом своём z-index.
Контекст наложения рисуется как единое целое: всё поддерево занимает место предка в родительском контексте, а значения z-index у потомков работают только внутри него.
У элемента с position: static z-index ничего не делает, если только он не элемент flex- или grid-контейнера.
Отладка: найти ближайшего предка, который создаёт контекст; помогает панель Layers в DevTools.
Исправления: отрендерить элемент в другом месте через портал, использовать нативный <dialog> или Popover API (top layer находится поверх всего) или убрать свойство, которое создаёт контекст.
isolation: isolate создаёт контекст намеренно, чтобы внутренние z-index компонента не вытекали наружу и не спорили с остальной страницей.
Сначала семантический HTML (button, a, label, заголовки, landmarks), всё должно работать с клавиатуры с видимым фокусом, у картинок и кнопок-иконок нужен текстовый эквивалент, нужен достаточный контраст цветов и управление фокусом, когда UI меняется без перезагрузки страницы.
Семантика: <button> бесплатно даёт фокус, активацию по Enter и Space и правильную роль; у <div onClick> ничего этого нет.
Формы: у каждого поля есть <label htmlFor> или aria-label; сообщения об ошибках связаны через aria-describedby.
Картинки: осмысленный alt или alt="" для декоративных.
Фокус: никогда не убирать outline без замены (:focus-visible); после клиентской смены роута переводить фокус на главный заголовок; модалка удерживает фокус внутри и возвращает его при закрытии.
Контраст: WCAG AA требует 4.5:1 для основного текста и 3:1 для крупного текста и элементов UI.
Проверка: eslint-plugin-jsx-a11y, axe в тестах, запросы Testing Library по роли (getByRole('button', { name: 'Save' })) и ручной проход с VoiceOver или NVDA.
<div> с onClick не получает фокус, не реагирует на Enter и Space и не имеет роли, а у иконки нет текстового эквивалента, поэтому с клавиатуры до элемента не добраться, а скринридер не сообщает ничего полезного. Нужен <button> с доступным именем.
<button type="button"> достижим через Tab, генерирует click по Enter и Space и озвучивается как кнопка.
Имя: кнопке только с иконкой нужен aria-label (или визуально скрытый текст); декоративная картинка получает alt="", чтобы её не зачитывали поверх метки.
Состояние: aria-expanded и aria-controls сообщают вспомогательным технологиям, открыто ли меню и каким элементом управляет кнопка.
Залатать div через role="button", tabIndex={0} и обработчик onKeyDown можно, но это повторная реализация того, что браузер даёт бесплатно, и в ней легко ошибиться.
eslint-plugin-jsx-a11y сообщает об обеих проблемах (click-events-have-key-events, alt-text).
ARIA представляет собой набор HTML-атрибутов (role, aria-*), которые сообщают вспомогательным технологиям, что это за элемент, в каком он состоянии и с чем связан, когда одного HTML не хватает. Первое правило ARIA: если есть нативный элемент с нужным смыслом, используйте его.
Роли вроде dialog или tablist; состояния вроде aria-expanded, aria-selected, aria-checked; связи вроде aria-labelledby, aria-describedby, aria-controls; live-регионы (aria-live="polite") озвучивают обновления, например тосты.
ARIA меняет только то, что озвучивается, а не поведение: role="button" не делает элемент фокусируемым или управляемым с клавиатуры.
Неправильная ARIA хуже, чем никакой: неверная роль или устаревший aria-expanded прямо вводят пользователя скринридера в заблуждение. Ежегодное исследование WebAIM Million находит на страницах с ARIA больше ошибок, чем на страницах без неё.
Частые ошибки: aria-label на обычном div без роли, aria-hidden="true" на фокусируемом элементе, лишний role="button" на <button>.
Для сложных виджетов (комбобокс, табы, дерево) нужно следовать паттернам WAI-ARIA Authoring Practices или брать готовые из React Aria или Radix.
При XSS (межсайтовом скриптинге) контент атакующего выполняется как скрипт на вашей странице с сессией пользователя. React экранирует каждую строку в JSX, поэтому {comment} показывается как текст; защита кончается там, где JSX обходят: dangerouslySetInnerHTML, URL и прямой доступ к DOM.
**dangerouslySetInnerHTML: сначала санитизировать HTML через DOMPurify или рендерить Markdown библиотекой, которая не пропускает сырой HTML.
URL**: экранирование не делает URL безопасным. href={user.website} со значением javascript:alert(1) выполнит код по клику, поэтому для ссылок от пользователей разрешают только https:, http: или mailto:.
Прямой DOM: ref.current.innerHTML, insertAdjacentHTML, eval, new Function и строковый setTimeout полностью обходят React.
SSR: если сериализовать стейт в тег <script> обычным JSON.stringify, то </script> внутри данных вырвется наружу; нужно экранировать < или использовать сериализатор фреймворка.
Content Security Policy ограничивает ущерб: script-src 'self' с nonce не даёт выполниться внедрённым инлайн-скриптам, а HttpOnly cookies держат токен сессии вне досягаемости внедрённого скрипта.
CORS определяет, как сервер сообщает браузеру, что JavaScript с другого origin может читать его ответы: браузер соблюдает same-origin policy и отдаёт ответ, только если в нём есть подходящий Access-Control-Allow-Origin. Чинят это на сервере или прокси на том же origin, не во фронтенде.
Origin: схема, хост и порт. app.example.com и api.example.com являются разными origin.
Запрос всё равно доходит до сервера: CORS блокирует чтение ответа, а не отправку запроса, поэтому CORS не защищает от CSRF.
Preflight: для «непростого» запроса (метод кроме GET, HEAD или POST, Content-Type: application/json, свой заголовок вроде Authorization) браузер сначала отправляет OPTIONS с Access-Control-Request-Method и Access-Control-Request-Headers. Сервер отвечает, что он разрешает, и может закешировать ответ через Access-Control-Max-Age.
Credentials: для credentials: 'include' нужны Access-Control-Allow-Credentials: true и точный origin, а не *.
В разработке server.proxy в Vite делает запросы к API запросами на тот же origin; в продакшене API отдают с того же сайта или настраивают allow-list на сервере.
mode: 'no-cors' ничего не исправляет: он возвращает непрозрачный ответ, который нельзя прочитать.
CSP задаётся заголовком ответа и сообщает браузеру, какие скрипты, стили, фреймы и соединения разрешены странице, поэтому внедрённая разметка не выполнится, даже попав в DOM. Сначала включите режим report-only, переведите инлайн-скрипты на nonce или хеши и только потом включайте блокировку.
Строгая политика
script-src 'nonce-…' 'strict-dynamic': выполняются только скрипты со случайным nonce этого ответа и скрипты, которые они загружают. Списки разрешённых хостов (script-src cdn.example.com) слабее: любой JSONP-эндпоинт или устаревшая библиотека на разрешённом хосте становится обходом.
object-src 'none' и base-uri 'none' закрывают старые приёмы внедрения, а frame-ancestors 'self' защищает от кликджекинга.
Внедрение
Report only: отправляйте Content-Security-Policy-Report-Only с эндпоинтом для отчётов. Ничего не блокируется, нарушения приходят в виде JSON. Собирайте их неделю-две на реальном трафике.
Инвентаризация: инлайн-блоки <script> (сниппеты аналитики, скрипт темы, сериализованное состояние), атрибуты onclick="", eval в старых библиотеках и тег-менеджеры, которые подгружают ещё скрипты.
Исправление: вынесите инлайн-код в файлы, поставьте nonce на оставшиеся скрипты, добавьте те сторонние сервисы, которыми действительно пользуетесь.
Блокировка сначала для части трафика с включёнными отчётами, затем для всех.
Nonce или хеши
Nonce должен быть новым в каждом ответе, поэтому HTML приходится рендерить на каждый запрос. В Next.js заголовок ставит proxy.ts, а фреймворк сам добавляет nonce своим скриптам, но такие страницы становятся динамическими и теряют статическое и CDN-кеширование.
Статический SPA на CDN не может менять свой HTML: обходитесь без инлайн-скриптов с script-src 'self' или используйте хеши ('sha256-…') немногих инлайн-скриптов, посчитанные при сборке.
Следующий шаг после CSP: Trusted Types (require-trusted-types-for 'script'), с ними innerHTML и похожие точки записи принимают только очищенные значения; на 2026 год их поддерживают все основные браузеры.
Подделка межсайтового запроса (CSRF) заставляет браузер пользователя отправить запрос на ваш сайт с другого сайта, приложив cookies пользователя. Cookies с SameSite не дают браузеру прикладывать их к межсайтовым запросам; CSRF-токен доказывает, что запрос пришёл с вашей собственной страницы.
Уязвима только авторизация на cookies: токен в заголовке Authorization не отправляется автоматически, поэтому API на bearer-токенах не подвержен классическому CSRF (а XSS подвержен).
**SameSite=Lax**, значение по умолчанию в Chrome и Edge, если атрибут не указан, отправляет cookie при переходах верхнего уровня через GET, но не при межсайтовых POST, fetch и в iframe. **Strict никогда не отправляет её с другого сайта, поэтому пользователь, пришедший по ссылке из письма, оказывается разлогинен. None** требует Secure и нужен для настоящего межсайтового использования, например встраиваемых виджетов.
Same-site не равно same-origin: evil.example.com считается тем же сайтом, что и app.example.com, поэтому скомпрометированный поддомен обходит SameSite.
Токены: synchronizer token, который хранится в сессии и отправляется в заголовке или скрытом поле, или double-submit cookie; серверные фреймворки генерируют их сами.
Правила: никогда не менять состояние на GET; проверять заголовок Origin или Sec-Fetch-Site у изменяющих запросов как дешёвый дополнительный слой.
Самая надёжная типовая схема: короткоживущий access-токен в памяти плюс refresh-токен в cookie с HttpOnly, Secure и SameSite, или только сессионная cookie, а токены хранит backend-for-frontend. Украсть проще всего из localStorage: любая XSS-нагрузка может его прочитать и отправить наружу.
Варианты
**localStorage: просто и переживает перезагрузку, но читается любым скриптом на странице, включая скомпрометированную npm-зависимость.
HttpOnly cookie**: JavaScript не может её прочитать, поэтому XSS не может её скопировать; но браузер отправляет её автоматически, поэтому нужны SameSite и защита от CSRF, а API на другом origin требует CORS с credentials.
Память: пропадает при перезагрузке, поэтому приложение при старте вызывает эндпоинт обновления (с cookie); XSS может пользоваться токеном, пока страница открыта, но не может его унести.
Backend-for-frontend
Браузер хранит только сессионную cookie; тонкий сервер держит OAuth-токены и добавляет их к запросам в API. Рекомендации IETF по OAuth для браузерных приложений называют эту схему самой безопасной.
Чего хранение не исправит
Пока работает скрипт атакующего, он может отправлять запросы от имени пользователя, что бы вы ни выбрали. Хранилище определяет, как долго и откуда работает украденный доступ; от самой XSS защищают CSP и экранирование вывода.
Приложение отправляет пользователя к провайдеру с хешем одноразового секрета, провайдер возвращает пользователя с короткоживущим кодом, а приложение обменивает код и исходный секрет на токены. Этот секрет (PKCE) заменяет client secret, который браузерное приложение не может хранить.
Поток
Старт: сгенерируйте случайный code_verifier, вычислите code_challenge = BASE64URL(SHA-256(verifier)), сохраните verifier и случайный state в sessionStorage и сделайте редирект на /authorize с response_type=code, client_id, redirect_uri, scope, state и challenge.
У провайдера: пользователь входит и даёт согласие на домене провайдера; приложение никогда не видит пароль.
Callback: провайдер делает редирект на redirect_uri?code=…&state=…. Приложение проверяет, что state совпадает с сохранённым, и это не даёт атакующему подсунуть свой код (login CSRF), а затем убирает код из URL.
Обмен: POST /token с кодом и code_verifier. Провайдер хеширует verifier, сравнивает его с сохранённым challenge и возвращает access-токен, refresh-токен и, при OpenID Connect, id_token с описанием пользователя.
Зачем PKCE
SPA является публичным клиентом: всё, что лежит в бандле, включая «секрет», можно прочитать. Без PKCE код, утёкший через историю, логи или вредоносное расширение, может обменять любой, у кого он есть; с PKCE это может только владелец verifier.
Старый implicit flow (response_type=token) возвращал токен прямо в URL, откуда он утекал через историю и referrer. RFC 9700 (2025) не рекомендует его использовать и требует PKCE для публичных клиентов.
В продакшене
Берите поддерживаемую библиотеку (oidc-client-ts или SDK провайдера), а не самописный код, и регистрируйте точные redirect URI.
Refresh-токены, которые хранит браузер, должны ротироваться при каждом использовании или быть привязаны к клиенту.
Backend-for-frontend может выполнять тот же поток на сервере как конфиденциальный клиент и отдавать браузеру только сессионную cookie.