Аутентификация и авторизация

Кто ты и что тебе разрешено — два разных вопроса, на которые API отвечает по-разному.

Аутентификация (authn) устанавливает, кто делает запрос; авторизация (authz) решает, что этому субъекту позволено.

Любой публичный API рано или поздно сталкивается с вопросом доступа: данные одного пользователя нельзя отдавать другому, платные функции — бесплатным аккаунтам, а административные операции — кому попало. Разделение на две стадии — сначала опознать, потом проверить права — лежит в основе всех схем безопасности REST. Перепутать их легко: например, проверить токен на валидность (authn пройдена), но забыть убедиться, что объект принадлежит именно этому пользователю (authz провалена). Это одна из самых дорогих ошибок в индустрии.

Authn против authz на конкретике

Представьте запрос на удаление чужого комментария. Authn отвечает на вопрос «это вообще зарегистрированный пользователь Иван?» — по токену видно, что да. Authz отвечает на вопрос «можно ли Ивану удалять этот комментарий?» — и если комментарий написал Пётр, ответ «нет», даже при идеально валидном токене. Authn возвращает 401 Unauthorized (личность не установлена), authz — 403 Forbidden (личность известна, но прав не хватает).

API-ключи: просто, но статично

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

curl https://api.example.com/v1/orders \
  -H "X-API-Key: sk_live_4eC39HqLyjWDarjtT1zdp7dc"

Плюсы: понятно, быстро интегрируется, удобно для server-to-server. Минусы существенны: ключ статичен — он не истекает сам, не содержит информации о пользователе и при утечке даёт полный доступ, пока его вручную не отзовут. Поэтому API-ключи годятся для бэкенд-интеграций, но плохо подходят для приложений в браузере, где их легко украсть.

JWT: самодостаточный токен

JSON Web Token решает проблему «токен ничего о себе не знает». Это строка из трёх частей, разделённых точками: header.payload.signature. Каждая часть — это Base64URL.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MDAwMDB9.s5d8F_signature_bytes
└─────── header ──────┘ └──────────── payload ───────────┘ └─── signature ───┘

В декодированном виде это выглядит так:

{ "alg": "HS256", "typ": "JWT" }
{ "sub": "123", "role": "user", "exp": 1700000000, "iat": 1699996400 }
  • header — алгоритм подписи (HS256, RS256) и тип токена.
  • payload — «claims»: sub (кто), exp (когда истекает, Unix-время), role, любые свои поля.
  • signature — подпись от header + payload секретным ключом сервера.

Ключевое свойство — stateless: сервер не хранит токен у себя. Получив JWT, он пересчитывает подпись и сравнивает; если совпало и exp ещё не наступил — токен подлинный. Не нужна база сессий, любой инстанс кластера проверит токен сам. Платой за это становится истечение: пока токен живёт, его нельзя «выключить» без дополнительных механизмов (короткий exp + refresh-токены, чёрные списки).

Bearer-токен в заголовке Authorization

Стандартный способ передать токен (JWT или OAuth) — заголовок Authorization со схемой Bearer («предъявитель»):

curl https://api.example.com/v1/me \
  -H "Authorization: Bearer <token>"

Слово Bearer означает «кто предъявил — тот и владелец»: токен не привязан к устройству, поэтому украденный Bearer-токен работает у злоумышленника так же, как у вас. Отсюда два правила: только HTTPS и короткое время жизни.

OAuth2: обзор ролей и authorization code flow

OAuth2 нужен, когда стороннее приложение хочет действовать от вашего имени, не получая ваш пароль. Роли:

  • Resource Owner — пользователь (владелец данных).
  • Client — приложение, которому нужен доступ.
  • Authorization Server — выдаёт токены (например, accounts.google.com).
  • Resource Server — собственно API с данными.

Самый распространённый сценарий — authorization code flow:

Пользователь          Client            Auth Server         Resource Server
    |  жму "Войти"     |                    |                     |
    |----------------->|  редирект на login |                     |
    |<--------- редирект -------------->|                     |
    |  логинюсь, разрешаю доступ        |                     |
    |<------ редирект с ?code=ABC ------|                     |
    |----------------->| code=ABC           |                     |
    |                  |-- code + secret -->|                     |
    |                  |<--- access_token --|                     |
    |                  |------ Bearer access_token ------------>|
    |                  |<------------- данные -------------------|

Суть: клиент сначала получает одноразовый code через браузер, затем меняет его на access_token в прямом серверном запросе со своим секретом. Так токен не светится в адресной строке. Полученный access_token обычно короткоживущий, и вместе с ним выдаётся refresh_token — долгоживущий, по которому клиент тихо получает новый access без повторного логина пользователя. Каждый токен ограничен набором прав через scopes (например, read:profile, но не write:payments), поэтому стороннее приложение получает ровно столько доступа, сколько пользователь явно одобрил — а не весь аккаунт целиком, как было бы при передаче пароля.

Как работает под капотом

Проверка JWT на сервере — это перевычисление подписи. Сервер берёт base64(header) + "." + base64(payload), подписывает своим ключом тем же алгоритмом и сравнивает с третьей частью токена. Совпало — данные не подделаны; не совпало — токен отвергается. Затем проверяется exp: если текущее время больше — 401. Важно: payload не зашифрован, а лишь подписан, поэтому в него нельзя класть секреты — любой может его раскодировать. Подпись защищает от изменения, но не от чтения.

Где хранить токен на клиенте

Два популярных варианта и их риски:

ХранилищеПлюсРиск
localStorageпросто, доступно из JSлюбой XSS читает токен напрямую
httpOnly cookieJS не видит токен → XSS не украдётуязвима к CSRF без защиты

Если на странице сработает XSS-инъекция (внедрённый скрипт), токен из localStorage утекает мгновенно. Cookie с флагом httpOnly недоступна скриптам, поэтому считается безопаснее против XSS, но требует защиты от CSRF (флаг SameSite, CSRF-токены). Общая рекомендация: для чувствительных приложений — httpOnly + Secure + SameSite.

Частые ошибки

  • Считать, что валидный токен = есть права. Authn пройдена, а authz объекта забыта — классическая дыра (BOLA).
  • Класть в JWT-payload пароли или приватные данные — payload читается кем угодно.
  • Бессрочные токены без exp — украденный токен работает вечно.
  • Хранить токен в localStorage в приложении с пользовательским контентом, открытым для XSS.
  • Передавать токен по HTTP без TLS — Bearer перехватывается в открытом виде.
  • Возвращать 403 там, где личность вообще не установлена (нужен 401), и наоборот.

Итоги

  • Authn (кто ты) и authz (что можно) — разные стадии; коды 401 и 403 соответственно.
  • API-ключи просты, но статичны и не истекают — для server-to-server.
  • JWT самодостаточен (header.payload.signature), stateless, но обязан истекать.
  • Токен передают в Authorization: Bearer <token>; Bearer = «у кого токен, тот и владелец» → только HTTPS.
  • OAuth2 даёт доступ без пароля; authorization code flow меняет одноразовый code на токен.
  • httpOnly cookie безопаснее localStorage против XSS, но требует защиты от CSRF.
Проверьте себя
1. Пользователь предъявил валидный токен, но пытается удалить чужой объект. Какой код ответа уместен?
A401 Unauthorized
B403 Forbidden
C400 Bad Request
D200 OK
2. Из каких трёх частей состоит JWT?
Aheader.body.footer
Bheader.payload.signature
Ckey.value.hash
Dtype.data.crc
3. Почему нельзя класть пароль в payload JWT?
Apayload ограничен по размеру
Bpayload подписан, но не зашифрован — его легко раскодировать
CJWT не поддерживает строки
Dpayload удаляется при истечении токена
4. Чем хранение токена в httpOnly cookie выгодно отличается от localStorage?
Acookie работает быстрее
BJS не имеет доступа к httpOnly cookie, поэтому XSS не украдёт токен
Ccookie не истекает
DlocalStorage не отправляется на сервер