Разработчики всё ещё путают JWT, JWKS, OAuth2 и OpenID Connect — разбираем на примерах. Часть 1
- среда, 17 декабря 2025 г. в 00:00:09

JWT, SSO, OAuth, OpenID Connect — названия, знакомые каждому разработчику. В коде токены встречаются повсюду. Кажется, что всё понятно.
Но стоит спросить: «Зачем в продакшене нужен JWKS?» или «Чем отличается OAuth2 от OpenID Connect?» — уверенность сразу исчезает. Большинство работает с аутентификацией поверхностно: по шаблону или туториалам, не понимая, что лежит в основе.
В этой статье мы разберём, как устроен JWT и его подпись, зачем нужны access и refresh-токены, что такое JWKS и в чём отличие OAuth от OpenID Connect. Вместо скучных стандартов и спецификаций протоколов в статье будет один наглядный образ на примере отелей и пропусков. Это позволит не только запомнить, но и на реальных примерах, избавиться от хаоса и путаницы в голове, когда речь заходит об аутентификации и авторизации. Цель статьи — не «рецепт внедрения», а возможность понять, как это работает внутри и «пощупать» эти темы на Go.
Статья не претендует на подробнейший разбор протоколов в детальном рассмотрении.
Когда человек приходит в отель, первым делом на стойке регистрации его просят назвать имя или код брони, чтобы убедиться, что в системе есть данные о нём. Этот процесс называется идентификация (кто вы?).
Затем сотрудник отеля просит паспорт. Это момент проверки личности — сотрудник отеля должен убедиться, что перед ним действительно тот самый человек. Этот процесс называется аутентификация. Он отвечает только на один вопрос: это правда вы?
После того как личность подтверждена, гость получает браслет. И вот уже по цвету или коду браслета сотрудники отеля понимают, что этому человеку доступен бар, но не спа, или что он имеет право на поздний выезд. Это — авторизация. Она отвечает на другой вопрос: что тебе разрешено?
Идентификация и аутентификация — это момент регистрации на стойке. Авторизация — это всё, что происходит после: как и куда тебя пускают на основе выданного статуса. Один гость может пройти в VIP-зону, другой — только в общий зал. Без аутентификации браслет выдать невозможно, но без авторизации он бы ничего не значил.

В веб-приложении роль браслета часто выполняет токен. Он не только подтверждает, что вы были аутентифицированы, но и содержит информацию об уровне доступа.
JWT (JSON Web Token) — это способ представить ограниченную информацию о пользователе или процессе в виде компактного, переносимого и подписанного объекта (токена). Его основное предназначение — передача данных между двумя сторонами таким образом, чтобы получатель мог быть уверен в их подлинности и целостности.
Название JWT — JSON Web Token — не случайный набор слов, а отражение сути технологии.
JSON — полезная нагрузка токена и его заголовок представлены в формате JSON. Это делает его человекочитаемым и легко интегрируемым в разные веб-системы. Внутри токена могут быть любые ключ-значения: user_id, email, role, exp, iss и т. д., и всё это сериализуется в обычный JSON-объект.
Web — изначально токен проектировался для использования в веб-приложениях. Он легко передаётся по HTTP: в заголовке Authorization, в Cookie, в URL, в теле POST-запроса. Но на практике он используется не только в вебе — JWT давно вышел за его пределы и применяется в мобильных приложениях, микросервисах и IoT.
Token — это маркер доступа. Клиент получает его после успешной аутентификации и предъявляет при каждом запросе как доказательство своей легитимности. Это не файл, не сессия, не объект в памяти — это просто строка, которую можно проверить и доверять, если её подпись валидна.
JWT-токен всегда состоит из трёх частей: заголовка, полезной нагрузки и подписи. Все они представлены в виде JSON-структур, которые кодируются в base64 и соединяются точками. В итоге получается строка вроде:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJhZG1pbiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cЕё можно передавать по сети, хранить в Cookie или передавать в заголовках HTTP-запросов.
Кратко разберём JWT по частям.
{
"alg": "HS256",
"typ": "JWT"
}Эта часть указывает:
alg — какой алгоритм используется для подписи токена. Здесь HS256, то есть HMAC с SHA-256.
Список всех поддерживаемых криптографических алгоритмов в JWT — JWA RFC 7518
typ — тип токена, обычно это просто JWT. Поле формальное, но подчёркивает, что объект — именно JSON Web Token.
Этот JSON сериализуется, кодируется в base64url (кодировка base64 без пробелов и без переносов) и становится первой частью токена.
JWT payload — это набор claims, то есть утверждений о субъекте, времени, правах, идентификаторах и других свойствах (ваш идентификатор, email, ваш номер в отеле, какое у вас питание, включены ли напитки и когда у вас выезд из отеля). Это обычный JSON-объект, в котором можно использовать как зарезервированные по стандарту поля, так и произвольные пользовательские.
Зарезервированные (standard) claims описаны в спецификации RFC 7519. Они не обязательны, но имеют стандартизированный смысл и поведение.
Вот основные:
iss (issuer) — кто выдал токен (например, best.hotel.com);
sub (subject) — о ком токен (например, user_id);
aud (audience) — для кого токен предназначен (например, resort.best.hotel.com);
exp (expiration time) — когда истекает срок действия токена (время в секундах с UNIX-эпохи);
nbf (not before) — с какого времени токен считается действительным;
iat (issued at) — время выпуска токена;
jti (JWT ID) — уникальный идентификатор токена, может использоваться для отслеживания или отзыва токена.
Кроме этих стандартных, разработчик может добавлять в payload любые собственные поля. Такие произвольные claims могут использоваться для разграничения доступа, отображения UI, управления функциональностью — всё зависит от архитектуры системы и вашего воображения!
Важно понимать, что спецификация не запрещает расширения, но рекомендует избегать конфликтов имён со стандартными полями. Payload не шифруется, он легко читается — любой, кто получил токен, может просмотреть его содержимое.
{
"sub": "1234567890",
"iat": 1516239022,
"exp": 1516339022,
"name": "JohnDoe",
"email": "johndoe@gmail.com",
"room_type": "deluxedoubleroom",
"room_number": "123",
"accommodation": "AI"
}Этот JSON тоже кодируется в base64url и становится второй частью токена.
JWT по своей природе не защищён от чтения — любой может расшифровать заголовок и payload, так как они просто закодированы в base64. Без подписи это был бы обычный JSON с открытыми утверждениями, который легко подделать. Именно поэтому у JWT есть третья, обязательная часть — подпись, которая обеспечивает целостность и аутентичность токена.
Если злоумышленник меняет даже один байт в payload — например, меняет "room_number": 123 на "room_number": 124 — подпись уже не будет соответствовать, и сервер откажет в доступе. Подпись нельзя сгенерировать заново без знания секретного ключа. В этом и заключается её смысл: не запретить чтение токена (он не зашифрован), а сделать невозможным его подделку.
Когда токен создаётся, из заголовка и полезной нагрузки формируется строка:
base64url(header) + "." + base64url(payload)
Эта строка подписывается с помощью криптографического алгоритма, указанного в поле alg заголовка. Результат подписания — бинарные данные, которые затем кодируются в base64url и становятся третьей частью токена.
JWT поддерживает алгоритм none. None означает отсутствие подписи или небезопасный JWT — по сути, просто два JSON-а. Это легально по стандарту, но на практике почти всегда запрещено, потому что приводит к критическим уязвимостям. Были случаи, когда системы принимали токены с alg: none, считая их валидными, что позволяло обойти подпись вообще. Поэтому любой валидатор JWT обязан не только проверять подпись, но и явно ограничивать допустимые алгоритмы.
В итоге, собрав все части вместе, получаем полноценный JWT-токен.

Он самодостаточен. В нём есть и информация, и защита от подделки. Именно это делает JWT удобным для аутентификации и авторизации в распределённых системах.
Для быстрого кодирования и декодирования токенов можно пользоваться JSON Web Token (JWT) Debugger
Представим, что наш отель — это маленький хостел, в котором из удобств только номера, а на ресепшене клиентам выдают браслеты (пропуск от номера). На браслете указано кто этот человек, какой у него номер в отеле и подпись, гарантирующая, что он действительно гость, а не случайный прохожий с поддельным браслетом. Секрет от подписи знает только консьерж на ресепшен, и он же проверяет подпись у всех посетителей.

В нашем случае секрет – это симметричный ключ, который знает только консьерж. Этим секретом подписывается и проверяется токен. В жизни такой механизм подписи реализует алгоритм HMAC.

Такой подход называется подпись симметричным алгоритмом подписи (HMAC). Используется обычно для SPA-приложений или монолитного backend-а.
Для демонстрации данного примера напишем HTTP сервер на Go, в котором будут два метода:
POST /login — метод для аутентификации пользователя по логину и паролю (Basic Auth). Взамен метод возвращает JWT-токен. Мы используем POST метод, потому что он безопаснее и логичнее для входа: логина и пароль не передаются в URL, как при GET, а отправляются в теле запроса, который защищает от утечек в логи и кэш.
GET /hello — метод получения информации о пользователе из токена (Bearer Auth): если токен валидный, мы извлечём из него информацию и выведем Hello {user_name}, в противном случае вернём HTTP код 401 Unauthorized.

func main() {
mux := http.NewServeMux()
mux.HandleFunc("POST /login", login)
mux.HandleFunc("GET /hello", hello)
srv := http.Server{
Addr: ":8080",
Handler: mux,
}
fmt.Println("Server running at http://localhost:8080")
srv.ListenAndServe()
}Про Basic и Bearer аутентификацию — это два распространённых способа передачи данных для авторизации в HTTP-запросах. В Basic Auth клиент отправляет логин и пароль, закодированные в base64, прямо в заголовке Authorization: Basic .... Этот способ прост, но обязательно требует HTTPS, чтобы данные не утекли в открытом виде (для простоты мы будем считать, что у нас настроен HTTPS-сервер).
В Bearer Auth клиент использует заранее выданный токен (обычно JWT), который передаёт в заголовке Authorization: Bearer .... Такой подход безопаснее и гибче: токен можно ограничить по времени жизни, правам доступа и не хранить пароль. Bearer удобен для API и как раз используется после первичной аутентификации.
Чтобы выдать пользователю токен, сначала нужно его аутентифицировать. Самый простой способ сделать это — по логину и паролю через Basic Auth. Для большей безопасности лучше использовать двухфакторную аутентификацию (2FA), когда помимо пароля требуется второй фактор — например, код из SMS, приложения (OTP) или физический ключ. В своём примере мы ограничимся одним фактором (1FA) — паролем.
Напишем реализацию нашего HTTP-обработчика POST /login. Логика обработчика проста:
Из заголовка Authorization извлекаются логин и пароль.
При успешной аутентификации возвращается JWT-токен. Этот токен позволит пользователю обращаться к API без повторной отправки логина и пароля.
func login(w http.ResponseWriter, r *http.Request) {
// 1. Из заголовка Authorization извлекаются логин и пароль
authHeader := r.Header.Get("Authorization")
const basicAuthPrefix = "Basic "
if authHeader == "" || !strings.HasPrefix(authHeader, basicAuthPrefix) {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
payload, err := base64.StdEncoding.DecodeString(authHeader[len(basicAuthPrefix):])
if err != nil {
http.Error(w, "Invalid Authorization header", http.StatusBadRequest)
return
}
creds := strings.SplitN(string(payload), ":", 2)
if len(creds) != 2 {
http.Error(w, "Invalid Authorization header", http.StatusBadRequest)
return
}
// 2. Проверяем на корректность логин и пароль
user, err := authUser(creds[0], creds[1])
if err != nil {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 3. Если корректны, то создаём JWT-токен и возвращаем его
// в ответе. Этот токен позволит пользователю обращаться к API
// без повторной отправки логина и пароля.
token, err := createAccessToken(user)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
type response struct {
AccessToken string `json:"access_token"`
}
json.NewEncoder(w).Encode(response{AccessToken: token})
}Хранить пароли пользователей в базе в открытом виде — опаснейшая ошибка, которую, к сожалению, до сих пор совершают разработчики.
Если ваша база данных будет скомпрометирована (взлом, утечка, баг) — все пароли пользователей моментально окажутся в руках злоумышленников. А ведь многие пользователи используют один и тот же пароль в разных сервисах: это означает, что взлом одного сайта может привести к взлому почты, соцсетей, банков и т. д.
Вместо хранения пароля, мы храним его хеш — специальную «отпечаток-функцию», которую невозможно (или почти невозможно) обратить обратно в оригинальный пароль.

Для простоты примера будем хранить их в памяти приложения.
type user struct {
Name string
Email string
HashedPassword []byte
}
var usersDB = []user{
{
Name: "bob",
Email: "bob@google.com",
HashedPassword: hashPassword("bobpassword"),
},
{
Name: "alice",
Email: "alice@google.com",
HashedPassword: hashPassword("alicepassword"),
},
}Однако хранить просто хеш — недостаточно: злоумышленник может использовать заранее подготовленные словари хешей (так называемые «радужные таблицы»). Поэтому к паролю добавляется уникальная соль — случайная строка, которая делает каждый хеш уникальным, даже если два пользователя используют одинаковые пароли.

Для хеширования паролей используют специальные медленные алгоритмы, такие как, например, bcrypt, scrypt и argon2, потому что они устойчивы к атакам перебора. Быстрые хеш-функции вроде SHA256 не подходят — злоумышленник может перебрать миллионы вариантов в секунду. Медленные алгоритмы делают такие атаки затратными и неэффективными.
Воспользуемся алгоритмом bcrypt, который в себе уже содержит хеш пароля и соль:
import "golang.org/x/crypto/bcrypt"
func hashPassword(password string) []byte {
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return hash
}
func checkPassword(hashedPassword []byte, password string) error {
return bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
} и реализуем аутентификацию пользователя:
var ErrInvalidUserOrPassword = errors.New("invalid user or password")
func authUser(email, password string) (user, error) {
// ищем пользователя в БД
idx := slices.IndexFunc(usersDB, func(u user) bool {
return strings.EqualFold(email, u.Email)
})
if idx == -1 {
return user{}, ErrInvalidUserOrPassword
}
usr := usersDB[idx]
// сравниваем пароли
if err := checkPassword(usr.HashedPassword, password); err != nil {
return user{}, ErrInvalidUserOrPassword
}
return usr, nil
}Обратите внимание: мы возвращаем одну и ту же ошибку (ErrInvalidUserOrPassword), чтобы не дать злоумышленнику понять, существует ли пользователь с таким логином. Одинаковая ошибка защищает от подбора логинов (уязвимость user enumeration).
После аутентификации пользователя по паролю необходимо выдать ему токен, который он будет предъявлять нам в дальнейшем вместо логина и пароля.
func createAccessToken(u user) (string, error)
Для работы с JWT воспользуемся библиотекой github.com/olang-jwt/jwt/v5 — это фактический стандарт в Go, активно поддерживается и подходит для большинства задач с JWT-токенами.
Сформируем Claims, которые будут в токене.
Cтандартные JWT Claims:
iss — кем выдан токен (токен будем выписывать только мы);
sub — кому выдан токен;
iat — время выдачи токена.
Добавим ещё дополнительно информацию о пользователе:
user_name — имя пользователя;
user_email — email пользователя.
import "github.com/golang-jwt/jwt/v5"
const issuer = "best.hotel.com"
var claims = jwt.MapClaims{
"iss": issuer,
"sub": u.Email,
"iat": time.Now().Unix(),
"user_email": u.Email,
"user_name": u.Name,
}Формируем токен с алгоритмом HS256:
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) Теперь остаётся подписать наш токен, но для этого нам необходим секретный ключ (в примере объявим его явно в коде):
// Не храните в исходниках кода!
HMAC-секрет нужно хранить в надёжном месте, например, в хранилищах секретов (HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, …).
var secret = []byte("your-secret-string") Важно: секрет для подписи HMAC должен быть достаточно длинным и случайным, рекомендуется использовать не менее 256 бит (32 байт) случайных данных.
HMAC-секрет нужно хранить в надёжном месте, например, в хранилищах секретов (HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, …).
Библиотека подпишет выбранным алгоритмом и вернёт уже готовый подписанный токен:
signedToken, err := token.SignedString(secret)
return signedToken, err Теперь метод POST /login готов, и мы можем получить токен. Запускаем наш сервер и отправляем запрос на аутентификацию по логину и паролю:
curl -u bob@google.com:bobpassword --request POST 'http://localhost:8080/login' В ответе получаем JWT-токен:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3NTEzODczNjksImlzcyI6ImJlc3QuaG90ZWwuY29tIiwic3ViIjoiYm9iQGdvb2dsZS5jb20iLCJ1c2VyX2VtYWlsIjoiYm9iQGdvb2dsZS5jb20iLCJ1c2VyX25hbWUiOiJib2IifQ.8yZUCIyE1BV0brOSko1d0hlE0rTGx4M-CfpKOdsk7Rs"
}Декодируем токен в jwt.io и заодно проверим подпись:

Как видим, токен валидный и содержит информацию о пользователе.
В этом разделе мы разберём, как осуществляется проверка подлинности и корректности access JWT-токена, полученного от клиента. Это критически важный этап для любого защищённо��о эндпоинта. Без этой проверки сервер не сможет отличить валидного пользователя от постороннего запроса с поддельным или просроченным токеном.
Важно отметить, что верификация — это процесс, при котором мы:
Проверяем алгоритм подписи, чтобы исключить возможность подмены безопасного алгоритма на небезопасный (например, none).
Проверяем подпись токена, чтобы убедиться, что он действительно был выпущен нами, а не кем-то извне.
Проверяем issuer (того, кто выпустил токен), чтобы токен соответствовал ожидаемому источнику.
Проверяем срок действия токена, чтобы не допустить использование уже просроченного токена.
Извлекаем полезные данные из токена — например, email и имя пользователя.
Библиотека github.com/golang-jwt/jwt/v5 требует функцию возврата, с помощью которого нужно проверить подпись токена. В случае алгоритма HMAC (когда используется один и тот же секретный ключ для подписи и верификации), эта функция просто возвращает секрет:
func keyFunc() jwt.Keyfunc {
return func(_ *jwt.Token) (interface{}, error) { return secret, nil }
} Теперь реализуем функцию, которая будет выполнять верификацию access-токена и возвращать информацию о пользователе, если токен валиден:
var ErrInvalidToken = errors.New("invalid token")
func verifyAccessToken(accessToken string) (user, error) {
token, err := jwt.Parse(accessToken,
// получаем ключ для проверки подписи
keyFunc(),
// проверяем соответсвие алгоритма подписи
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
// проверяем соответствие автора токена
jwt.WithIssuer(issuer),
// проверяем время жизни токена
jwt.WithExpirationRequired(),
)
if err != nil {
return user{}, fmt.Errorf("parse token failed: %w", err)
}
if !token.Valid {
return user{}, ErrInvalidToken
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return user{}, ErrInvalidToken
}
userEmail, _ := claims["user_email"].(string)
userName, _ := claims["user_name"].(string)
return user{
Name: userName,
Email: userEmail,
}, nil
}Разберём, что здесь происходит.
jwt.Parse — основной метод, который инициализирует весь процесс разбора и проверки токена.
jwt.WithValidMethods — указываем, что мы ожидаем токен, подписанный именно HS256, чтобы исключить подмену алгоритма.
jwt.WithIssuer — проверяем, что токен был выдан нашим сервисом, а не кем-либо ещё.
jwt.WithExpirationRequired — запрещаем использование токенов без срока действия.
Если хоть одно из этих условий не выполнено, токен считается невалидным. Мы возвращаем ошибку и не допускаем пользователя к защищённому ресурсу.
Отлично! Теперь нам осталось добавить проверку токена во все методы, требующие авторизацию (в нашем случае GET /hello). Для этого реализуем middleware, в котором будет происходит проверка токена и передача пользователя в контекст запроса в случае успеха.
type contextKey string
const userContextKey = contextKey("user")
// Добавляем пользователя в контекст
func putUserToContext(ctx context.Context, u user) context.Context {
return context.WithValue(ctx, userContextKey, u)
}
// Достаём пользователя из контекста
func getUserFromContext(ctx context.Context) (user, bool) {
u, ok := ctx.Value(userContextKey).(user)
return u, ok
}
// Cама middleware:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
// Мы проверяем наличие заголовка Authorization и префикса Bearer.
// Это стандарт для передачи access-токенов в HTTP-запросах.
const bearerPrefix = "Bearer "
if authHeader == "" || !strings.HasPrefix(authHeader, bearerPrefix) {
http.Error(w, "Missing access token", http.StatusUnauthorized)
return
}
rawToken := strings.TrimPrefix(authHeader, bearerPrefix)
u, err := verifyAccessToken(rawToken)
if err != nil {
// Если токен отсутствует, невалиден или просрочен — возвращаем статус 401 Unauthorized.
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
return
}
ctx := putUserToContext(r.Context(), u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}Теперь каждый обработчик, обёрнутый в authMiddleware, будет иметь доступ к пользователю через context.
mux := http.NewServeMux()
mux.HandleFunc("POST /login", login)
// защищённый маршрут
mux.Handle("GET /hello", authMiddleware(http.HandlerFunc(hello))) Реализация обработчика hello в этом случае будет выглядеть очень просто и компактно:
func hello(w http.ResponseWriter, r *http.Request) {
// достаём пользователя из контекста
user, ok := getUserFromContext(r.Context())
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// если он есть, отдаём приветственное сообщение с его именем
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
type response struct {
Message string `json:"message"`
}
json.NewEncoder(w).Encode(
response{Message: fmt.Sprintf("Hello %s!", user.Name)},
)
}Проверяем теперь всё вместе.
Получаем ещё раз токен по логину и паролю:
curl -u bob@google.com:bobpassword --request POST 'http://localhost:8080/login'Далее полученный токен отправляем в заголовке Authorization
curl --location 'http://localhost:8080/hello' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTE2NDg0NjEsImlhdCI6MTc1MTY0NzU2MSwiaXNzIjoiYmVzdC5ob3RlbC5jb20iLCJzdWIiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfZW1haWwiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfbmFtZSI6ImJvYiJ9.jkPbuKAbRi9denxnxQaIMVaWnpMZkf5hkYvkHTcsQrw' В ответе увидим приветствие с именем нашего пользователя:
{
"message": "Hello bob!"
}Функция верификации access-токена — это центральный элемент безопасности вашего приложения. Он отвечает за то, чтобы запросы к закрытым обработчикам выполнялись только от тех, кто прошёл аутентификацию.
Важно помнить, что простая проверка подписи недостаточна. Всегда комбинируйте её с проверкой срока действия, источника токена и алгоритма подписи. Именно в этом и заключается надёжная реализация контроля доступа.

Представьте, что вы приезжаете в отель, подходите к ресепшен, называете своё имя и бронирование и вам выдают ключ от номера. Этот ключ — как access-токен. Пока он работает, вы можете свободно заходить в свой номер, пользоваться всеми удобствами, и никто вас не останавливает. Но представьте, что ключ потерян или украден. Любой, кто его поднимет, сможет спокойно пройти внутрь, охрана ведь проверяет не лицо, а сам ключ. Если такой ключ действителен бессрочно, вы никак не сможете остановить злоумышленника: он будет приходить в ваш номер снова и снова (пока вы на пляже или где-нибудь у бассейна).
Чтобы этого избежать, ключи в отелях делают с ограничением по времени — expires. Скажем, он перестаёт работать через сутки. Это уже безопаснее: если ключ утерян, доступ будет закрыт автоматически через какое-то время. Но здесь возникает другая неудобная ситуация: если срок действия ключа слишком короткий, вам придётся регулярно возвращаться на ресепшен и просить продление. Согласитесь, это очень раздражает и мешает отдыху!
Поэтому в современных системах придумали удобную схему: вам всё так же выдают временный ключ (access-токен) с коротким ограничением по времени, но вместе с ним дают специальную карточку с долгим временем жизни (например, месяц) — refresh токен. Она не открывает номер напрямую, но позволяет в любой момент, когда ключ истёк, подойти и получить новый ключ (access-токен) на ресепшене. Вам не нужно заново подтверждать личность, не нужно переселяться или заполнять документы — достаточно предъявить эту карточку, и вы снова с ключом в руках.
Хорошо, что в реальности пропускная система в отелях работает немного не так.
В цифровом мире всё работает схоже. После входа в систему сервер выдаёт вам access-токен с коротким сроком жизни и refresh-токен, который живёт сильно дольше. Когда access-токен истекает, клиент может «подойти к ресепшен», то есть отправить запрос на /refresh и получить новый access-токен без повторного ввода логина и пароля. Если вдруг refresh-токен утёк или был скомпрометирован, его можно аннулировать: например, удалить из базы или отметить как отозванный. А поскольку access-токены короткоживущие — злоумышленник долго ими пользоваться не сможет.
Access-токен обычно реализуется как JWT, в нём содержится ID пользователя, роль, срок действия и другие метаданные. Он подписан приватным ключом и не требует хранения на сервере.
Refresh-токен может быть также JWT-токеном. Однако чаще всего в качестве refresh-токенов используется opaque token — случайная, уникальная строка символов, не содержащая никакой читаемой информации о пользователе. Самое главное для refresh-токена — реализовать возможность его отзыва. В случае opaque это сделать чуть проще — мы храним на сервере эти токены и можем их отозвать. Для JWT необходимо добавить в токен некоторую метку или идентификатор, который мы также будем хранить на сервере и проверять его актуальность.
Важно: хранить в открытом виде refresh-токены так же, как и пароли, нельзя! Можно хранить их идентификатор (в случае JWT) или hash (для opaque).
Когда срок действия access-токена истекает, клиент отправляет запрос на специальную точку /refresh, передавая refresh-токен. Сервер проверяет токен: если он валиден и не отозван, то выдаёт новый access-токен (и опционально — новый refresh-токен), а старый refresh-токен инвалидирует. Так предотвращается повторное использование украденных или перехваченных refresh-токенов (replay attack).

При выходе из системы клиент может отправить запрос на /logout, и сервер удалит refresh-токен из базы или добавит его в «чёрный список». Таким образом, access-токены будут жить до окончания срока, но обновить их уже будет нельзя.
Важно понимать: access-токен предназначен для быстрого доступа к ресурсам, а refresh-токен для контролируемого продления доступа. Разделение этих ролей позволяет добиться баланса между безопасностью и удобством.
Среди best practices — использовать короткие TTL для access-токенов (1–15 минут), безопасно хранить refresh-токены (лучше в HttpOnly cookie), и инвалидировать их при каждом обновлении. Также желательно, чтобы один refresh-токен соответствовал одной сессии или устройству, чтобы можно было избирательно завершать сессии.
Попробуем доработать наш пример с учётом всего вышесказанного. Для начала зададим нашему access-token время жизни (например, 15 минут). За это отвечает заголовок «exp»:
func createAccessToken(u user) (string, error) {
now := time.Now()
claims := jwt.MapClaims{
// стандартные JWT claims
"iss": issuer, // кто выдал токен
"sub": u.Email, // кому выдан токен
"iat": now.Unix(), // время создания токена
"exp": now.Add(15 * time.Minute).Unix(), // время жизни токена
// наши произвольные claims
"user_email": u.Email,
"user_name": u.Name,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}Напишем метод создания refresh-токена.
В нашем примере будем использовать JWT refresh-токены и хранить их (идентификатор) в памяти приложения. В действительности их можно хранить в любой удобной вам CУБД. Для refresh-токена укажем время жизни 7 дней.
func createRefreshToken(u user) (string, error) {
tokenID := uuid.New().String() // уникальный ID для refresh-токена
now := time.Now()
claims := jwt.MapClaims{
// стандартные JWT claims
"iss": issuer, // кто выдал токен
"sub": u.Email, // кому выдан токен
"iat": now.Unix(), // время создания токена
"exp": now.Add(7 * 24 * time.Hour).Unix(), // время жизни токена
"jti": tokenID, // "JWT ID" — идентификатор токена
// наши произвольные claims
"type": "refresh",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(secret)
if err != nil {
return "", err
}
// сохраняем только id токена (а не сам токен)
// даже зная id токена, подделать его, будет очень сложно
mx.Lock()
refreshTokens[tokenID] = struct{}{}
mx.Unlock()
/* Если бы мы использовали opaqueToken, то мы бы хранили его hash
mx.Lock()
refreshTokens[hash(opaqueToken)] = struct{}{}
mx.Unlock()
*/
return signed, nil
}И доработаем наш метод POST /login: добавим в ответ ещё refresh-токен:
at, err := createAccessToken(user)
if err != nil {}
rt, err := createRefreshToken(user)
if err != nil {}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
type response struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
json.NewEncoder(w).Encode(response{AccessToken: at, RefreshToken: rt})Теперь наш метод аутентификации возвращает 2 токена:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTE2NDg0NjEsImlhdCI6MTc1MTY0NzU2MSwiaXNzIjoiYmVzdC5ob3RlbC5jb20iLCJzdWIiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfZW1haWwiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfbmFtZSI6ImJvYiJ9.jkPbuKAbRi9denxnxQaIMVaWnpMZkf5hkYvkHTcsQrw",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTIyNTIzNjEsImlhdCI6MTc1MTY0NzU2MSwiaXNzIjoiYmVzdC5ob3RlbC5jb20iLCJqdGkiOiI5M2RhY2QxMy1lNmFhLTRhZTUtYTMwYi0zZDg0YTQxOWExZDkiLCJzdWIiOiJib2JAZ29vZ2xlLmNvbSIsInR5cGUiOiJyZWZyZXNoIn0.RDCOoOP9DPvBpkLH6kZNFoIEggggcaqtSQ2uAChuz4Q"
}Нам осталось реализовать метод POST /refresh для того, чтобы обменять refresh-токен на новый access и refresh-токены.
Для этого сначала напишем метод верификации refresh-токена. Его отличие от verifyAccessToken заключается лишь в том, что нам приходится искать в хранилище данный токен и удалять его после использования (это необходимо для возможности реализовать logout или отзыва доступа).
// аналогично валидации access-токену, но с проверкой на существование/отзыв
func verifyRefreshToken(refreshToken string) (user, error) {
token, err := jwt.Parse(refreshToken, keyFunc(),
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
jwt.WithIssuer(issuer),
jwt.WithExpirationRequired(),
)
if err != nil {
return user{}, fmt.Errorf("parse token failed: %w", err)
}
if !token.Valid {
return user{}, ErrInvalidToken
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || claims["type"] != "refresh" {
return user{}, ErrInvalidToken
}
tokenID, ok := claims["jti"].(string)
if !ok {
return user{}, ErrInvalidToken
}
email, ok := claims["sub"].(string)
if !ok {
return user{}, ErrInvalidToken
}
mx.RLock()
_, exists := refreshTokens[tokenID]
mx.RUnlock()
// Проверяем, не отозвали ли токен
if !exists {
return user{}, ErrInvalidToken
}
mx.Lock()
delete(refreshTokens, tokenID) // больше нельзя использовать этот refresh (одноразовый)
mx.Unlock()
idx := slices.IndexFunc(usersDB, func(u user) bool {
return strings.EqualFold(email, u.Email)
})
if idx == -1 {
return user{}, ErrInvalidToken
}
return usersDB[idx], nil
}И тогда реализация метода POST /refresh будет выглядеть следующим образом:
func refresh(w http.ResponseWriter, r *http.Request) {
// Достаём токен из запроса (обычно передают в теле запроса)
type request struct {
RefreshToken string `json:"refresh_token"`
}
var req request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.RefreshToken == "" {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Проверяем его refresh-токен
user, err := verifyRefreshToken(req.RefreshToken)
if err != nil {
http.Error(w, "Bad refresh token", http.StatusBadRequest)
return
}
// Аналогично методу POST /login выписываем новую пару access и refresh-токенов:
newAccess, err := createAccessToken(user)
if err != nil {
http.Error(w, "Error creating access token", http.StatusInternalServerError)
return
}
newRefresh, err := createRefreshToken(user)
if err != nil {
http.Error(w, "Error creating refresh token", http.StatusInternalServerError)
return
}
// Отдаём оба токена в теле ответа
type response struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response{
AccessToken: newAccess,
RefreshToken: newRefresh,
})
}Теперь наше API поддерживает аутентификацию с помощью JWT-токенов и умеет выписывать access и refresh-токены и работать с ними. Данный пример можно усложнять дальше: например, привязывать refresh-токены к устройствам (по User-Аgent).
Представим, что наш отель растёт: теперь у него есть не только номера, но и бассейн с баром на крыше отеля. Бармен у бассейна раздаёт бесплатные напитки, но только постояльцам отеля. Чтобы отличить гостей от случайных прохожих, ему, как и консьержу на ресепшене, нужно уметь проверять подписи на браслетах, которые подтверждают статус клиента.

До сих пор все работали по одной схеме: и консьерж, и бармен знали секрет, с помощью которого можно было проверять (а при желании и подделывать) браслеты. И вот однажды один из барм��нов решил использовать этот секрет в своих целях: сделал поддельные браслеты для друзей, и те получили доступ ко всем привилегиям отеля.
Так стало ясно: нельзя раздавать один и тот же секрет всем подряд. Нужно разделить полномочия: только консьерж должен иметь доступ к приватному ключу и подписывать браслеты, а все остальные — бармены, охранники, официанты — могут проверять подписи, но не создавать их!
В web эта задача возникает, когда множество сервисов должно проверять подлинность пользователей, но только один — выпускать токены. В этом и помогает асимметричное шифрование, такое как RSA. Это основа для построения распределённых систем (микросервисов) и для протоколов вроде OpenID Connect.

Вернёмся к нашему примеру:

Разделим теперь наш сервис на два микросервиса:
AuthService — занимается аутентификацией и выпиской токенов.
GreetingService — обслуживает запросы, проверяет токены.

Сначала создадим пару RSA-ключей (один подписывает и его знает только AuthService, второй проверяет — его можно безопасно передавать любым сервисам). Для этого воспользуемся утилитой openssl.
Приватный:
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048Публичный:
openssl rsa -pubout -in private.pem -out public.pemТеперь в сервисе AuthService необходимо загрузить приватный ключ (private.pem). Реализуем функцию загрузки ключа:
package main
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"os"
)
// Загрузка приватного ключа из файла
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "PRIVATE KEY" {
return nil, errors.New("невалидный PEM-файл для приватного ключа")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("ключ не RSA")
}
return rsaKey, nil
}и загрузим его:
var privateKey *rsa.PrivateKey
func init() {
key, err := loadPrivateKey("private.pem")
if err != nil {
log.Fatal(err)
}
privateKey = key
}В методах выдачи access и refresh-токенов нам достаточно изменить алгоритм подписи на RSA256 и передать наш приватный ключ:
func createAccessToken(u user) (string, error) {
/* ... */
// Используем RSA256 для подписи токена
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
func createRefreshToken(u user) (string, error) {
/* ... */
// Используем RSA256 для подписи токена
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signed, err := token.SignedString(privateKey)
/* ... */
return signed, nil
}Для верификации refresh-токена в AuthService немного изменим keyFunc:
// функция провайдер ключа для верификации подписи
func keyFunc() jwt.Keyfunc {
// в RSA мы проверяем подпись публичным ключом
return func(_ *jwt.Token) (interface{}, error) { return privateKey.PublicKey, nil } // отдаём публичный ключ
}И поменяем название алгоритма на RS256 в верификации токена:
token, err := jwt.Parse(refreshToken, keyFunc(),
jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Name}), // RS256
jwt.WithIssuer(issuer),
jwt.WithExpirationRequired(),
)В сервисе GreetingService у нас нет доступа к приватному ключу, но у нас есть публичный ключ, которым мы и будем проверять токены. Для этого его необходимо загрузить:
package main
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"os"
)
// Загрузка публичного ключа из файла
func loadPublicKey(path string) (*rsa.PublicKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "PUBLIC KEY" {
return nil, errors.New("невалидный PEM-файл для публичного ключа")
}
pubInterface, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
pubKey, ok := pubInterface.(*rsa.PublicKey)
if !ok {
return nil, errors.New("не RSA публичный ключ")
}
return pubKey, nil
}И аналогично AuthService поменяем методы верификации на RSA в keyFunc и verify.
Запустим AuthService и GreetingService на 8080 и 8081 портах соответственно.
Сначала авторизуемся в AuthService:
curl --location --request POST 'http://localhost:8080/login' \
--header 'Authorization: Basic Ym9iQGdvb2dsZS5jb206Ym9icGFzc3dvcmQ='В ответ получим наши уже знакомые access и refresh-токены:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTM1MzY0NDIsImlhdCI6MTc1MzUzNTU0MiwiaXNzIjoiYmVzdC5ob3RlbC5jb20iLCJzdWIiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfZW1haWwiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfbmFtZSI6ImJvYiJ9.HicXOgIolJddPspElUvJunKSNDyD9TMHhU321rr6V8GcVbv8vdacupUc2PPeV8ePGRNhoPTbZ0iiPYBz3apPV_Hqai1uxLf-RO2BA_QWTn341AUBeo_JXxTVPRAGXb2fmK-Jhz1Dx8n_5uPq8Zc_AmANNtpy-_4UUkXhKDOkjMcAKsjlMRxH8LdmElAeR5R5nFDs3nybvWqYmfev_nbjmv0c6RefSkI6IuLNE6k2O96HcKBvPb0MPzy1Wg24jI0vN0laiHrhuBwHpflWGERcB3Aj00IMQrUFMd_2izX_Gc1MCLd_Gl9Dhc5XMtJhlKlODhFPUIM5pMqD5HW1vPkM8Q",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTQxNDAzNDIsImlhdCI6MTc1MzUzNTU0MiwiaXNzIjoiYmVzdC5ob3RlbC5jb20iLCJqdGkiOiIyN2QxNmUxYi0xNDU0LTRkMmEtYTVkZS1hNGFmMDhlYTNhOGYiLCJzdWIiOiJib2JAZ29vZ2xlLmNvbSIsInR5cGUiOiJyZWZyZXNoIn0.PTwHC4URKDXp4ErIasBb7czF69KtI737E1jeQmdrH59tiQ43sNQf8kAxHowNJO5WCEhkmZIivtM0924vQvyiV4rwEZ689if8E76Dmec9WFGDf6uKbu3IkX7Gfu6c2EumdtmoGZTQmQssTZ2VACmfuVj3V6TefQI9E6O3e2X_Fw4diIc9zWUF0PH2ndPpMUg1N2ohWldeVAfE4K4CE_jdyTyhgNNRMtI-LPysFbdFrqSjvhq4Cwa7EcdOgqhgmn8Dqsg0GLg3gdJJpH0lvLXZxvS8wSnBlxnpodsaDQK3AnLWo8W2QNO9kvZb4RoQSmFfPhgNWOeoDMC_EZcPSAYvzw"
}Убедимся, что теперь используется алгоритм подписи RS256, и наш публичный ключ верифицирует токен:

Теперь выполним запрос в сервис GreetingService и передадим наш access-токен:
curl --location 'http: //localhost:8081/hello' \
--header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTM1MzY0NDIsImlhdCI6MTc1MzUzNTU0MiwiaXNzIjoiYmVzdC5ob3RlbC5jb20iLCJzdWIiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfZW1haWwiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfbmFtZSI6ImJvYiJ9.HicXOgIolJddPspElUvJunKSNDyD9TMHhU321rr6V8GcVbv8vdacupUc2PPeV8ePGRNhoPTbZ0iiPYBz3apPV_Hqai1uxLf-RO2BA_QWTn341AUBeo_JXxTVPRAGXb2fmK-Jhz1Dx8n_5uPq8Zc_AmANNtpy-_4UUkXhKDOkjMcAKsjlMRxH8LdmElAeR5R5nFDs3nybvWqYmfev_nbjmv0c6RefSkI6IuLNE6k2O96HcKBvPb0MPzy1Wg24jI0vN0laiHrhuBwHpflWGERcB3Aj00IMQrUFMd_2izX_Gc1MCLd_Gl9Dhc5XMtJhlKlODhFPUIM5pMqD5HW1vPkM8Q' В ответе увидим, что верификация прошла и вывелось имя нашего пользователя:
{
"message": "Hello bob!"
}Таким образом, RSA и асимметричная подпись позволяют нам:
централизованно выписывать токены (только AuthService);
децентрализованно проверять токены (любой сервис с публичным ключом).
Это критически важно в распределённых системах (микросервисы, API-шлюзы, мобильные клиенты), где необходимо не раскрывать секрет, но всё равно проверять подлинность токенов.
Мы разобрали фундамент аутентификации:
Что такое JWT, как он устроен.
Рассмотрели, как работают подписи, основанные на симметричных и асимметричных алгоритмах.
Какие ошибки безопасности встречаются и как избежать уязвимостей при работе с токенами.
Во второй части статьи разберёмся с более сложными протоколами, использующих JWT: JWKS, OAuth 2.0, OIDC.