Две вкладки одновременно обновили токены. Обе получили 200 OK, но при следующем обновлении пользователя отправило на экран входа. Такое возможно, даже если сервер меняет токены в транзакции и блокирует строку через FOR UPDATE. Клиент мог сохранить последний пришедший ответ, а токен в нём к этому моменту уже отозван.
На ручной проверке refresh работает: отправил запрос, сохранил ответ, повторил. В браузере запросы идут не по очереди, и у маленького endpoint появляются последствия, которых на ручной проверке не видно.
Обычно refresh добавляют, чтобы человек мог спокойно работать после истечения access‑токена. Сложности начинаются, когда запрос повторяется: ответ потерялся, вкладки не договорились или старым токеном воспользовался кто‑то ещё. Серверу нужно решить, что делать с сессией, хотя причину повтора он не знает.
В статье про Telegram OIDC я обещал вернуться к этой теме. За основу взял реализацию refresh‑сессий из рабочего бэкенда. Разберём повторы, а на PostgreSQL 17 с Read Committed воспроизведём гонку, в которой отзыв завершился, но новый токен остался активным. Затем соберём алгоритм и проверки для своего сервиса.
Коротко
Неиспользованный refresh не доказывает, что клиент не получил ответ.
Массовый
UPDATEв PostgreSQL под Read Committed может пропустить потомка, вставленного параллельно отзыву семейства.Порядок commit на сервере не задаёт порядок доставки ответов клиенту.
Содержание

Что хранится между обновлениями?
После входа клиент получает два токена. Access отправляет в обычные API‑запросы, а refresh обменивает на новую пару, когда access истёк. Например, первый живёт 15 минут, второй — 30 дней. Конкретные сроки выбирает приложение.
При каждом обновлении меняется refresh: R0 → R1 → R2. Эти токены относятся к одной сессии, а их цепочку будем называть семейством. Если потребуется остановить дальнейшие обновления, нужно закрыть всё семейство, включая последний выданный токен.

В исходной реализации refresh — JWT. Сервер проверяет подпись, срок и назначение токена: access нельзя предъявить вместо refresh. Но подпись не отвечает, использовали ли этот refresh раньше и завершена ли сессия. Поэтому у каждого выданного токена есть запись в БД:
Данные |
Для чего нужны |
|---|---|
|
Найти токен и проверить владельца |
|
Связать токены с сессией и семейством |
|
Сверить предъявленный секрет и проверить срок |
|
Отличить использованный токен от действующего или отозванного |
|
Найти выданного преемника |
session_id — то, что пользователь увидит как «устройство» в списке сессий; family_id — техническое семейство токенов, связанных ротациями. В исходной реализации они создаются вместе и сохраняются при каждой ротации, поэтому дальше достаточно говорить о семействе.
Вместо самого refresh сохраняется SHA-256 от его значения, поэтому утечка таблицы сама по себе не выдаёт готовые bearer‑токены. При проверке хеш сравнивается функцией криптографической библиотеки с постоянным временем сравнения. JWT здесь тоже не обязателен: можно использовать случайный непрозрачный refresh, сохранив серверное состояние.
В примерах refresh приходит в Authorization: Bearer … по HTTPS. Это удобно для схем, а не рекомендация хранить токен в localStorage: для cookie нужны HttpOnly, Secure, подходящий SameSite и защита от CSRF — OWASP.
Обновили токен. Теперь пришёл повтор
Сначала запросы идут по одному. Клиент прислал R0, сервер проверил JWT и открыл транзакцию. Чтобы два обработчика не использовали одну запись одновременно, читаем её с блокировкой:
SELECT * FROM refresh_sessions WHERE jti = $1 FOR UPDATE;
$1 — параметр с идентификатором токена. После проверок владельца, хеша, срока и состояния помечаем R0 использованным, записываем R1 и фиксируем транзакцию. Только затем отправляем пару клиенту.

Оба изменения должны попасть в один commit. Иначе можно оставить использованный R0 без преемника или выдать новый токен, не запретив старый. Подписанную строку подготовить заранее можно; отправлять её до подтверждённого commit нельзя.
Теперь R0 пришёл ещё раз. Для начала выберем строгую политику: повтор использованного refresh отзывает семейство. Если первый обмен выполнил атакующий, у него уже есть R1, которым он продолжит обновлять доступ. Просто отказать старому R0 недостаточно.
Сервер при этом не знает, кто пришёл первым. Повтор мог сделать законный клиент после потери ответа. Строгая политика допускает повторный вход пользователя, чтобы закрыть семейство. Такой механизм ротации и обнаружения повторов описан в RFC 9 700 § 4.14.2 для OAuth; здесь мы применяем его идею к собственным сессиям приложения.
У отзыва есть неприятная ловушка. Если транзакционная обёртка откатывает изменения при исключении, такой обработчик только делает вид, что закрыл сессию:
в транзакции: отозвать семейство выбросить Unauthorized → обёртка выполнит ROLLBACK
Клиент увидит отказ, а токены останутся активны. Поэтому внутри транзакции сохраняем решение «отказать», коммитим отзыв и формируем 401 после выхода. Ошибка выполнения SQL по‑прежнему должна приводить к откату: разрешать commit при любых исключениях нельзя.

Получился понятный порядок, но его цена проявится при первом потерянном ответе.
Попробуем пережить потерю ответа
Сервер обменял R0 на R1 и сделал commit. Ответ до клиента не дошёл. Клиент повторяет R0 и при строгой политике теряет сессию. Можно ли дать ему восстановиться?
В исходной реализации есть такая попытка. У использованного R0 находим преемника через replaced_by_jti и блокируем его запись. Если преемник не отозван, не истёк и ещё не использован для refresh, отзываем его и выдаём новую пару с R2. Ссылка у R0 теперь указывает на R2.

Это новая выдача, а не возврат прежней пары: из сохранённого хеша восстановить секрет нельзя. Если же преемник уже участвовал в следующем обновлении, повтор старого токена приводит к отзыву семейства.
На первый взгляд условие выглядит разумно: R1 ещё не использовали, значит, можно помочь клиенту. Но неиспользованный refresh не означает неполученный ответ. Пользователь мог получить пару и работать с access ещё несколько минут. Проверим другую последовательность:
Кто‑то скопировал действующий
R0пользователя.Пользователь обменял
R0наR1и получил ответ.Второй обладатель предъявил
R0. Восстановление отозвалоR1и выдало емуR2.Пользователь позже предъявил
R1и получил отказ. В исходной реализации уже отозванная запись сразу отклоняется, поэтому этот запрос не отзывает семейство.R2остаётся активным.
Эти переходы воспроизвелись на минимальной SQL‑модели. Это не наблюдавшийся взлом сервиса: предполагаем, что у второго клиента уже есть действующий R0. Эксперимент показывает, что происходит дальше по выбранным правилам. Потеря ответа для такого результата вообще не нужна.
В этой ветке нет короткого окна восстановления или счётчика перевыдач.
Пять предъявлений одного
R0в модели дали первую ротацию и ещё четыре восстановления.
В рабочем коде сроки токенов продолжают ограничивать повтор, но это не равнозначно разрешению повторить запрос только несколько секунд.
Сопоставим последствия двух разобранных политик. В первых трёх строках R1 ещё не использован:
Ситуация |
Строгий отзыв |
Перевыдача, если преемник не использован |
|---|---|---|
Ответ потерян, клиент повторил R0 |
Нужен новый вход |
Клиент получает R2 вместо R1 |
Две вкладки предъявили R0 |
Второй запрос отзывает и R1 |
Обе получают пары, но действующим остаётся R2 |
Пользователь получил R1, другой обладатель повторил R0 |
Семейство закрывается |
Второй обладатель получает R2, R1 пользователя отозван |
После R0 → R1 → R2 снова пришёл R0 |
Семейство закрывается |
Семейство закрывается: R1 уже использован |
Само разрешение повторов не делает решение ошибочным. Auth0 и Okta описывают временные послабления. У Supabase есть также возврат активного refresh при предъявлении его родителя. Это отличается от отзыва преемника и новой выдачи в нашем примере.
Нужно определить собственный контракт: какой повтор разрешён, как долго, какой токен получит клиент и что произойдёт с другими ответами. Короткое окно уменьшает возможность повторного использования, но само по себе не отличает пользователя от другого обладателя секрета.
Почему два успешных ответа ломают сессию?
Вернёмся к двум вкладкам из начала. В PostgreSQL с Read Committed первый обработчик блокирует R0, выпускает R1 и делает commit. Второй ждёт ту же строку, затем видит её обновлённое состояние. Это ожидаемое поведение FOR UPDATE.
При строгой политике второй запрос отзовёт семейство: первый ответ может быть 200, второй — 401, а действующего refresh не останется. При восстановлении второй обработчик может отозвать ещё не использованный R1 и выпустить R2. Тогда оба ответа успешны.

Но порядок commit не задаёт порядок доставки ответов:

Если клиент сохраняет каждую полученную пару, поздний ответ с R1 затрёт действующий R2. Серверная транзакция отработала правильно по выбранной политике, а клиент остался с непригодным refresh.
Поэтому одновременные обновления стоит объединять: один запрос получает новую пару, остальные ждут его результат. Для нескольких вкладок нужна координация между вкладками, а не только переменная с текущим запросом внутри каждой. Это помогает избежать случайных повторов, но не заменяет серверные проверки: второй обладатель токена правила клиента соблюдать не обязан.
Для вкладок одного origin можно использовать Web Locks API: под общей блокировкой проверить, не обновила ли токены другая вкладка, и только при необходимости отправить refresh. Через BroadcastChannel можно сообщать об обновлении, но сам канал не обеспечивает взаимное исключение. Другой вариант — централизовать refresh в Service Worker, предусмотрев восстановление состояния: браузер может остановить его между запросами, и при следующем запуске прежних значений переменных в памяти уже не будет.
До сих пор запросы спорили за одну запись R0. Теперь посмотрим, что произойдёт, если они работают с разными токенами одного семейства.
Отзыв завершился, но один токен выжил
Пусть в БД уже есть семейство R0 → R1 → R2. Первые два токена использованы, текущий — R2. Один запрос обновляет R2, другой предъявил старый R0 и должен отозвать семейство. Они начинают с разных строк, поэтому блокировка конкретного jti не остановит их одновременно.
В исходном подходе отзыв — массовый запрос:
UPDATE refresh_sessions SET revoked = true WHERE family_id = $1 AND NOT revoked;
Проверил его в изолированном PostgreSQL 17.11, Read Committed. Минимальная таблица содержит состояние токенов и связь с семейством; JWT, хеши, сроки и HTTP в эксперимент не включены. Все предъявленные токены считаются корректными и неистёкшими. Два отдельных соединения выполняют транзакции в заданном порядке:

Транзакция отзыва сначала блокирует R0 и его использованного преемника R1. Затем её UPDATE доходит до заблокированного R2 и ждёт. В этот момент ротация помечает R2 использованным, вставляет R3 и делает commit. Отзыв продолжает работу и тоже коммитится.
Итоговое чтение БД показывает: R3 не использован и не отозван. Ожидание проверялось через pg_stat_activity: транзакция действительно ждала блокировку, а не просто оказалась медленнее в случайном запуске.
Причина — снимок данных отдельной команды в Read Committed. UPDATE находит строки, видимые на момент его начала. После ожидания он может обработать обновлённую версию R2, но не подхватывает новую строку R3, вставленную параллельно. Это поведение описано в документации PostgreSQL.

Эксперимент подтверждает конкретное пересечение транзакций, не частоту проблемы в рабочем сервисе. Для других уровней изоляции результат может отличаться, вплоть до ошибки сериализации и необходимости повторить транзакцию. Но обещание «после отзыва семейство больше не обновляется» текущая блокировка строки сама по себе не обеспечивает.
Что из этого переносится на другие СУБД? Требование общее: ротация и отзыв одного семейства должны выполняться так, будто идут последовательно. Механизм зависит от хранилища. В MySQL/InnoDB по умолчанию используется Repeatable Read, а UPDATE может блокировать диапазоны индекса и мешать вставке новых строк — этот сценарий нужно проверять отдельно. Если состояние семейства хранится в одном ключе Redis, проверку и изменение можно объединить в атомарный Lua‑скрипт; отдельно прочитать состояние, а потом записать новое недостаточно.
Воспроизведение: два соединения psql, один переживший отзыв токен
Нужны два соединения psql с одной чистой тестовой БД под одним пользователем. Ни HTTP‑сервис, ни ORM не нужны. Пример проверен на PostgreSQL 17.11. Выполняйте шаги по очереди, переключаясь между соединениями A и B: отправка всех команд сразу не воспроизводит нужный порядок.
1. В A создайте таблицу и семейство R0 → R1 → R2. Эти команды выполняются вне явной транзакции, с обычным autocommit psql.
CREATE TABLE refresh_race_demo ( jti text PRIMARY KEY, family_id text NOT NULL, used_at timestamptz, revoked boolean NOT NULL DEFAULT false, replaced_by_jti text ); INSERT INTO refresh_race_demo (jti, family_id, used_at, replaced_by_jti) VALUES ('R0', 'F', now(), 'R1'), ('R1', 'F', now(), 'R2'), ('R2', 'F', NULL, NULL);
2. В A начните ротацию и удерживайте R2. После получения строки не делайте commit.
BEGIN ISOLATION LEVEL READ COMMITTED; SELECT jti FROM refresh_race_demo WHERE jti = 'R2' FOR UPDATE;
3. В B начните отзыв. Последний запрос будет ждать блокировку R2. Это ожидаемо: оставьте его выполняться и переключитесь в A. Если он завершился сразу, порядок нарушен — такой запуск не проверяет нужную гонку.
SET application_name = 'refresh_demo_B'; BEGIN ISOLATION LEVEL READ COMMITTED; SET LOCAL statement_timeout = '5min'; SELECT jti FROM refresh_race_demo WHERE jti = 'R0' FOR UPDATE; SELECT jti FROM refresh_race_demo WHERE jti = 'R1' FOR UPDATE; UPDATE refresh_race_demo SET revoked = true WHERE family_id = 'F' AND NOT revoked;
4. В A убедитесь, что B действительно ждёт именно A. Ожидаем wait_event_type = Lock и waits_for_a = t. Если их ещё нет, повторите оба запроса; pg_stat_clear_snapshot() позволяет заново прочитать статистику внутри открытой транзакции. Не переходите дальше без этого результата.
SELECT pg_stat_clear_snapshot(); SELECT wait_event_type, pg_backend_pid() = ANY(pg_blocking_pids(pid)) AS waits_for_a FROM pg_stat_activity WHERE application_name = 'refresh_demo_B' AND datname = current_database();
5. В A закончите ротацию. Именно сейчас, когда UPDATE в B уже начался и ждёт, появляется R3.
UPDATE refresh_race_demo SET used_at = now(), replaced_by_jti = 'R3' WHERE jti = 'R2'; INSERT INTO refresh_race_demo (jti, family_id) VALUES ('R3', 'F'); COMMIT;
6. Вернитесь в B. Дождитесь завершения UPDATE из шага 3: он должен сообщить UPDATE 3. Это R0, R1 и R2, которые ещё не были отозваны; R3 команда не увидела. После этого зафиксируйте отзыв и проверьте оставшиеся токены.
COMMIT; SELECT jti, revoked FROM refresh_race_demo WHERE used_at IS NULL AND NOT revoked;
Результат — одна строка:
jti | revoked -----+--------- R3 | f
Это и есть потомок, которого не было в снимке команды отзыва. В модели нет проверки JWT и сроков: «активен» здесь означает «не использован и не отозван». После завершения обеих транзакций тестовую таблицу можно удалить командой DROP TABLE refresh_race_demo;. Для повторного опыта начните с чистой таблицы; если прервали шаги, сначала выполните ROLLBACK в обоих соединениях.
Собираем общий порядок обновления и отзыва
У ротации и отзыва должна появиться общая точка согласования. Для этого добавим стабильную запись семейства с признаком revoked. Это предлагаемое изменение, в исходном подходе такой записи не было.
Теперь любой обработчик сначала блокирует семейство, затем работает с конкретным токеном. Если ротация прошла первой, отзыв дождётся её и закроет семейство вместе с новой выдачей. Если отзыв прошёл первым, ротация увидит revoked и откажет.

В варианте эксперимента с общей блокировкой после обеих транзакций активных refresh не осталось. Для реального обработчика нужна ещё обязательная проверка состояния семейства: один массовый UPDATE не должен быть единственным способом запретить дальнейшую выдачу.
Альтернативы отдельной записи семейства
Отдельная строка семейства — не единственный способ согласовать операции. В PostgreSQL можно брать транзакционную advisory‑блокировку через pg_advisory_xact_lock: новая таблица для самой блокировки не нужна, а освобождается она при завершении транзакции. Все обработчики должны одинаково преобразовывать family_id в числовой ключ и брать блокировку до работы с токенами. Сама она не хранит признак отзыва: состояние по‑прежнему нужно записывать и проверять. Здесь отдельная запись удобна тем, что объединяет состояние семейства и точку блокировки.
Дополнительно можно закрепить в БД правило «не более одного неиспользованного и неотозванного токена на семейство»: частичный уникальный индекс по family_id с условием WHERE used_at IS NULL AND NOT revoked. Для этого family_id и revoked должны быть NOT NULL, а старый токен нужно помечать использованным или отозванным до вставки нового. Срок действия проверяется отдельно: условие с текущим временем не подходит для предиката индекса. Такой индекс защищает от появления двух активных записей, но не закрывает показанную гонку отзыва: единственный оставшийся R3 его не нарушает.
Соберём порядок для строгой политики. Она подходит как исходная точка, если повторный вход после потерянного ответа приемлем. Ниже псевдокод для Read Committed; проверки некорректного входа сокращены, но должны выполняться до выдачи:
проверить подпись, срок, назначение и обязательные поля JWT найти family_id по серверной записи jti в транзакции: заблокировать семейство FOR UPDATE заблокировать и перечитать запись токена проверить владельца, хеш и принадлежность семейству проверить актуальные сроки после ожидания блокировок если семейство отозвано: результат = ОТКАЗ иначе если токен уже использован: установить family.revoked = true результат = ОТКАЗ иначе если токен отозван: результат = ОТКАЗ иначе: создать новую пару пометить старый refresh использованным записать новый refresh в том же семействе результат = НОВАЯ_ПАРА COMMIT после подтверждённого COMMIT: вернуть отказ или новую пару
Несуществующие записи и неуспешные проверки не должны проваливаться в ветку выдачи. Идентификатор семейства нельзя брать на веру из произвольного клиентского параметра. Порядок блокировок должен быть одинаковым в refresh, восстановлении, logout и отзыве. Если один путь его обходит, гарантия снова пропадает.
Что ещё закрепить в контракте:
Неизвестный исход commit. При потере соединения сервер не объявляет неподтверждённый успех; строгая политика допускает новый вход после повтора. Если это неприемлемо, восстановление проектируют отдельно с учётом разобранных последствий.
Logout и выданный access. Logout закрывает семейство под той же блокировкой, но access‑JWT с прежними правами работает до
exp, если API проверяет только подпись и срок. Для немедленного запрета запросов нужна проверка серверного состояния.Срок сессии и хранение истории. Ротация может продлевать срок токена постоянно, поэтому абсолютный срок сессии хранят отдельно и проверяют после блокировки, не продлевая при refresh. Старые записи сохраняют, пока они нужны для распознавания допустимых по сроку повторов.
Выход на всех устройствах. Для отзыва одновременно с созданием новых сессий нужна координация на уровне пользователя. Блокировка одного семейства этой гарантии не даёт.
Что проверить в своём сервисе?
Начал бы с пяти проверок. Они полезны и для строгой политики, и для собственного варианта восстановления; отличаются ожидаемые результаты.
Проверка |
Что должно быть видно |
|---|---|
Ошибка между изменением старого токена и записью нового |
Нет частично выполненной ротации |
Повтор, который должен отозвать семейство |
Новое чтение БД после отказа подтверждает сохранённый отзыв |
Два обновления одного refresh |
Проверено состояние обоих выданных токенов и поведение клиента при обратном порядке ответов |
Отзыв через старый токен одновременно с ротацией текущего |
После завершения отзыва нельзя продолжить семейство |
Logout |
Дальнейший refresh запрещён, судьба выданного access соответствует отдельному контракту |
Для проверки конкуренции нужны отдельные соединения и управляемое пересечение транзакций. Просто запустить два запроса параллельно недостаточно: они могут случайно пройти последовательно. А проверка только 200 и 401 не покажет, остался ли рабочий токен там, где его быть не должно.
Отдельная проверка для восстановления: повторите сценарий «ответ получен, но новый refresh ещё не использован». Затем предъявите отозванного преемника. Именно этот следующий запрос покажет, обнаруживает ли система конфликт или лишь отправляет одного из клиентов на экран входа.
Для диагностики пригодятся исходы rotated, recovered, reuse_revoked и внутренний идентификатор семейства. Сам по себе рост повторов ещё не доказывает атаку: это повод сопоставить события и проверить клиентскую координацию. Значения токенов в логи не записываем.
Что осталось за рамками?

Рядом с refresh в модуле авторизации обычно лежат Argon2id (пароли, не токены — OWASP), ротация ключа подписи (RFC 8725) и JWKS (RFC 7517). Это другие задачи: они не заменяют отзыв сессии и не решают, как обрабатывать повтор refresh.
Сначала контракт, потом проверка

Для своего /refresh сначала стоит записать, что произойдёт после повтора, потери ответа и одновременного отзыва. Затем проверить это по состоянию БД и пригодности выданных токенов. Так становится видно, какую гарантию даёт транзакция, где нужна координация клиента и какие последствия восстановления вы готовы принять.
funca
Разве тащить в браузер авторизацию на токенах это не моветон? Используйте сессии, и раздавайте через какой-нибудь oauth2-proxy.