10.3Аутентификация и авторизация
Раздели authentication - кто обращается - и authorization - что ему разрешено. Сопоставь сессии, access/refresh tokens и JWT по отзыву, хранению и проверке, а затем привяжи каждый маршрут к нужной проверке.
Аутентификация, авторизация и всё, что рядом
Вся глава крутится вокруг одного слова. Токен - строка, которую сервер один раз выдал
клиенту после проверки пароля, а клиент потом прикладывает к каждому запросу вместо пароля.
Всё. После логина пароль больше никуда не летит, летит токен. Схемы различаются ответами на
два вопроса. Что внутри строки: случайный идентификатор («непрозрачный» токен, его смысл
знает только сервер) или подписанные данные, которые сервер проверит, ни у кого не спрашивая
(это JWT)? И кто прикладывает токен к запросу: браузер сам (кука) или наш JavaScript
руками (заголовок Authorization)? Из второго вопроса вырастает вся тема CSRF
в главе 10.4.
| Термин | Вопрос, на который отвечает | Пример механизма | Код ошибки |
|---|---|---|---|
| Идентификация | Кем ты называешься? | логин, email, client_id, номер карты | - |
| Аутентификация (AuthN) | Ты действительно он? | пароль, TOTP, подпись токена, mTLS-сертификат | 401 Unauthorized |
| Авторизация (AuthZ) | Что тебе можно? | роли, права, ACL, политики, владение объектом | 403 Forbidden |
| Аудит (Accounting) | Кто что сделал? | журнал действий, immutable-лог | - |
Порядок жёсткий: сначала аутентификация, потом авторизация. Нельзя решить, что человеку
можно, пока не знаешь, кто он. Названия кодов в HTTP исторически перепутаны:
401 Unauthorized на деле означает «не аутентифицирован» и обязан идти
с заголовком WWW-Authenticate, а 403 Forbidden значит
«аутентифицирован, но прав не хватает».
- Токена нет, он битый или протух →
401+WWW-Authenticate: Bearer(клиент понимает, что надо обновить токен и повторить). - Токен валиден, но прав нет →
403. Повторять бессмысленно. - Токен валиден, прав нет, а само существование объекта надо скрыть →
404. Тогда перебором не узнать, какие id существуют (так GitHub прячет приватные репозитории).
Сессии против JWT: где живёт состояние
Разница одна, и всё остальное следует из неё. У сессии состояние лежит на сервере: клиент носит непрозрачный идентификатор, сервер по нему достаёт данные. У JWT состояние у клиента: все данные внутри токена, сервер только проверяет подпись.
exp.| Критерий | Сессии (cookie + стор) | JWT (Bearer) |
|---|---|---|
| Где состояние | на сервере, клиент носит непрозрачный id | внутри токена у клиента |
| Проверка запроса | чтение из Redis/БД - сеть, ~0.2–1 мс | проверка подписи в памяти - микросекунды |
| Отзыв | мгновенный: удалить запись | невозможен без доп. механизма (список отзыва, версия токена) |
| Смена прав | применяется со следующего запроса | только после протухания токена |
| Масштабирование | нужен общий стор (или sticky sessions - хуже) | инстансы полностью независимы |
| Размер на запрос | ~30–50 байт | 300–1500 байт, растёт с claims; попадает в каждый запрос и в логи |
| Кросс-доменность | сложно (кука привязана к домену, SameSite) | легко - заголовок Authorization |
| Мобильные и сервис-к-сервису | неудобно | естественно |
| Основной риск | кража куки, CSRF, компрометация стора | кража токена (XSS), утечка ключа подписи, невозможность отозвать |
| Когда брать | монолит, классический веб, админки, всё, где нужен мгновенный logout | микросервисы, SPA + мобильные, API третьих лиц, федерация |
«JWT нужен там, где проверяющей стороне нельзя ходить в общий стор: много сервисов, федерация, внешние клиенты. Обычному веб-приложению с одним бэкендом проще и дешевле сессия в Redis, и logout с ней мгновенный. Чаще всего ошибаются так: берут JWT ради моды, а потом прикручивают к нему blacklist в Redis с проверкой на каждый запрос. Получается та же сессия, только с токеном в килобайт и лишней криптографией.» В проде прижился компромисс: короткоживущий access-JWT (5–15 минут) + refresh-токен, хранимый на сервере. Проверка идёт быстро и без стора, а отзыв запаздывает не больше чем на TTL access-токена.
Устройство JWT
JWT (RFC 7519) состоит из трёх частей через точку: header.payload.signature.
Каждая закодирована base64url (алфавит без + и /, без
паддинга, чтобы спокойно жить в URL). Но кодирование ещё не шифрование: payload
прочитает кто угодно.
kid в заголовке указывает, каким ключом подписан
токен, и благодаря ему ключи можно ротировать без даунтайма.Стандартные claims, которые обязан назвать кандидат:
| Claim | Смысл | Что будет, если не проверить |
|---|---|---|
iss | кто выпустил | примешь токен от чужого issuer, если ключ случайно совпал или доверяешь нескольким |
sub | кому выдан (id пользователя) | - |
aud | для какого сервиса | confused deputy: токен, выданный для сервиса A, примет сервис B |
exp | срок годности (unix seconds) | токен живёт вечно - главная дыра |
nbf | не действителен раньше | - |
iat | когда выпущен | нельзя отсечь «токены старше момента X» при массовом отзыве |
jti | уникальный id токена | нечем адресовать токен в списке отзыва |
HS256 против RS256
| HS256 (HMAC-SHA256) | RS256 / ES256 (асимметричные) | |
|---|---|---|
| Ключи | один общий секрет | приватный подписывает, публичный проверяет |
| Кто может выпустить токен | любой, у кого есть секрет - то есть все проверяющие сервисы | только владелец приватного ключа |
| Раздача ключей | секрет надо безопасно доставить всем | публичный ключ раздаётся открыто (JWKS-эндпоинт) |
| Скорость | очень быстро | подпись дороже, проверка RSA быстрая; ES256 компактнее |
| Размер подписи | 32 байта | RSA-2048 - 256 байт; ECDSA P-256 - 64 байта |
| Когда брать | монолит или один сервис, который и выпускает, и проверяет | микросервисы, внешние потребители, OIDC-провайдер |
Главный аргумент за RS256: принцип наименьших привилегий. С HS256 любой сервис, который
проверяет токен, технически может его и подделать. Достаточно взломать один второстепенный
сервис, чтобы выписать себе админский токен. С RS256 приватный ключ есть только
у auth-сервиса, остальные тянут публичные ключи с
/.well-known/jwks.json и кэшируют их по kid.
alg: none. Атакующий меняет заголовок на{"alg":"none"}, выкидывает подпись и правит payload ("role":"admin"). Наивная библиотека «уважает» заголовок и говорит «подпись валидна, потому что её не требуется». Отсюда правило: алгоритм задаёт сервер, а не токен.- Подмена алгоритма RS256 → HS256. Сервер ждёт RSA и передаёт в проверку публичный
ключ. Атакующий подписывает токен HMAC-ом, взяв публичный ключ как секрет: он же
общедоступен. Если код слепо берёт
algиз токена, подпись сойдётся. Защищает жёсткий whitelist допустимых алгоритмов. - Непроверенный
expделает токен вечным. Многие библиотеки проверяютexp, только если он есть, так что токен без этого claim тоже дыра. - Непроверенные
aud/iss: токен соседнего сервиса или чужого тенанта примут как свой. - Подмена
kid: еслиkidподставляют в путь к файлу ключа или в SQL-запрос, получается path traversal или инъекция.kidтакой же недоверенный ввод, ключ по нему ищут только в whitelist. - Словарное слово вместо секрета. HS256 с секретом
secretломаетсяhashcatза секунды. Нужно минимум 32 случайных байта.
// Правильная проверка на golang-jwt/jwt/v5
type Claims struct {
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
var claims Claims
token, err := jwt.ParseWithClaims(raw, &claims,
func(t *jwt.Token) (any, error) {
// 1. семейство алгоритмов фиксируем сами
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("неожиданный alg: %v", t.Header["alg"])
}
// 2. kid ищем только по whitelist, никаких путей и SQL
kid, _ := t.Header["kid"].(string)
key, ok := keyring[kid]
if !ok {
return nil, errors.New("неизвестный kid")
}
return key, nil
},
jwt.WithValidMethods([]string{"HS256"}), // 3. дублируем whitelist явно
jwt.WithIssuer("auth.example.com"), // 4. iss
jwt.WithAudience("api.example.com"), // 5. aud
jwt.WithExpirationRequired(), // 6. exp обязателен
jwt.WithLeeway(30*time.Second), // допуск на расхождение часов
)
if err != nil || !token.Valid {
problem(w, http.StatusUnauthorized, "невалидный токен")
return
}
Где хранить токен на клиенте
Вопрос-ловушка: «правильного» варианта нет, ты выбираешь, от какой атаки страдать.
localStorage / sessionStorage | httpOnly + Secure + SameSite cookie | |
|---|---|---|
| Доступ из JS | есть - любой XSS крадёт токен | нет: document.cookie её не видит |
| Уязвим к CSRF | нет: заголовок надо проставить руками | да: браузер сам шлёт куку - нужен SameSite и/или CSRF-токен |
| Отправка | вручную: Authorization: Bearer | автоматически браузером |
| Кросс-домен | просто, работает и в мобильном приложении | сложно: SameSite=None; Secure + CORS с credentials |
| Утечка в логи | заголовок часто пишут в access-логи и трассировку | кука тоже может попасть в логи, но реже |
«Из localStorage токен достанет любой JS: одна XSS-дыра, и токен уехал
атакующему, причём навсегда, потому что отозвать JWT нечем. Куку с HttpOnly
из JS не прочитать, поэтому XSS не сможет её украсть. Зато сможет слать запросы
от имени пользователя прямо из его браузера, так что XSS остаётся катастрофой в любом
случае. Практический дефолт для веба: Set-Cookie: token=…; HttpOnly; Secure;
SameSite=Lax; Path=/ плюс CSRF-защита. SPA на другом домене и мобильным подходит
Authorization: Bearer, а токен держим в памяти JS (переменная в
замыкании), refresh кладём в httpOnly-куку с Path=/auth/refresh. Тогда XSS не достанет
ни долгоживущий refresh, ни токен после перезагрузки страницы.»
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true, // недоступна из JS
Secure: true, // только по HTTPS
SameSite: http.SameSiteLaxMode, // база защиты от CSRF
MaxAge: int((15 * time.Minute).Seconds()),
// Domain намеренно не задаём: без него кука привязана к конкретному хосту
// и не утечёт на поддомены (__Host- префикс дал бы то же самое жёстче)
})
Refresh-токены: зачем, ротация, обнаружение кражи
Дилемма такая: с длинным TTL access-токена удобно, но украденный токен долго живёт; короткий TTL безопасен, но заставляет логиниться каждые 10 минут. Поэтому выдают пару токенов:
- Access - JWT, 5–15 минут, живёт в памяти клиента, летит в каждый запрос, проверяется без похода в БД.
- Refresh - непрозрачная случайная строка (не JWT), 7–30 дней, хранится
в БД в хешированном виде, летит только на
/auth/refresh, привязан к устройству и сессии.
Refresh специально делают непрозрачным: его всё равно сверяют с БД, так что подпись не нужна,
а отозвать его можно одной строкой UPDATE. В БД кладут хеш
(хватит SHA-256: энтропии 256 бит, словарём не подберёшь), чтобы дамп базы не давал
готовых сессий.
Ротация и обнаружение переиспользования
При каждом обновлении старый refresh инвалидируется, а клиент получает новый. Заодно выходит бесплатный детектор кражи: если кто-то предъявил уже использованный refresh, значит, токен ходит по рукам. Кто из двоих вор, не понять, поэтому убивают всё семейство токенов и заставляют залогиниться заново.
CREATE TABLE refresh_tokens (
id uuid PRIMARY KEY,
family_id uuid NOT NULL, -- цепочка ротаций одной сессии
user_id bigint NOT NULL,
token_hash bytea NOT NULL UNIQUE, -- sha256(секрет), не сам секрет
used_at timestamptz, -- NOT NULL => уже потрачен
revoked_at timestamptz,
expires_at timestamptz NOT NULL,
user_agent text,
ip inet
);
CREATE INDEX ON refresh_tokens (family_id) WHERE revoked_at IS NULL;
func (s *Auth) Refresh(ctx context.Context, raw string) (Pair, error) {
h := sha256.Sum256([]byte(raw))
tx, _ := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
defer tx.Rollback()
var t token
// FOR UPDATE: параллельный refresh ждёт блокировку и потом видит used_at.
// Без неё оба выпустят по живой паре, и кражу никто не заметит
err := tx.QueryRowContext(ctx,
`SELECT id, family_id, user_id, used_at, revoked_at, expires_at
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE`, h[:]).Scan(...)
if errors.Is(err, sql.ErrNoRows) {
return Pair{}, ErrUnauthorized
}
switch {
case t.RevokedAt.Valid || t.ExpiresAt.Before(time.Now()):
return Pair{}, ErrUnauthorized
case t.UsedAt.Valid:
// переиспользование: токен уже меняли, значит, утёк. Рубим всё семейство
_, _ = tx.ExecContext(ctx,
`UPDATE refresh_tokens SET revoked_at = now()
WHERE family_id = $1 AND revoked_at IS NULL`, t.FamilyID)
_ = tx.Commit()
s.alert(ctx, "refresh token reuse", t.UserID, t.FamilyID)
return Pair{}, ErrUnauthorized
}
tx.ExecContext(ctx, `UPDATE refresh_tokens SET used_at = now() WHERE id = $1`, t.ID)
newRaw, newHash := generateToken() // 32 байта из crypto/rand
tx.ExecContext(ctx, `INSERT INTO refresh_tokens
(id, family_id, user_id, token_hash, expires_at) VALUES ($1,$2,$3,$4,$5)`,
uuid.NewV7(), t.FamilyID, t.UserID, newHash, time.Now().Add(30*24*time.Hour))
// v7 сортируется по времени: вставки ложатся в хвост индекса, а не разбрасываются
if err := tx.Commit(); err != nil { return Pair{}, err }
return Pair{Access: s.issueJWT(t.UserID), Refresh: newRaw}, nil
}
В продакшене это настоящая боль. Мобильное приложение шлёт три запроса разом, все три получают
401 и все три идут обновлять токен одним и тем же refresh. Один выигрывает,
двое предъявляют «уже использованный» токен, и система честно разлогинивает пользователя,
который ничего плохого не делал. Лечат тремя способами, обычно всеми сразу:
(1) на клиенте ставят единый мьютекс/промис на обновление, остальные ждут его результата;
(2) на сервере дают grace-период: использованный токен ещё 10–30 секунд валиден и
отдаёт тот же новый токен (идемпотентный refresh); (3) берут FOR UPDATE или
уникальный индекс, чтобы гонка внутри БД разрешалась детерминированно. Назовёшь эту проблему
сам, и станет ясно, что ты с этим действительно работал.
Как разлогинить пользователя с JWT
Если коротко, по-настоящему никак. Подписанный токен валиден, пока не истёк
exp, и проверяющий сервер не обязан ни с кем сверяться. Остаётся выбрать
компромисс.
| Приём | Как работает | Цена | Задержка отзыва |
|---|---|---|---|
| Короткий TTL + refresh | access живёт 5–15 мин; при logout удаляем refresh - обновиться уже не выйдет | ноль, это и так нужно | до конца TTL access |
Blacklist по jti | Redis SET jti 1 EX ttl, проверка на каждый запрос | поход в Redis на каждый запрос - теряется главный плюс JWT | мгновенно |
Версия токена (token_version) | в claims кладём ver; в БД у юзера счётчик; logout/смена пароля увеличивают счётчик | чтение версии (кэшируется в памяти на 5–30 с) | мгновенно или до конца кэша |
not_before по пользователю | храним «токены, выпущенные раньше T, недействительны»; сравниваем с iat | то же, что версия | мгновенно |
| Убить сессию на клиенте | стереть токен из хранилища | ноль | не защищает: украденный токен продолжает работать |
«Стандартный logout: удаляю refresh-токен и стираю access на клиенте, access доживает свои
5–15 минут. Если бизнесу нужен мгновенный отзыв (бан, кража аккаунта, смена пароля),
добавляю token_version в claims и в БД. Версия кэшируется в памяти инстанса на
десятки секунд, а инкремент убивает все токены пользователя разом. Полный blacklist
по jti делаю только точечно, потому что он превращает stateless-схему в
stateful.» Отдельно проговори, что смена пароля обязана инвалидировать все сессии:
это требование, а не опция.
OAuth 2.0 и OIDC
OAuth 2.0 придуман для делегирования доступа, а не для логина: «дай этому приложению
читать мои репозитории, не отдавая ему пароль от GitHub». OIDC надстраивается над OAuth
тонким слоем и добавляет к нему аутентификацию: новый скоуп openid,
дополнительный id_token (JWT с данными о пользователе), эндпоинт
/userinfo и дискавери /.well-known/openid-configuration. Фраза
«OAuth - авторизация, OIDC - аутентификация поверх неё» закрывает половину вопроса.
| Роль | Кто это | Пример |
|---|---|---|
| Resource Owner | пользователь, владелец данных | человек с аккаунтом |
| Client | приложение, которое хочет доступ | наш веб-сервис, SPA, мобильное приложение |
| Authorization Server | выдаёт токены, аутентифицирует пользователя | Keycloak, Auth0, Яндекс ID, «Сбер ID» |
| Resource Server | API, которое принимает access-токен | наш API, GitHub API |
Клиенты делятся на confidential (серверные, умеют хранить client_secret) и
public (SPA и мобильные: секрет там спрятать негде, любой выковыряет его из бандла
или apk). Для public-клиентов PKCE и придумали.
code_verifier, которого у перехватчика нет.stateзащищает от CSRF на этапе логина: без него атакующий подсовывает свойcode, и жертва «логинится» в аккаунт атакующего. Значение случайное, привязано к сессии, проверяется на колбэке. В OIDC ту же роль дляid_tokenиграетnonce.redirect_uriсверяется на AS по точному совпадению с зарегистрированным. Wildcard и «начинается с» оставляют классическую дыру: через неё код уводят на чужой домен (open redirect + перехват кода).- Код одноразовый и короткоживущий (обычно 30–60 секунд). Если его предъявили повторно, выданные по нему токены надо отозвать.
- Почему умер implicit flow: токен приходил прямо в URL-фрагменте и попадал в историю
браузера, в
Referer, в логи прокси, а refresh-токена не было вовсе. Его место занял code+PKCE. - Другие гранты:
client_credentialsдля сервис-к-сервису (пользователя нет вообще),device_codeдля телевизоров и CLI.password(ROPC) устарел и запрещён в OAuth 2.1: клиент видит пароль пользователя.
API-ключи и Basic auth - где уместны
| Механизм | Как выглядит | Где уместен | Чего не умеет |
|---|---|---|---|
| Basic | Authorization: Basic base64(user:pass) | внутренние утилиты, healthcheck, прототипы - только поверх TLS | нет срока жизни, пароль летит в каждом запросе, нет скоупов, легко утекает в логи |
| API-ключ | Authorization: Bearer sk_live_… или X-API-Key | интеграции сервис-к-сервису, вебхуки, партнёрские API | нет пользователя, нет TTL по умолчанию, нет делегирования |
| OAuth-токен | Authorization: Bearer eyJ… | пользовательские сценарии, сторонние приложения | сложнее в реализации |
| mTLS | клиентский сертификат | внутренний трафик, service mesh, банковские интеграции | нужен PKI и ротация сертификатов |
| HMAC-подпись запроса | подпись тела + timestamp + nonce | вебхуки и платёжные API (защищает и от подмены тела, и от replay) | сложнее для клиента |
- Ключ берут из
crypto/rand, 32 случайных байта, и добавляют префикс-метку (sk_live_,sk_test_): по префиксу секрет-сканеры ловят утечки в публичных репозиториях. - В БД лежит хеш, а не сам ключ; пользователь видит ключ один раз, при создании.
- Ключ передают только в заголовке
Authorization, никогда в query-параметре: URL оседает в логах nginx, вRefererи в истории браузера. - У ключа должны быть владелец, скоупы, срок жизни, метка «последнее использование» и кнопка отзыва. Ротируют с окном, в котором валидны оба ключа.
- Сравнивают через
subtle.ConstantTimeCompareили по хешам, но не==по сырой строке.
RBAC, ABAC и middleware авторизации
RBAC: у пользователя роли, у ролей - права, а проверка звучит как
«есть ли у роли право posts:delete». Просто, понятно, легко
аудируется, но плохо выражает «можно, если это твой пост, рабочее время и сумма меньше
10 000». В ABAC решение считается функцией от атрибутов субъекта,
объекта, действия и среды. Гибко, но отлаживать труднее, и понять, «почему отказали», тоже.
| RBAC | ABAC | |
|---|---|---|
| Модель | user → roles → permissions | policy(subject, object, action, env) → allow/deny |
| Пример | «редактор может удалять посты» | «автор может удалять свой пост в первые 24 часа» |
| Плюсы | предсказуемость, простой аудит, легко объяснить бизнесу | выражает любые правила, не плодит роли |
| Минусы | взрыв числа ролей (editor_ru_premium_readonly) | сложность, риск «почему нет доступа?» без внятного ответа |
| Инструменты | таблицы в БД, Casbin | OPA/Rego, Casbin, Cedar, SpiceDB (ReBAC) |
В жизни почти всегда встречается гибрид: RBAC грубо фильтрует на входе (middleware), а владение и условия точечно проверяют в сервисном слое.
// Middleware: только грубая проверка «есть ли вообще такое право»
func RequirePerm(perm string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := UserFrom(r.Context())
if !ok {
w.Header().Set("WWW-Authenticate", `Bearer realm="api"`)
problem(w, http.StatusUnauthorized, "нужна аутентификация")
return
}
if !u.Can(perm) {
problem(w, http.StatusForbidden, "недостаточно прав")
return
}
next.ServeHTTP(w, r)
})
}
}
mux.Handle("DELETE /v1/posts/{id}", RequirePerm("posts:delete")(deletePost))
Middleware знает про маршрут, но ничего не знает про объект. Он пропустит
DELETE /v1/posts/777 от любого редактора, даже если пост чужой. Это
IDOR / broken object level authorization, номер один в OWASP API Top 10. Владение
и условия проверяют там, где объект уже загружен, то есть в сервисе, а лучше прямо
в запросе:
UPDATE posts SET … WHERE id = $1 AND author_id = $2. Ноль затронутых строк
означает «нет прав или нет объекта». Второе правило, deny by default: новый роут без
явного разрешения должен быть закрыт, а не открыт. Тест «а что если убрать декоратор» стоит
автоматизировать: пройтись по всем зарегистрированным роутам и убедиться, что у каждого есть
политика.
Хранение паролей
Пароль нельзя ни хранить в открытом виде, ни шифровать (расшифровать сможет и атакующий с ключом), ни хешировать быстрым алгоритмом. Нужна намеренно медленная функция с солью.
| Алгоритм | Стойкость к GPU/ASIC | Параметры | Вердикт |
|---|---|---|---|
MD5, SHA-1, SHA-256 | никакой: миллиарды хешей в секунду на одной видеокарте | - | нельзя - они спроектированы быстрыми |
PBKDF2 | средняя (мало памяти → хорошо параллелится на GPU) | 600 000+ итераций HMAC-SHA256 | только если требует регулятор/FIPS |
bcrypt | хорошая | cost 12 (2^12 итераций) | надёжная классика, есть в stdlib-экосистеме |
scrypt | хорошая (memory-hard) | N=2^17, r=8, p=1 | ок |
argon2id | лучшая: memory-hard + защита от side-channel | минимум OWASP: m=19 МиБ, t=2, p=1; практичнее m=64 МиБ, t=3, p=4 | дефолт для нового кода |
// bcrypt: соль внутри самого хеша
hash, err := bcrypt.GenerateFromPassword(
[]byte(pass), 12) // cost 12 ≈ 250 мс
err = bcrypt.CompareHashAndPassword(
hash, []byte(pass)) // сравнение constant-time
// bcrypt учитывает только 72 байта пароля,
// а GenerateFromPassword на более длинном
// вернёт ErrPasswordTooLong. Длинную фразу
// заранее сворачивают в SHA-256 (или берут argon2)
// argon2id
salt := make([]byte, 16)
rand.Read(salt) // с Go 1.24 ошибки не бывает
key := argon2.IDKey([]byte(pass), salt,
3, // time: число проходов
64*1024, // memory: 64 МиБ
4, // threads
32) // длина ключа
ok := subtle.ConstantTimeCompare(key, stored) == 1
// в БД: соль + все параметры + хеш,
// иначе не сможешь их поднять при ротации
- Соль своя у каждого пароля и лежит рядом с хешем открыто. Прятать пароль она
не должна: соль ломает rainbow-таблицы и делает так, что два одинаковых пароля
дают разные хеши (иначе видно, у кого пароль
123456). - Перец - общий секрет, который живёт не в БД, а в HSM/Vault или в конфиге приложения. Его подмешивают к паролю или берут ключом HMAC поверх хеша. Если утекла только база, хеши без перца бесполезны. Минус в том, что перец просто так не сменишь.
- Сравнение за constant-time. Обычное
==для строк идёт побайтово с ранним выходом и в теории позволяет подбирать значение байт за байтом. Для токенов и HMACsubtle.ConstantTimeCompareобязателен. - User enumeration. Если на несуществующий логин отвечать мгновенно, а на существующий через 250 мс, атакующий соберёт базу логинов по таймингу. Поэтому, когда пользователя нет, всё равно прогоняют фиктивный хеш и отвечают одинаковым «неверный логин или пароль».
- Ротация параметров. Cost/params хранят вместе с хешем. Если при успешном логине параметры оказались устаревшими, хеш пересчитывают с новыми: только в этот момент у тебя на руках открытый пароль.
- Rate limit на логин и блокировка после N неудач: без них против онлайн-перебора бесполезна любая медленная функция.
Секреты приложения
Под секретом понимают пароль БД, ключ подписи JWT, токен платёжного провайдера. Требований к ним три: не попасть в git, не светиться в логах и уметь меняться без релиза.
| Способ | Плюсы | Минусы |
|---|---|---|
| Переменные окружения | просто, поддерживают все платформы, 12-factor | видны в /proc/PID/environ и в дампах; часто утекают в логи краш-репортеров и в docker inspect; ротация = рестарт |
| Файл, смонтированный в контейнер | можно обновлять без рестарта, права на файл | нужно уметь перечитывать |
| Kubernetes Secret | нативно, RBAC, монтируется как файл или env | это base64, а не шифрование; без включённого encryption-at-rest в etcd лежит почти открытым |
| Vault / облачный KMS | централизованно, аудит, TTL, динамические учётки БД, ротация | ещё одна зависимость в рантайме, нужна аутентификация самого приложения |
Vault AppRole - стандартный способ аутентифицировать сервис без участия человека.
У приложения есть role_id (не секрет, лежит в конфиге) и secret_id
(одноразовый, короткоживущий, приходит отдельным каналом: от оркестратора или через
response wrapping). Приложение меняет пару на токен с TTL, регулярно продлевает его
(renew) и по этому токену читает секреты.
role_id и secret_id разделены, чтобы утечка одного артефакта не давала
доступа. Есть и отдельный джокер, динамические секреты: Vault сам создаёт временного
пользователя в PostgreSQL с TTL 1 час, и «украсть пароль от БД» становится почти бессмысленно.
- Нельзя:
.envс боевыми значениями, приватные ключи,kubeconfig, дампы с продовыми данными,docker-compose.ymlс реальными паролями. В репозитории живёт только.env.exampleс пустышками. - Удалить файл новым коммитом недостаточно: секрет остался в истории и в форках.
Порядок строгий: сначала отозвать и перевыпустить секрет, потом
(по желанию) чистить историю
git filter-repo/BFG. Ротация обязательна, чистка истории - косметика. - В CI ставят
gitleaks/trufflehogна pre-commit и в пайплайн. - Секрет не должен попадать в логи: не логируй целиком конфиг и заголовки, дай секретным
типам
String(), который возвращает"[REDACTED]", а в структурированном логгере заведи список замаскированных полей. - Ротация должна быть рутиной, а не подвигом: держишь два активных ключа с
kid, выкатываешь новый, переключаешь подпись, через окно снимаешь старый.
Вопросы
10401, второе -
403. Порядок всегда такой: сначала AuthN, потом AuthZ.Разложить по полочкам
- Идентификация: ты предъявляешь идентификатор (логин, email,
client_id). Сама по себе она ничего не доказывает. - Аутентификация (AuthN) проверяет доказательство: пароль, одноразовый код, подпись JWT, клиентский сертификат mTLS. После неё «мы знаем, кто это».
- Авторизация (AuthZ) - решение о доступе к конкретному ресурсу и действию: роль, право, политика, владение объектом. На выходе «можно/нельзя».
- Аудит записывает, кто что сделал. Про него часто забывают, а на собесе в финтехе спрашивают отдельно.
Почему путаница живучая
Виноват сам HTTP: статус 401 называется Unauthorized, хотя означает
«не аутентифицирован». По смыслу 401 говорит «предъяви (другие)
учётные данные» и обязан нести заголовок WWW-Authenticate, а 403
значит «мы поняли, кто ты, и тебе нельзя»: перевыпуск токена тут не поможет.
Практическое следствие для кода
Это два разных слоя. Аутентификацию делает общий middleware на всё приложение: разбирает
заголовок, проверяет подпись или сессию, кладёт subject в контекст. Авторизация живёт
ближе к домену. Middleware закрывает грубые вещи (роль admin на
/admin/*), а «этот заказ принадлежит этому пользователю» можно проверить только
там, где известен заказ, то есть в сервисном слое.
Вопрос-продолжение: «а 403 отдавать всегда?» Нет. Если само
существование ресурса надо скрыть (приватный репозиторий, чужой платёж), правильнее отдать
404: 403 подтверждает, что объект есть, а это уже утечка
информации. GitHub так и делает.
Сравнение по осям
| Ось | Сессия (cookie + стор) | JWT |
|---|---|---|
| Где состояние | на сервере: Redis/БД | у клиента, в самом токене |
| Проверка запроса | чтение из стора (сеть ~1 мс) | проверка подписи локально (микросекунды) |
| Отзыв | мгновенный: DEL session:ID | невозможен без чёрного списка |
| Изменение прав | применяется со следующего запроса | только после переиздания токена |
| Размер на запрос | ~30–60 байт | 0.5–2 КиБ в каждом запросе |
| Горизонтальное масштабирование | нужен общий стор (не sticky sessions) | инстансы независимы, нужен только публичный ключ |
| Межсервисность | сложно: у каждого сервиса нет доступа к стору | сильная сторона: подпись проверяет кто угодно |
| SPOF | лёг Redis - легли все логины | нет зависимости на горячем пути |
Главный аргумент, который ждут
Стоит приделать к JWT чёрный список отозванных токенов, и в Redis придётся ходить на каждый запрос. Возвращается ровно та зависимость, от которой JWT и должен был избавить. Поэтому формулировка такая: JWT покупает дешёвую распределённую проверку ценой окна, в котором токен не отозвать; окно равно TTL токена. Всё проектирование сводится к тому, чтобы окно было маленьким (5–15 минут) и закрывалось refresh-токеном, у которого состояние уже есть.
Как выбирать
- Классическому веб-приложению с одним бэкендом и браузерными клиентами хватит сессий: проще, безопаснее, отзыв бесплатный. Это до сих пор дефолт, а не легаси.
- Публичный API, мобильные клиенты, десяток сервисов, которым нужно проверять токен без общего стора - JWT (короткий access + серверный refresh).
- В проде чаще всего побеждает гибрид: JWT-access на 10 минут для межсервисных проверок + серверная запись о сессии/refresh для отзыва и списка устройств.
Полностью stateless JWT-система не умеет разлогинить со всех устройств, показать «активные сессии», немедленно забанить пользователя, отозвать доступ при смене пароля, снизить права. Всё это продуктовые требования, они появляются на втором году жизни сервиса и возвращают состояние.
header.payload. HS256 - общий секрет (симметрично, годится внутри одного
сервиса), RS256/ES256 - приватный ключ подписывает, публичный проверяет (для многих
потребителей). Хранить лучше в httpOnly-куке с SameSite, TTL
access-токена - минуты.Структура
// eyJhbGciOi… . eyJzdWIiOi… . 4pcPyMD0… (три части base64url через точку)
// header: {"alg":"HS256","typ":"JWT","kid":"2024-06"}
// payload: {"sub":"123","exp":1700000000,"iat":...,"iss":"api","aud":"web","jti":"..."}
// signature: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)
Вслух обязательно скажи: base64 не шифрует. Payload читает любой, у кого есть токен, поэтому персональные данные, внутренние идентификаторы и суммы в токен не кладут. Подпись гарантирует целостность и авторство, а не конфиденциальность (для шифрования есть JWE, отдельная спецификация, в вебе она почти не встречается).
HS256 против RS256
| HS256 (HMAC) | RS256 / ES256 | |
|---|---|---|
| Ключи | один общий секрет | пара: приватный подписывает, публичный проверяет |
| Кто может подписать | все, кто может проверить | только владелец приватного ключа |
| Скорость | очень быстро | подпись дороже, проверка RSA быстрая; ES256 компактнее |
| Раздача ключа | секрет в каждый сервис - риск утечки растёт линейно | публичный ключ по JWKS-эндпоинту, кэшируется по kid |
| Когда | монолит, один эмитент и один потребитель | много сервисов, внешние потребители, OIDC |
Где хранить на клиенте
localStorageдоступен любому JS на странице, так что любой XSS = кража токена. Зато CSRF ему не страшен: браузер сам ничего оттуда не отправляет.httpOnly; Secure; SameSite=Lax-куку JS не прочитает, и XSS её не украдёт. Но браузер прикрепляет её сам, поэтому нужна защита от CSRF (SameSiteплюс, для критичных операций, токен).- Мобильное приложение - Keychain/Keystore, не
SharedPreferences. - Продвинутый вариант для SPA: access-токен живёт только в памяти JS
(в переменной, не в хранилище), refresh лежит в
httpOnly-куке с узкимPath=/auth/refresh. После перезагрузки вкладки срабатывает тихий refresh.
Срок жизни
Access живёт 5–15 минут, и это окно, в котором отозванный доступ ещё работает.
Refresh живёт от нескольких дней до месяца, с ротацией. Обязательные поля: exp
(иначе токен вечный), iat, iss и aud (иначе токен
от соседнего сервиса примут как свой), а если нужен поштучный отзыв, ещё и jti.
alg проверяют по белому списку своих алгоритмов, а не берут из
заголовка токена, иначе открываются классические атаки alg: none и подмена
RS256 на HS256 с публичным ключом в роли секрета.
/auth/refresh, хранится на сервере - и потому его можно отозвать.Зачем разделение
Будь токен один, пришлось бы выбирать между удобством (долгий TTL, пользователя не разлогинивает) и безопасностью (короткий TTL, украденный токен быстро протухает). Два токена снимают этот компромисс: короткий access ограничивает ущерб от утечки, долгий refresh держит сессию. К тому же на refresh сервер обязательно идёт в своё хранилище и заодно может проверить, не забанен ли пользователь, не сменился ли пароль, не отозвано ли устройство.
Ротация (rotation)
На каждый обмен выдаётся новый refresh, старый помечается использованным. Токен становится одноразовым, и «украл один раз - пользуюсь вечно» перестаёт работать.
// обмен уже потраченного refresh = сигнал кражи
row, err := repo.GetRefresh(ctx, hash(presented))
switch {
case err != nil:
return ErrInvalid
case row.UsedAt != nil: // токен уже обменивали
repo.RevokeFamily(ctx, row.FamilyID) // рвём всю цепочку: и вора, и жертву
audit.Warn("refresh reuse", "user", row.UserID)
return ErrInvalid
case time.Now().After(row.ExpiresAt):
return ErrExpired
}
newRT := random256()
repo.MarkUsedAndIssue(ctx, row.ID, hash(newRT), row.FamilyID)
Reuse detection
Идея взята из RFC 6819 и BCP по OAuth: у всех токенов одной сессии общий
family_id. Если предъявлен уже использованный refresh, значит, копия токена
есть у двоих: у вора и у жертвы. Кто из них пришёл, серверу неизвестно, поэтому убивают
всю семью: настоящий пользователь просто перелогинится, а вор потеряет доступ. Так
кражу ловят дёшево, без хитрого фингерпринтинга.
Отзыв и хранение
- Refresh хранят хешированным (SHA-256), и утёкший дамп таблицы не даёт рабочих токенов.
- Строка в БД:
user_id, family_id, token_hash, expires_at, used_at, revoked_at, user_agent, ip, device_name. Из неё же вырастает экран «активные сессии». - Для отзыва проставляют
revoked_atодной записи, семье или всем записям пользователя. Отсюда три кнопки: «выйти», «выйти на этом устройстве», «выйти везде». - Смена пароля обязана отзывать все refresh (кроме, по вкусу, текущей сессии).
- Refresh кладут в куку
httpOnly; Secure; SameSite=Strict; Path=/auth/refresh: браузер не потащит его в обычные запросы, а XSS не прочитает.
Мобильное приложение шлёт пять параллельных запросов, все
получают 401 и все идут рефрешить одним и тем же токеном. Один выигрывает,
четверо натыкаются на «reuse» и выкидывают пользователя. На клиенте спасает
single-flight (один рефреш, остальные ждут его результат), на сервере - grace-окно
10–30 секунд: повторный обмен тем же токеном в этом окне возвращает тот же новый
токен из журнала и кражей не считается.
exp по построению. Значит логаут - это либо удаление токена у клиента плюс
отзыв refresh (окно = TTL access), либо явный чёрный список, за который платим походом в
Redis на каждый запрос.Варианты по возрастанию цены
- Просто выбросить токен на клиенте + отозвать refresh на сервере. Access живёт свои 5–15 минут. Для 95 % продуктов этого достаточно, и такой ответ вполне годится.
- Чёрный список по
jti:SET revoked:jti 1 EX ttl_остаток. Ключ исчезнет сам, когда истечёт токен, поэтому список остаётся маленьким. Платишь за этоGETв Redis на каждый запрос. - Версия токенов пользователя (
token_version/epoch): в payload кладём номер, в БД держим текущий. «Выйти везде» = инкремент. Проверка сводится к сравнению чисел и кэшируется в памяти инстанса с TTL 30 с. Это на порядок дешевле чёрного списка и покрывает главный сценарий. - Не выдавать access вообще и вернуться к сессиям. Иногда это правильный ответ.
// вариант 3: одна цифра решает 90% задач отзыва
type Claims struct {
jwt.RegisteredClaims
Ver int `json:"ver"`
}
// при логауте-везде / смене пароля / бане:
// UPDATE users SET token_version = token_version + 1 WHERE id = $1
func (a *Auth) check(ctx context.Context, c Claims) error {
cur, err := a.verCache.Get(ctx, c.Subject) // локальный кэш на 30 секунд
if err != nil { return err }
if c.Ver != cur { return ErrRevoked }
return nil
}
Что сказать, чтобы прозвучало зрело
«Логаут с JWT упирается не в технику, а в решение, какое окно рассинхронизации мы готовы терпеть. Ноль секунд стоит запроса в стор на каждый вызов, то есть возврата к состоянию. Пять минут бесплатны». Дальше отдельно проговори сценарии, где нужен именно ноль: увольнение сотрудника, компрометация устройства, бан за фрод, отзыв прав в админке. Для них обычно делают гибрид: короткий TTL для всех + чёрный список только для критичных ролей или только для эндпоинтов с деньгами.
Удалить куку правильно (Max-Age=0 и те же
Path/Domain, иначе кука останется), отозвать refresh-семью,
закрыть открытые WebSocket-соединения (они аутентифицированы один раз при
handshake и живут часами), инвалидировать серверные кэши прав.
id_token и превращает всё это в
аутентификацию («войти через Google»).Четыре роли
- Resource Owner - пользователь, владелец данных.
- Client - приложение, которое хочет доступ (твой бэкенд или SPA).
- Authorization Server выдаёт токены (Google, Keycloak, Auth0).
- Resource Server - API, которое проверяет токен и отдаёт данные.
Authorization code flow + PKCE
- Клиент редиректит браузер на
/authorizeсresponse_type=code,client_id,redirect_uri,scope,stateиcode_challenge. - Пользователь логинится на стороне провайдера и подтверждает согласие. Твоё приложение пароля не видит, в этом и весь смысл.
- Провайдер редиректит обратно на
redirect_uri?code=...&state=.... Одноразовый код живёт секунды. - Бэкенд меняет код на токены запросом server-to-server на
/token, предъявляяclient_secret(илиcode_verifierдля публичных клиентов). - Получает
access_token,refresh_tokenи, в случае OIDC,id_token.
Почему через код, а не сразу токен? Код летит через адресную строку браузера и
оседает в истории, в логах прокси, в Referer. Поэтому в редиректе едет
одноразовый код, бесполезный сам по себе, а настоящие токены приходят по защищённому
каналу, куда браузер не заглядывает. Устаревший implicit flow отдавал токен прямо во
фрагменте URL; сейчас его считают небезопасным, и в OAuth 2.1 он запрещён.
Два параметра, о которых спрашивают
state- случайное значение: его кладут в сессию и сверяют на возврате. Так флоу защищён от CSRF (иначе атакующий подсунет свой код и привяжет твой аккаунт к своему).- В PKCE клиент генерирует
code_verifier, шлёт его SHA-256 какcode_challenge, а при обмене предъявляет оригинал. Без verifier перехваченный код бесполезен. PKCE обязателен для SPA и мобильных (секрета там нет), а по OAuth 2.1 - вообще для всех.
Чем OIDC отличается
access_token в OAuth говорит «предъявитель имеет право на такие-то действия».
Он адресован API, и клиенту разбирать его вообще не положено. id_token в
OIDC всегда JWT, он адресован клиенту и говорит «этот пользователь только что
аутентифицирован там-то»: sub, iss, aud,
nonce, auth_time, профиль. Ещё OIDC стандартизует
/.well-known/openid-configuration, JWKS-эндпоинт с публичными ключами и
/userinfo. Часто ошибаются, принимая access_token за
доказательство личности, а он может оказаться непрозрачной строкой, в которой нет вообще
ничего.
Basic auth
Это Authorization: Basic base64(user:pass), то есть пароль в открытом
виде в каждом запросе: base64 ни от кого не защищает. Без TLS применять нельзя
категорически. Логаута нет, истечения нет, браузер кэширует учётку и подставляет её сам.
Уместен он там, где надо закрыть /metrics и /debug/pprof или стенд
от посторонних, и в простой интеграции со скриптом на cron, где TLS есть и ротация не нужна.
На сервере сравнивать обязательно через subtle.ConstantTimeCompare.
API-ключи
// ключ отдаём один раз при создании, в БД только хеш и префикс
key := "sk_live_" + base64url(random(24)) // 192 бита энтропии
row := APIKey{
Prefix: key[:14], // для поиска и показа в UI
Hash: sha256.Sum256([]byte(key)), // быстрый хеш ок: секрет высокоэнтропийный
Scopes: []string{"orders:read"},
ExpiresAt: time.Now().AddDate(1, 0, 0),
}
// проверка: находим по префиксу, сравниваем хеши constant-time
Почему для ключа хватает SHA-256, а для пароля нет? Пароль придумывает человек, энтропии в нём мало, и его подбирают по словарю. Случайный 192-битный ключ перебрать нельзя, а медленная KDF только жжёт CPU на каждом запросе.
Правила, которые ждут в ответе
- Ключ передают в заголовке (
Authorization: BearerилиX-API-Key), никогда в query: URL пишется в access-логи, прокси иReferer. - У каждого ключа свои скоупы и права, на каждую интеграцию отдельный ключ, чтобы отзыв одного не ломал остальные.
- Срок жизни и ротация: на время переключения можно держать два активных ключа.
- Rate limit по ключу, лог использования, дата последнего применения.
- Для вебхуков наружу лучше не ключ, а HMAC-подпись тела с меткой времени
(
X-Signature,X-Timestamp): секрет вовсе не передаётся, а метка защищает от повторной отправки. - Самый серьёзный вариант для межсервисного трафика - mTLS: клиентский сертификат вместо строки, отзыв через CRL, срок жизни короткий.
RBAC
Пользователь → роли → права (permissions), права проверяются в коде. Проверять надо
право, а не роль. if user.Role == "admin" закладывает
бомбу замедленного действия: появится второй тип админа, и придётся править сотню мест.
if user.Can("order:cancel") переживёт и новые роли, и их перекройку.
RBAC понятен бизнесу и легко ложится на интерфейс администратора.
ABAC
Решение принимает политика по кортежу атрибутов: кто (роль, отдел, стаж), что (владелец объекта, статус, сумма), в каком контексте (время, IP, был ли MFA). Так выражается то, что RBAC не умеет: «сумму больше миллиона согласует только руководитель», «к персональным данным пускать только из офисной сети». Реализуют это кодом в сервисном слое или отдельным движком политик (OPA/Rego, Casbin, Cedar). Платишь за это более сложной отладкой («почему отказ?») и аудитом решений.
Middleware
func RequirePerm(p string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := auth.From(r.Context()) // положил middleware аутентификации
if !ok { fail(w, 401); return }
if !u.Can(p) { fail(w, 403); return }
next.ServeHTTP(w, r)
})
}
}
r.With(RequirePerm("order:cancel")).Post("/orders/{id}/cancel", h.Cancel)
// а это middleware сделать не может, он не знает, чей это заказ:
func (s *Service) Cancel(ctx context.Context, orderID, userID int64) error {
o, err := s.repo.Get(ctx, orderID)
if err != nil { return err }
if u, _ := auth.From(ctx); o.UserID != userID && !u.Can("order:cancel:any") {
return ErrForbidden // отдать наружу как 404, если факт существования секретен
}
...
}
Главные грабли
- Авторизация только в middleware даёт IDOR: право «отменять заказы» есть, а что заказ чужой, никто не проверил.
- Fail-open: нет токена → в контексте пустой пользователь → политика «нет запрета» → пускаем. По умолчанию должно быть «запрещено»: безопаснее закрыть роутер целиком и явно пометить публичные маршруты, чем наоборот.
- Права в JWT удобны (ноль запросов), но протухают до конца TTL; критичные права лучше читать из источника истины.
- Полезный тест: пройтись по всем зарегистрированным роутам и убедиться, что у каждого есть политика. Иначе однажды новый эндпоинт приедет голым.
argon2id,
надёжная классика - bcrypt с cost 12.Почему быстрые хеши не годятся
Стойкость к перебору упирается не в математику, а в стоимость одной попытки. У SHA-256 она измеряется наносекундами, так что утёкшая база с SHA-хешами фактически и есть база паролей: 8-символьные пароли перебираются полностью, остальное ловят словарями с правилами мутаций. Медленная KDF поднимает цену попытки до ~250 мс, и перебор перестаёт окупаться. А memory-hard функции (scrypt, argon2) вдобавок требуют десятки мегабайт на попытку, и у GPU с ASIC пропадает преимущество: памяти на ядро у них мало.
Соль и перец
- Соль - уникальная случайная строка на каждый пароль, лежит рядом с хешем
открыто. Прятать пароль она не должна. Она ломает rainbow-таблицы, даёт одинаковым
паролям разные хеши (иначе по базе видно, у кого пароль
123456) и не даёт атаковать всю базу разом. - Перец - общий секрет вне БД (Vault, HSM, конфиг). Его подмешивают к паролю или берут ключом HMAC поверх хеша. Если утекла только база, хеши бесполезны. Минус: перец трудно ротировать.
- В bcrypt соль зашита в саму строку хеша, генерировать её руками не надо, а в argon2 соль и параметры хранишь сам.
Что ещё обязательно назвать
- Constant-time сравнение результата (
subtle.ConstantTimeCompare, у bcrypt -CompareHashAndPassword). - User enumeration: для несуществующего логина всё равно прогонять фиктивный хеш и отвечать одинаковым «неверный логин или пароль», иначе аккаунты вычисляются по времени ответа.
- Ротация параметров: cost хранится в самом хеше; если при успешном логине параметры оказались устаревшими, хеш пересчитывают, ведь только в этот момент открытый пароль есть на руках.
- Rate limit и блокировка после серии неудач: без них медленная KDF не спасёт от онлайн-перебора вроде password spraying или credential stuffing.
- Ограничение bcrypt: он учитывает только первые 72 байта пароля. В Go
GenerateFromPasswordна более длинном вернётErrPasswordTooLong, поэтому длинную парольную фразу заранее сворачивают в SHA-256 или берут argon2id. - Не требуй «спецсимвол и смену раз в 90 дней»: актуальный NIST советует длину, проверку по списку скомпрометированных паролей и никакой принудительной ротации.
Лестница зрелости
- Хардкод в коде - катастрофа: секрет в git навсегда, у всех форков и в CI-кэшах.
- Переменные окружения по 12-factor дают минимальный приемлемый уровень. Минусы: видны
в
/proc/PID/environ, вdocker inspect, утекают в краш-репорты; ротация = рестарт. - Файл, смонтированный в контейнер, можно обновить без рестарта, на него действуют права доступа; приложение должно уметь перечитывать его по сигналу.
- Kubernetes Secret: RBAC, монтирование как файл или env. Обязательно проговори: это base64, а не шифрование, и без включённого encryption-at-rest в etcd секрет лежит практически открытым.
- Vault / KMS дают централизованное хранение, аудит доступа, TTL, автоматическую ротацию, динамические секреты. Взамен получаешь ещё одну зависимость в рантайме и задачу аутентифицировать само приложение.
AppRole в двух предложениях
У сервиса есть role_id (не секрет, лежит в конфиге) и secret_id
(короткоживущий, его доставляют отдельно, оркестратором или через response wrapping).
Пару меняют на токен с TTL, приложение периодически его продлевает и по нему
читает секреты. Артефактов два, чтобы утечка одного не давала доступ. Отдельный козырь
Vault - динамические секреты: он сам заводит временного пользователя в PostgreSQL
с TTL в час, и красть пароль от базы становится почти бессмысленно.
Ротация как рутина
Ротация должна быть скучной операцией, а не подвигом. Для этого приложение держит
два действующих значения одновременно: два ключа подписи с разными
kid (выкатили новый на проверку → переключили подпись → сняли старый через
окно), старый и новый пароль БД, два API-ключа у интеграции. Тогда секрет меняется без
одномоментного рестарта всего.
Удалить файл новым коммитом недостаточно: он остался в
истории, в форках и в клонированных CI-кэшах. Порядок строго такой: сначала отозвать и
перевыпустить секрет, потом (по желанию) чистить историю
git filter-repo/BFG. Ротация обязательна, чистка истории - косметика.
В пайплайн ставят gitleaks/trufflehog, а секретным типам в коде
дают String(), который возвращает "[REDACTED]", чтобы секрет не
уехал в лог вместе со структурой конфига.
Что стоит запомнить
- Подписанный JWT защищает от подмены claims, но не скрывает содержимое и не отзывается автоматически до истечения срока.
- Аутентификация успешна, даже если пользователь не владеет запрошенным ресурсом; authorization проверяет действие и объект.
- Короткий access token ограничивает окно риска, а refresh token требует более защищённого хранения и ротации.
Частые ошибки
- Не доверяй id ресурса из URL без проверки владельца/разрешения в запросе.
- Не храни секреты или чувствительные claims в payload JWT в расчёте на шифрование подписи.
- Не помещай bearer token в URL: он попадёт в историю, логи и referer.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Прочитай срок JWT
Проверка подписи успешна, а claims такие: sub=user-42, exp=18:00 UTC, now=18:03 UTC. Должен ли middleware принять токен и какую гарантию даёт подпись?
sub=user-42
exp=2026-10-03T18:00:00Z
now=2026-10-03T18:03:00Z
signature=valid
Ответ и разбор
Токен должен быть отклонён как истёкший, несмотря на корректную подпись. Подпись подтверждает, что claims не были изменены доверенным ключом, но не отменяет exp и сама не подтверждает доступ пользователя к конкретному ресурсу.
Код-ревью
2. Добавь проверку владения
Middleware проверил access token, но handler получает заказ только по id. Исправь запрос так, чтобы нельзя было получить чужой заказ заменой ID в URL.
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
order, err := repo.GetByID(r.Context(), id)
Ответ и разбор
Извлеки проверенный principal из context и используй запрос с обоими условиями: GetForUser(ctx, id, principal.UserID), SQL вида WHERE id=$1 AND user_id=$2. Это делает проверку владения частью выборки и не оставляет окно между отдельной проверкой и чтением. Не раскрывай, существует ли чужой заказ, если политика API этого не допускает.
Применение
3. Выбери хранение токена в браузере
SPA вызывает API из браузера. Сравни хранение bearer access token в localStorage с HttpOnly Secure cookie и назови связанные угрозы, не объявляя один способ универсально безопасным.
Ответ и разбор
localStorage доступен JavaScript и уязвим к краже при XSS. HttpOnly Secure cookie недоступна скрипту, но браузер автоматически отправляет cookie, поэтому нужны SameSite и CSRF-защита для изменяющих запросов. Выбор зависит от модели клиента, XSS/CSRF защиты, доменов, CORS и ротации токенов.
Применение
4. Выбери отзыв токена
Access JWT живёт час, но сотрудника нужно отключить немедленно. Назови два способа отзыва и их цену.
Ответ и разбор
Можно уменьшить TTL access token, чтобы сократить окно отзыва, и использовать короткоживущий access token с серверной проверкой refresh/session state. Для немедленной блокировки можно проверять denylist или версию сессии при запросе, но это добавляет shared state/сетевую зависимость и кэширование. Подпись JWT сама по себе не сообщает, что пользователя уже отключили.