Попробуйте выбрать срок жизни одного токена для настоящего сервиса — и упрётесь в компромисс без выигрышной стороны. Access живёт пятнадцать минут: украденный токен быстро мёртв, но человек, который работает час, к четверти часа получает разлогин — продукт с такой схемой бросят. Access живёт неделю: удобно, но кража открывает неделю доступа, а отозвать stateless-токен без хранилища нельзя — сервер просто обязан верить подписи до exp. Обе крайности лечатся разделением полномочий, которое пришло из OAuth 2.0: работать с API ходит короткий access-токен, а обновляет его долгий refresh, годный только в одном месте — на эндпоинте обновления. Перелогин исчезает: клиент раз в пятнадцать минут незаметно для пользователя меняет access. Кража дешевеет: украденный access мёртв через минуты, а украденный refresh можно вычислить по повторному использованию. Схема пришла из OAuth 2.0 и давно переехала в обычные API — от SPA до мобильных клиентов и CLI, где cookie нет вовсе. Запросы refresh token go в поисковиках — про эту пару, и в этом уроке соберём её целиком: выпуск, различение по типу, ротацию и отказ на реюз.
Пара access/refresh: кто на что годен#
Два токена — два набора полномочий, и путать их нельзя. Access-токен короткий, от пяти до пятнадцати минут в распространённой практике: им подписывают каждый запрос к API, его видят все сервисы, и компрометация стоит минуты. Refresh-токен долгий, от суток до пары недель по тем же практикам: он появляется в трафике один раз за цикл обновления, ходит только на один эндпоинт и годен ровно на одно действие — получить новую пару. Реализация в golang-jwt v5 минимальна: подпись у обоих одна, HS256 с тем же секретом из урока про HMAC, а различает их поле в payload. Сроки из практики, а не из стандарта: RFC 6749 описывает механизм refresh-токена, но конкретные TTL оставляет сервису — их выбирают из модели риска, а не из документа. Удобнее всего добавить в свой claims-тип строковое поле TokenType и писать в него access или refresh; годятся и отдельные структуры claims под каждый токен — тогда правильный тип гарантируется самим типом Go, и перепутать сложнее. На стороне проверки разница одной строкой: refresh-эндпоинт отвергает токен, у которого TokenType равен access, а middleware API отвергает refresh. Без этой проверки пара вырождается: долгий refresh становится вечным пропуском в API, и весь смысл схемы исчезает.
Выпуск пары access/refresh
GOconst (
accessTTL = 15 * time.Minute
refreshTTL = 7 * 24 * time.Hour
)
func issuePair(userID int64, role string) (string, string, error) {
access, err := buildToken(userID, role, "access", accessTTL)
if err != nil {
return "", "", err
}
refresh, err := buildToken(userID, role, "refresh", refreshTTL)
if err != nil {
return "", "", err
}
return access, refresh, nil
}
func buildToken(userID int64, role, typ string, ttl time.Duration) (string, error) {
now := time.Now()
claims := AppClaims{
UserID: userID,
Role: role,
TokenType: typ,
RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", userID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
ID: newJTI(),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).
SignedString([]byte(os.Getenv("JWT_SECRET")))
}Один конструктор на оба токена: различие — TokenType и ttl, всё остальное общее. TTL задан константами — пятнадцать минут и неделя, диапазоны меняются в одном месте. Поле ID — jti, уникальный идентификатор токена: сейчас он кажется лишним, но на нём держится и ротация, и отзыв из текста ниже. Подпись — та же HS256 и тот же секрет, что в пятом уроке.
Ротация refresh-токенов#
Ротация — правило, на котором стоит вся пара: каждое обращение к refresh-эндпоинту выдаёт новую пару и убивает старый refresh. Схема выглядит так: клиент приносит refresh, сервер проверяет подпись и срок, убеждается по TokenType, что это действительно refresh, проверяет по jti, что токен не отозван, — и возвращает свежую пару, записывая старый jti в список мёртвых. Хранилище здесь неизбежно: чистый stateless не умеет забывать, а ротация без забывания — декорация. Подойдёт Redis с записью jti и TTL, равным остатку срока жизни токена: запись сама исчезнет, когда умрёт и её владелец, — механика разобрана в курсе по Redis. Временная шкала на живом примере: логин в десять часов дал access на пятнадцать минут и refresh на неделю; в 10:14 клиент обменял пару — получил новый access до 10:29 и свежий refresh на неделю вперёд, а старый jti лег в список мёртвых и сам исчезнет через остаток своего срока. У ротации есть бонус, ради которого её все и терпят: повторное использование старого refresh — почти точный сигнал кражи. Честный клиент держит одну актуальную пару; вторая попытка с уже отозванным jti значит, что где-то токен скопировали. По рекомендациям безопасности OAuth в этом случае отзываются все токены цепочки — пользовательский refresh и все access, выпущенные с ним: неудобно на минуту, зато атака обрывается целиком.
Эндпоинт обновления
GOfunc refreshHandler(w http.ResponseWriter, r *http.Request) {
refreshStr, err := readBodyToken(r)
if err != nil {
http.Error(w, "нет токена в запросе", http.StatusBadRequest)
return
}
claims := &AppClaims{}
_, err = jwt.ParseWithClaims(refreshStr, claims, verifyKey,
jwt.WithValidMethods([]string{"HS256"}))
if err != nil || claims.TokenType != "refresh" {
http.Error(w, "невалидный refresh", http.StatusUnauthorized)
return
}
if revoked(r.Context(), claims.ID) {
revokeFamily(r.Context(), claims.Subject)
http.Error(w, "refresh отозван", http.StatusUnauthorized)
return
}
access, newRefresh, err := issuePair(claims.UserID, claims.Role)
if err != nil {
http.Error(w, "не удалось выпустить пару", http.StatusInternalServerError)
return
}
markRevoked(r.Context(), claims.ID, refreshTTL)
writeJSON(w, http.StatusOK, map[string]string{
"access": access, "refresh": newRefresh,
})
}Три проверки по порядку: подпись и срок через ParseWithClaims, тип по TokenType, отзыв по jti — только потом выпуск новой пары. Повторное использование сработало бы на revoked: сервер отзывает всю семью и отказывает. Старый jti уходит в чёрный список с TTL, равным остатку жизни refresh, — запись почистит себя сама.
Схема, где refresh обновляет refresh без ограничений, превращает выход в фикцию: пользователь нажал logout, а его сессия живёт вечно, потому что refresh каждый раз возрождает сам себя. Я считаю это дырой, если у сервиса есть хоть какое-то требование к выходу и отзыву. Рабочие границы: ограничить общую жизнь сессии полем в claims, например issued at, по которому refresh-цепочка умирает через месяц безусловно, — или честно поставить отзыв через хранилище. Право на бесконечный refresh есть только у сервисов, где logout не существует как понятие: фоновые интеграции с машинными токенами.
Где живёт refresh и как его хранят#
Refresh ценнее access: он дольше живёт, а значит и красть его выгоднее, поэтому хранение различается по силе. Для веба рабочий вариант — HttpOnly cookie: скрипт страницы её не читает, XSS не вытащит токен, а путь и SameSite режут поверхность атаки; тонкости SameSite и CSRF-вектора — отдельные уроки позже в курсе. Для SPA популярна связка access в памяти, refresh в HttpOnly cookie: перезагрузка страницы теряет access, но refresh молча его восстанавливает. localStorage против токенов — тема отдельного спора: доступ к нему имеет любой скрипт, и refresh в нём — мишень номер один. Мобильные клиенты хранят в защищённых хранилищах платформы. Что бы вы ни выбрали, два правила общие: refresh не кладут в URL и не пишут в логи — и то и другое оставляет копию в местах, откуда её не вычистить. Отзыв и полный жизненный цикл сессии — от выпуска до выхода — разобраны дальше по курсу, там же — denylist по jti целиком.
Access и refresh: сравнение по обязанностям#
| Свойство | Access | Refresh |
|---|---|---|
| Срок жизни | 5-15 минут, обычная практика | сутки и до пары недель |
| Где используется | каждый запрос к API | только refresh-эндпоинт |
| Что умеет | читает и пишет данные | получает новую пару |
| Частота в трафике | каждый вызов | один раз за цикл обновления |
| Хранение | память клиента или cookie | HttpOnly cookie или защищённое хранилище |
| Цена кражи | минуты до exp | высокая: отсюда ротация и отзыв |
Проверка: срок access
Какой TTL чаще всего выбирают для access-токена в паре с refresh?
Проверка: зачем TokenType в claims
Зачем в claims пары лежит поле TokenType со значением access или refresh?
Повторное использование старого refresh
Прочитайте код и выберите, что он выведет:
// Ротация работает, denylist на Redis заведён.
// Клиент дважды прислал один и тот же refresh:
// первый запрос — успех, через секунду второй:
_, err = jwt.ParseWithClaims(refreshStr, claims, verifyKey)
// err == nil, TokenType == "refresh",
// revoked(ctx, claims.ID) вернул trueAccess пришёл на refresh-эндпоинт
Прочитайте код и выберите, что он выведет:
// Клиент перепутал и прислал access вместо refresh
claims := &AppClaims{}
_, err := jwt.ParseWithClaims(accessStr, claims, verifyKey)
// err == nil, claims.TokenType == "access"Схема собрана, и честная оговорка про запуск ставит точку: паре нужен работающий HTTP и Redis, поэтому в браузерной песочнице сайта она не поднимется — нужен локальный Go, установленный пакет и запущенный Redis из курса по нему. Без запуска материал остаётся рабочим: quizzes построены на реальном поведении v5, а код эндпоинта читается построчно. Теперь у аутентификации не хватает одного звена — входа по паролю, который и выпускает пару: логин проверяет пароль против хеша, и в следующем уроке мы разберём bcrypt вместо MD5. Из него затем вырастет цельный middleware десятого урока, который будет требовать access на каждом защищённом маршруте.
Поднимите Redis из курса по нему, заведите два ключа-списка: отозванные jti и семьи. Выпустите пару из урока, обновите её curl-ом дважды одним и тем же refresh — второй запрос обязан упасть с отзывом семьи. Это лучший тест на то, что ротация действительно стоит на месте, а не только на бумаге.
Допишите проверки refresh-эндпоинта
Восстановите вызов разбора и сравнение типа токена.
Соберите скелет refresh-эндпоинта
Расставьте строки обработки refresh по порядку: от разбора до тихого выхода.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: refresh-токены
Дальше по курсу пара встраивается в полный цикл: в уроке про bcrypt вместо MD5 появится проверка пароля, которая выдаёт пару на логине, в уроке про middleware аутентификации access станет обязательным на защищённых маршрутах, а в уроке про отзыв токенов denylist по jti разберут целиком. Для хранилища ротации пригодится курс по Redis — записи с TTL там в первых уроках. Карта всех уроков и смежные темы — на странице курса: там же хранение токена на клиенте и HttpOnly cookie с их компромиссами.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Пара access/refresh решает дилемму TTL: короткий access ходит в каждом запросе, долгий refresh только обновляет пару.
- Тип токена — поле в claims: refresh-эндпоинт отвергает access, middleware отвергает refresh.
- Подпись у пары общая — HS256 и секрет из урока про HMAC; различие в TokenType и TTL.
- Ротация выдаёт новую пару на каждом обновлении и отзывает старый refresh по jti — хранилище с TTL обязательно.
- Повторное использование старого refresh — сигнал кражи: по рекомендациям OAuth отзывается вся семья токенов.
- Бесконечный refresh без общей границы сессии превращает выход в фикцию; хранят refresh в HttpOnly cookie или защищённом хранилище.
Домашнее задание
Соберите выпуск пары и refresh-эндпоинт локально: Redis для отозванных jti, два TTL-константы, проверка TokenType. Прогоните цикл: логин, запрос с access, обновление пары, второй запрос с тем же refresh — он обязан упасть с отзывом семьи. Затем добавьте в claims общую границу сессии и убедитесь, что через неё refresh-цепочка умирает.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли обойтись одним access-токеном без refresh?
Можно, если сервису не нужен долгий вход: токен на пятнадцать минут и честный перелогин. Пару строят ради удобства без потери контроля: пользователь не замечает обновлений, а сервер сохраняет короткое окно компрометации. Для API без человека — машинных интеграций — refresh чаще не нужен вовсе.
Нужен ли refresh отдельный секрет подписи?
Общий секрет с access — распространённая практика, различие делает поле TokenType. Отдельный секрет добавляет расхождений в конфигурации ради проверки, которую уже делает тип в claims. Разделять секреты имеет смысл между сервисами, а не между токенами одной пары.
Что делать, если хранилища для ротации нет?
Ротация без хранилища — имитация: старый refresh остаётся годным до exp. Рабочие минимумы: совсем без хранилища держите короткий refresh и короткую общую границу сессии; либо берите лёгкую таблицу в существующей базе — jti и срок, это одна таблица и два запроса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Telegram-боты на Go: от эха до полноценного сервиса
Учимся писать Telegram-ботов на Go: токен BotFather, команды, клавиатуры, состояния диалога, медиа, рассылки, webhook и деплой. 25 уроков с кодом.
- Создавать бота через BotFather и хранить токен безопасно
- Получать обновления через long polling и webhook
- Отвечать сообщениями, командами и клавиатурами
- Строить многошаговые диалоги с сохранением состояния