golang

Разработчики всё ещё путают JWT, JWKS, OAuth2 и OpenID Connect — разбираем на примерах. Часть 1

  • среда, 17 декабря 2025 г. в 00:00:09
https://habr.com/ru/companies/ozontech/articles/976950/

JWT, SSO, OAuth, OpenID Connect — названия, знакомые каждому разработчику. В коде токены встречаются повсюду. Кажется, что всё понятно.

Но стоит спросить: «Зачем в продакшене нужен JWKS?» или «Чем отличается OAuth2 от OpenID Connect?» — уверенность сразу исчезает. Большинство работает с аутентификацией поверхностно: по шаблону или туториалам, не понимая, что лежит в основе. 

В этой статье мы разберём, как устроен JWT и его подпись, зачем нужны access и refresh-токены, что такое JWKS и в чём отличие OAuth от OpenID Connect. Вместо скучных стандартов и спецификаций протоколов в статье будет один наглядный образ на примере отелей и пропусков. Это позволит не только запомнить, но и на реальных примерах, избавиться от хаоса и путаницы в голове, когда речь заходит об аутентификации и авторизации. Цель статьи — не «рецепт внедрения», а возможность понять, как это работает внутри и «пощупать» эти темы на Go.

Статья не претендует на подробнейший разбор протоколов в детальном рассмотрении.

Идентификация vs Аутентификация vs Авторизация (на пальцах) 

Когда человек приходит в отель, первым делом на стойке регистрации его просят назвать имя или код брони, чтобы убедиться, что в системе есть данные о нём. Этот процесс называется идентификация (кто вы?).  

Затем сотрудник отеля просит паспорт. Это момент проверки личности — сотрудник отеля должен убедиться, что перед ним действительно тот самый человек. Этот процесс называется аутентификация. Он отвечает только на один вопрос: это правда вы? 

После того как личность подтверждена, гость получает браслет. И вот уже по цвету или коду браслета сотрудники отеля понимают, что этому человеку доступен бар, но не спа, или что он имеет право на поздний выезд. Это — авторизация. Она отвечает на другой вопрос: что тебе разрешено? 

Идентификация и аутентификация — это момент регистрации на стойке. Авторизация — это всё, что происходит после: как и куда тебя пускают на основе выданного статуса. Один гость может пройти в VIP-зону, другой — только в общий зал. Без аутентификации браслет выдать невозможно, но без авторизации он бы ничего не значил.

Цепочка контроля доступа
Цепочка контроля доступа

В веб-приложении роль браслета часто выполняет токен. Он не только подтверждает, что вы были аутентифицированы, но и содержит информацию об уровне доступа. 

Что такое JWT и зачем он нужен

JWT (JSON Web Token) — это способ представить ограниченную информацию о пользователе или процессе в виде компактного, переносимого и подписанного объекта (токена). Его основное предназначение — передача данных между двумя сторонами таким образом, чтобы получатель мог быть уверен в их подлинности и целостности. 

Название JWT — JSON Web Token — не случайный набор слов, а отражение сути технологии. 

JSON — полезная нагрузка токена и его заголовок представлены в формате JSON. Это делает его человекочитаемым и легко интегрируемым в разные веб-системы. Внутри токена могут быть любые ключ-значения: user_idemailroleexpiss и т. д., и всё это сериализуется в обычный JSON-объект. 

Web — изначально токен проектировался для использования в веб-приложениях. Он легко передаётся по HTTP: в заголовке Authorization, в Cookie, в URL, в теле POST-запроса. Но на практике он используется не только в вебе — JWT давно вышел за его пределы и применяется в мобильных приложениях, микросервисах и IoT.

Token — это маркер доступа. Клиент получает его после успешной аутентификации и предъявляет при каждом запросе как доказательство своей легитимности. Это не файл, не сессия, не объект в памяти — это просто строка, которую можно проверить и доверять, если её подпись валидна. 

JWT-токен всегда состоит из трёх частей: заголовкаполезной нагрузки и подписи. Все они представлены в виде JSON-структур, которые кодируются в base64 и соединяются точками. В итоге получается строка вроде:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsInJvbGUiOiJhZG1pbiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Её можно передавать по сети, хранить в Cookie или передавать в заголовках HTTP-запросов. 

Кратко разберём JWT по частям.

Заголовок (Header)

{
    "alg": "HS256",
    "typ": "JWT"
}

Эта часть указывает: 

  • alg — какой алгоритм используется для подписи токена. Здесь HS256, то есть HMAC с SHA-256. 

    Список всех поддерживаемых криптографических алгоритмов в JWT — JWA RFC 7518 

  • typ — тип токена, обычно это просто JWT. Поле формальное, но подчёркивает, что объект — именно JSON Web Token. 

Этот JSON сериализуется, кодируется в base64url (кодировка base64 без пробелов и без переносов) и становится первой частью токена.

Полезная нагрузка (Payload)

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 и становится второй частью токена.

Подпись (Signature)

JWT по своей природе не защищён от чтения — любой может расшифровать заголовок и payload, так как они просто закодированы в base64. Без подписи это был бы обычный JSON с открытыми утверждениями, который легко подделать. Именно поэтому у JWT есть третья, обязательная часть — подпись, которая обеспечивает целостность и аутентичность токена. 

Если злоумышленник меняет даже один байт в payload — например, меняет "room_number": 123 на "room_number": 124 — подпись уже не будет соответствовать, и сервер откажет в доступе. Подпись нельзя сгенерировать заново без знания секретного ключа. В этом и заключается её смысл: не запретить чтение токена (он не зашифрован), а сделать невозможным его подделку

Когда токен создаётся, из заголовка и полезной нагрузки формируется строка: 

base64url(header) + "." + base64url(payload) 

Эта строка подписывается с помощью криптографического алгоритма, указанного в поле alg заголовка. Результат подписания — бинарные данные, которые затем кодируются в base64url и становятся третьей частью токена. 

JWT поддерживает алгоритм noneNone означает отсутствие подписи или небезопасный JWT — по сути, просто два JSON-а. Это легально по стандарту, но на практике почти всегда запрещено, потому что приводит к критическим уязвимостям. Были случаи, когда системы принимали токены с alg: none, считая их валидными, что позволяло обойти подпись вообще. Поэтому любой валидатор JWT обязан не только проверять подпись, но и явно ограничивать допустимые алгоритмы

В итоге, собрав все части вместе, получаем полноценный JWT-токен.

Структура JWT токена
Структура JWT токена

Он самодостаточен. В нём есть и информация, и защита от подделки. Именно это делает JWT удобным для аутентификации и авторизации в распределённых системах.

Для быстрого кодирования и декодирования токенов можно пользоваться JSON Web Token (JWT) Debugger

Пример генерации JWT на Go: симметричный алгоритм подписи

Представим, что наш отель — это маленький хостел, в котором из удобств только номера, а на ресепшене клиентам выдают браслеты (пропуск от номера). На браслете указано кто этот человек, какой у него номер в отеле и подпись, гарантирующая, что он действительно гость, а не случайный прохожий с поддельным браслетом. Секрет от подписи знает только консьерж на ресепшен, и он же проверяет подпись у всех посетителей. 

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

Симметричный ключ подписи
Симметричный ключ подписи

Такой подход называется подпись симметричным алгоритмом подписи (HMAC). Используется обычно для SPA-приложений или монолитного backend-а. 

Для демонстрации данного примера напишем HTTP сервер на Go, в котором будут два метода:  

  • POST /login — метод для аутентификации пользователя по логину и паролю (Basic Auth). Взамен метод возвращает JWT-токен. Мы используем POST метод, потому что он безопаснее и логичнее для входа: логина и пароль не передаются в URL, как при GET, а отправляются в теле запроса, который защищает от утечек в логи и кэш.

  • GET /hello — метод получения информации о пользователе из токена (Bearer Auth): если токен валидный, мы извлечём из него информацию и выведем Hello {user_name}, в противном случае вернём HTTP код 401 Unauthorized.

Схема авторизации пользователя v1
Схема авторизации пользователя v1
main.go
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-аутентификация 

Чтобы выдать пользователю токен, сначала нужно его аутентифицировать. Самый простой способ сделать это — по логину и паролю через Basic Auth. Для большей безопасности лучше использовать двухфакторную аутентификацию (2FA), когда помимо пароля требуется второй фактор — например, код из SMS, приложения (OTP) или физический ключ. В своём примере мы ограничимся одним фактором (1FA) — паролем. 

Напишем реализацию нашего HTTP-обработчика POST /login. Логика обработчика проста:

  1. Из заголовка Authorization извлекаются логин и пароль.

  2. При успешной аутентификации возвращается JWT-токен. Этот токен позволит пользователю обращаться к API без повторной отправки логина и пароля.

Реализация обработчика login
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})
}

Аутентификация по паролю

Хранить пароли пользователей в базе в открытом виде — опаснейшая ошибка, которую, к сожалению, до сих пор совершают разработчики. 

Если ваша база данных будет скомпрометирована (взлом, утечка, баг) — все пароли пользователей моментально окажутся в руках злоумышленников. А ведь многие пользователи используют один и тот же пароль в разных сервисах: это означает, что взлом одного сайта может привести к взлому почты, соцсетей, банков и т. д. 

Вместо хранения пароля, мы храним его хеш — специальную «отпечаток-функцию», которую невозможно (или почти невозможно) обратить обратно в оригинальный пароль.

Принцип работы hash алгоритма
Принцип работы hash алгоритма
Пример хранения пользователей

Для простоты примера будем хранить их в памяти приложения.

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"),
	},
}

Однако хранить просто хеш — недостаточно: злоумышленник может использовать заранее подготовленные словари хешей (так называемые «радужные таблицы»). Поэтому к паролю добавляется уникальная соль — случайная строка, которая делает каждый хеш уникальным, даже если два пользователя используют одинаковые пароли.

Использование соли для хеширования паролей
Использование соли для хеширования паролей

Для хеширования паролей используют специальные медленные алгоритмы, такие как, например, bcryptscrypt и 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).

Подпись JWT-токена через HMAC (HS256) 

После аутентификации пользователя по паролю необходимо выдать ему токен, который он будет предъявлять нам в дальнейшем вместо логина и пароля. 

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 и заодно проверим подпись:

Декодированный JWT-токен
Декодированный JWT-токен

Как видим, токен валидный и содержит информацию о пользователе. 

Верификация JWT-токена (HS256) 

В этом разделе мы разберём, как осуществляется проверка подлинности и корректности access JWT-токена, полученного от клиента. Это критически важный этап для любого защищённо��о эндпоинта. Без этой проверки сервер не сможет отличить валидного пользователя от постороннего запроса с поддельным или просроченным токеном. 

Важно отметить, что верификация — это процесс, при котором мы: 

  1. Проверяем алгоритм подписи, чтобы исключить возможность подмены безопасного алгоритма на небезопасный (например, none).

  2. Проверяем подпись токена, чтобы убедиться, что он действительно был выпущен нами, а не кем-то извне.

  3. Проверяем issuer (того, кто выпустил токен), чтобы токен соответствовал ожидаемому источнику.

  4. Проверяем срок действия токена, чтобы не допустить использование уже просроченного токена. 

  5. Извлекаем полезные данные из токена — например, 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, в котором будет происходит проверка токена и передача пользователя в контекст запроса в случае успеха.

auth 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 и Refresh-токены 

Представьте, что вы приезжаете в отель, подходите к ресепшен, называете своё имя и бронирование и вам выдают ключ от номера. Этот ключ — как 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). 

Сценарий использования access и refresh-токенов
Сценарий использования access и refresh-токенов

При выходе из системы клиент может отправить запрос на /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 токена: 

ответ обработчика login
{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTE2NDg0NjEsImlhdCI6MTc1MTY0NzU2MSwiaXNzIjoiYmVzdC5ob3RlbC5jb20iLCJzdWIiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfZW1haWwiOiJib2JAZ29vZ2xlLmNvbSIsInVzZXJfbmFtZSI6ImJvYiJ9.jkPbuKAbRi9denxnxQaIMVaWnpMZkf5hkYvkHTcsQrw",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTIyNTIzNjEsImlhdCI6MTc1MTY0NzU2MSwiaXNzIjoiYmVzdC5ob3RlbC5jb20iLCJqdGkiOiI5M2RhY2QxMy1lNmFhLTRhZTUtYTMwYi0zZDg0YTQxOWExZDkiLCJzdWIiOiJib2JAZ29vZ2xlLmNvbSIsInR5cGUiOiJyZWZyZXNoIn0.RDCOoOP9DPvBpkLH6kZNFoIEggggcaqtSQ2uAChuz4Q"
}

Нам осталось реализовать метод POST /refresh для того, чтобы обменять refresh-токен на новый access и refresh-токены. 

Для этого сначала напишем метод верификации refresh-токена. Его отличие от verifyAccessToken заключается лишь в том, что нам приходится искать в хранилище данный токен и удалять его после использования (это необходимо для возможности реализовать logout или отзыва доступа).

Верификация refresh-токена
// аналогично валидации 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).

Подпись и валидация JWT на Go: RSA

Представим, что наш отель растёт: теперь у него есть не только номера, но и бассейн с баром на крыше отеля. Бармен у бассейна раздаёт бесплатные напитки, но только постояльцам отеля. Чтобы отличить гостей от случайных прохожих, ему, как и консьержу на ресепшене, нужно уметь проверять подписи на браслетах, которые подтверждают статус клиента.

До сих пор все работали по одной схеме: и консьерж, и бармен знали секрет, с помощью которого можно было проверять (а при желании и подделывать) браслеты. И вот однажды один из барм��нов решил использовать этот секрет в своих целях: сделал поддельные браслеты для друзей, и те получили доступ ко всем привилегиям отеля. 

Так стало ясно: нельзя раздавать один и тот же секрет всем подряд. Нужно разделить полномочия: только консьерж должен иметь доступ к приватному ключу и подписывать браслеты, а все остальные — бармены, охранники, официанты — могут проверять подписи, но не создавать их!

В web эта задача возникает, когда множество сервисов должно проверять подлинность пользователей, но только один — выпускать токены. В этом и помогает асимметричное шифрование, такое как RSA. Это основа для построения распределённых систем (микросервисов) и для протоколов вроде OpenID Connect.

Ассиметричный алгоритм подписи
Ассиметричный алгоритм подписи

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

 Схема авторизации пользователя v1
 Схема авторизации пользователя v1

Разделим теперь наш сервис на два микросервиса: 

  • AuthService — занимается аутентификацией и выпиской токенов. 

  • GreetingService — обслуживает запросы, проверяет токены.

Схема авторизации пользователя v2
Схема авторизации пользователя v2

Сначала создадим пару 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). Реализуем функцию загрузки ключа: 

Реализация loadPrivateKey
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 у нас нет доступа к приватному ключу, но у нас есть публичный ключ, которым мы и будем проверять токены. Для этого его необходимо загрузить:  

Реализация loadPublicKey
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, и наш публичный ключ верифицирует токен: 

Декодированный JWT токен
Декодированный JWT токен

Теперь выполним запрос в сервис 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.