Паттерны аутентификации и авторизации

Паттерны аутентификации и авторизации простыми словами: чем «кто ты» отличается от «что тебе можно», JWT и сессии, OAuth2 с PKCE, RBAC и ABAC, вызовы между сервисами.

← назад к разделу

Каждое приложение рано или поздно задаётся двумя вопросами: кто этот пользователь и что ему разрешено делать. Это разные задачи с разными инструментами, и их легко перепутать. Разберём с нуля.

Разницу проще увидеть на одном запросе: «кто ты» проверяют один раз на границе, а «что тебе можно» — дальше по пути, и остановить запрос может только тот уровень, который видит сам объект.

GET /orders/77 · Authorization: Bearer eyJhbGciOiJSUzI1NiJ9… GET /orders/19 · тот же токен, та же роль EDITOR в токене: sub=42, roles=[EDITOR], до exp 9 минут из 15 уровень 1 · API Gateway — «кто ты»: токен и подпись подпись верна, exp не истёк → пропустил, sub=42 дальше пропустил; чей это заказ — доменной модели он не знает sub=42 идёт дальше уровень 2 · BFF — «что можно»: роль на эндпоинт (RBAC) запрос сюда ещё не дошёл /orders/** открыт ролям ADMIN и EDITOR → роль есть, пропустил пропустил; какой это заказ и чей — он не знает роль подошла уровень 3 · доменный сервис — владелец объекта (ABAC) запрос сюда ещё не дошёл заказ №77: userId=1005, а в токене 42 → 403 Forbidden заказ №19: userId=42, владелец совпал → 200 OK аутентификация — один раз на границе: «кто ты» роль пускает к эндпоинту, но не к конкретному объекту два верхних уровня пропустили — остановил третий токен тот же: решает не он, а данные объекта

Подпись токена и роль пускают запрос к эндпоинту, но не к конкретному объекту: заказ №77 остановил только доменный сервис, где видно userId=1005. Тот же токен на своём заказе №19 проходит все три уровня.

Аутентификация и авторизация — в чём разница

Аутентификация отвечает на вопрос «кто ты?». Пользователь доказывает свою личность: вводит логин и пароль, предъявляет токен, проходит Face ID. Результат — система знает, что перед ней конкретный пользователь, например Иван с userId=42.

Авторизация отвечает на вопрос «что тебе можно?». Уже зная, кто пользователь, система решает: может ли Иван удалить чужую статью, открыть страницу администратора, посмотреть чужой заказ.

На практике они идут одна за другой: сначала аутентификация, потом авторизация. Вернуть 403 Forbidden без проверки личности — ошибка.

Токены: JWT, Opaque и Session ID

Когда пользователь вошёл, сервер должен как-то «помнить» его в следующих запросах. Есть три подхода:

Session ID — сервер создаёт сессию, хранит её (обычно в Redis), а клиенту отдаёт только короткий идентификатор в cookie. При каждом запросе сервер загружает сессию из хранилища.

JWT (JSON Web Token) — токен, который содержит данные прямо внутри себя. Структура: header.payload.signature. В payload лежат userId, роли, срок действия. Подпись позволяет серверу проверить токен локально, без обращения к базе. Размер — от 300–400 байт у самого простого токена до 1–2 КБ у токена Keycloak, в котором лежит список realm- и client-ролей. За размером стоит следить: токен едет в заголовке каждого запроса, а прокси и серверы обычно не принимают заголовки больше 4–8 КБ. Разрастётся список ролей — и запросы начнут отваливаться с жалобой на слишком большой заголовок (431, а у части прокси просто 400), не дойдя до приложения.

eyJhbGciOiJSUzI1NiJ9 . eyJzdWIiOiI0MiIsInJvbGVzIjpbIkFETUlOIl19 . SflKxwRJSMeKKF2QT4fwpMe... заголовок: {"alg": "RS256"} тело: sub 42, roles [ADMIN], exp 1710000000 подпись RSA

Все данные о пользователе токен несёт с собой, поэтому сервер проверяет его подписью на месте, без похода в базу или к серверу авторизации. Плата — отозвать выданный токен нельзя: он остаётся действительным, пока не выйдет срок из exp.

Opaque Token — просто строка-идентификатор вроде a3f8b2c1-4d5e-.... Данных внутри нет. Для проверки нужно обратиться к серверу авторизации (этот запрос называется introspection).

ТипПроверкаИнвалидацияРазмер
Session IDЗапрос к RedisМгновенная (удалить из Redis)Маленький
JWTЛокально (подпись)Нельзя до истеченияСредний
OpaqueЗапрос к auth-серверуМгновеннаяМаленький

OAuth 2.0 и OpenID Connect

Раньше приложения просили у пользователя его логин и пароль, а потом от его имени ходили в другие сервисы. Это небезопасно: приложение видит пароль и может делать что угодно.

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

OpenID Connect (OIDC) — надстройка над OAuth 2.0. Добавляет к токену доступа ещё id_token — JWT с информацией о том, кто вошёл: идентификатор, имя, почта. На роли из id_token не опираются: он отвечает на вопрос «кто это», а права лежат в токене доступа. Положить туда роли провайдер умеет — тот же Keycloak делает это настройкой, — но проверять по ним доступ не стоит: id_token адресован приложению, которое показывает пользователя на экране, а не API, которое решает, что можно. Именно OIDC превращает OAuth из протокола «что приложению разрешено» в протокол «кто этот пользователь».

ТокенНазначение
access_tokenЧто приложению разрешено делать
refresh_tokenПолучить новый access_token без повторного входа
id_tokenКто пользователь (только OIDC)

Аутентификация для браузерного приложения (SPA)

Браузер — небезопасная среда. JavaScript-код виден всем через DevTools. Хранилища localStorage и sessionStorage доступны любому скрипту на странице. Один вредоносный скрипт — и все токены скомпрометированы.

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

Браузер умеет кое-что важное: он поддерживает cookie с флагом HttpOnly. Такой cookie JavaScript вообще не видит — только браузер. Именно это свойство используют все безопасные подходы для SPA.

Самый простой вариант. Пользователь вводит логин и пароль, сервер проверяет их, создаёт сессию в Redis и возвращает её идентификатор в HttpOnly cookie.

Set-Cookie: SESSION=abc123; HttpOnly; Secure; SameSite=Lax; Max-Age=86400 JavaScript не видит только по HTTPS защита от чужих сайтов живёт сутки

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

Плюсы: сессию легко удалить — пользователя выкидывает из системы мгновенно; простая реализация.

Минусы: сессии живут в Redis, и он становится обязательной частью каждого запроса — пока Redis недоступен, ни один пользователь не может ничего сделать, а если хранилище потеряло данные, все разом разлогинены. И cookie — браузерный механизм, мобильному приложению он не подходит.

OAuth2 Authorization Code Flow + PKCE (рекомендуется для SPA)

Современный стандарт. Пользователь входит через внешний Identity Provider (Keycloak, Okta, Entra ID) — приложение никогда не видит его пароль. Токены хранятся на сервере, а в браузер идёт только сессионная cookie.

У этого потока есть слабое место: промежуточный код авторизации едет через браузер в редиректе, и его можно перехватить — подменённое приложение или расширение ловит редирект и само обменивает код на токены. Эту дыру закрывает PKCE (Proof Key for Code Exchange): приложение генерирует случайный code_verifier, отправляет его хеш при запросе, а сам code_verifier — при обмене кода на токен. Перехватчик без code_verifier ничего не получит.

Упрощённо поток выглядит так:

  1. SPA обращается к backend — нет cookie → 401. Перенаправить в ответ на фоновый запрос нельзя: браузер такой редирект отработает молча внутри того же запроса. Поэтому приложение само переводит браузер на /login бэкенда.
  2. Бэкенд перенаправляет браузер на страницу входа IdP.
  3. Пользователь вводит пароль на сайте IdP (не приложения).
  4. IdP возвращает code в callback.
  5. Backend обменивает code + code_verifier на токены у IdP.
  6. Токены сохраняются на сервере (Redis), браузер получает HttpOnly cookie.
  7. Дальнейшие запросы идут с cookie — backend достаёт токены из Redis.

Почему это хорошо: токены никогда не попадают в JavaScript — со страницы их просто нечем достать; пароль видит только IdP, и он же может добавить второй фактор и единый вход (SSO).

Важная оговорка: HttpOnly закрывает кражу токена, но не отменяет XSS. Внедрённый на страницу скрипт токен не прочитает — зато сможет слать запросы к вашему же бэкенду, и cookie к ним браузер подставит сам. Пользователь при этом ничего не заметит. То есть украсть доступ «с собой» не выйдет, а сделать что-то от имени жертвы, пока она на странице, — вполне.

Конфигурация для Spring Boot:

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: my-web-app
            client-secret: ${KEYCLOAK_CLIENT_SECRET}
            scope: openid,profile,email,offline_access
            authorization-grant-type: authorization_code
        provider:
          keycloak:
            issuer-uri: https://keycloak.example.com/realms/my-realm

Альтернатива, когда внешний IdP не нужен. Сервер выдаёт JWT и кладёт его в HttpOnly cookie. Проверка — локально по подписи, Redis не нужен.

Главный недостаток: JWT нельзя «отозвать» до истечения срока. Если доступ нужно отобрать мгновенно — придётся вести список отозванных токенов (чёрный список), что фактически возвращает нас к серверному хранилищу.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtCookieFilter extends OncePerRequestFilter {

    private final JwtDecoder jwtDecoder;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {

        String token = extractFromCookie(req, "TOKEN");
        if (token != null) {
            try {
                Jwt jwt = jwtDecoder.decode(token);
                var roles = jwt.getClaimAsStringList("roles");
                var authorities = (roles == null ? List.<String>of() : roles).stream()
                    .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                    .toList();
                SecurityContextHolder.getContext()
                    .setAuthentication(new JwtAuthenticationToken(jwt, authorities));
            } catch (JwtException e) {
                // не молча: битый или просроченный токен стоит увидеть в логах
                log.debug("Невалидный токен в cookie: {}", e.getMessage());
            }
        }
        chain.doFilter(req, res);
    }

    private static String extractFromCookie(HttpServletRequest req, String name) {
        Cookie[] cookies = req.getCookies();
        if (cookies == null) {
            return null;
        }
        return Arrays.stream(cookies)
            .filter(c -> name.equals(c.getName()))
            .map(Cookie::getValue)
            .findFirst()
            .orElse(null);
    }
}

Две мелочи, на которых этот фильтр обычно и ломается. Первая: getClaimAsStringList возвращает null, если такого поля в токене вообще нет, — поэтому список ролей проверяют до .stream(). Иначе вместо честного «токен не подошёл» приложение получит падение на пустом месте, и клиенту уйдёт 500 вместо 401: catch (JwtException) такую поломку не ловит. Вторая: поле roles здесь — наше собственное, мы сами его и кладём в токен. У Keycloak роли лежат глубже, в realm_access.roles и resource_access.<client>.roles, и достают их отдельным конвертером.

Одно важное дополнение к этому примеру. Как только токен переехал в cookie, браузер начинает подставлять его сам — в том числе к запросам, которые инициировала чужая страница. Значит, вместе с таким фильтром обязательно нужна защита от подделки межсайтовых запросов: атрибут SameSite у cookie и парный токен, который Spring Security умеет выдавать из коробки. Для варианта с заголовком Authorization это не требовалось, для cookie — обязательно.

JWT в localStorage — почему это плохо

В туториалах часто хранят токен в localStorage. Это удобно, но опасно: любой JavaScript на странице может его прочитать. Одна уязвимость в сторонней аналитике или рекламном SDK — и токены пользователей скомпрометированы.

Если используете JWT для браузера — только в HttpOnly cookie.

Что выбрать для SPA

ПодходКогда использовать
OAuth2 + PKCE через BFFЛучший выбор при наличии IdP (Keycloak, Okta)
Сессия (cookie + Redis)Простой вариант без внешнего IdP
JWT в HttpOnly cookieStateless, но сложнее инвалидация
JWT в localStorageНе использовать в продакшене

Аутентификация для мобильного приложения

Мобильное приложение — другая среда. У него есть защищённое хранилище (iOS Keychain, Android EncryptedSharedPreferences), куда можно безопасно положить токены. Нет браузерного cookie — токены передаются в заголовке Authorization: Bearer <token>.

OAuth2 + PKCE для мобильных (рекомендуется)

Тот же Authorization Code Flow + PKCE, но с двумя отличиями:

  • Нет client_secret — мобильное приложение не может хранить секрет безопасно. Это нормально: его роль берёт на себя PKCE.
  • Вход через системный браузер (Chrome Custom Tabs на Android, ASWebAuthenticationSession на iOS), а не встроенный WebView. WebView позволял бы приложению перехватить введённые данные — системный браузер этого не даёт.
// Android, AppAuth SDK
val authRequest = AuthorizationRequest.Builder(
    serviceConfig,
    "mobile-app",
    ResponseTypeValues.CODE,
    Uri.parse("myapp://callback")
).setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier())
 .setScopes("openid", "profile", "email", "offline_access")
 .build()

После успешного входа access_token и refresh_token сохраняются в Keychain / EncryptedSharedPreferences.

JWT Bearer Token

Приложение получает JWT от сервера авторизации и добавляет его к каждому запросу. Backend проверяет подпись локально по открытым ключам IdP (JWKS).

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm

Когда access_token истекает (обычно через 5–15 минут), приложение автоматически обновляет его с помощью refresh_token, не беспокоя пользователя повторным входом.

Opaque Token + Introspection

Приложение получает непрозрачный токен. Backend при каждом запросе спрашивает сервер авторизации: «этот токен действителен?» — и получает данные пользователя.

Плюс: мгновенная инвалидация — auth-сервер отзывает токен, и следующий запрос вернёт 401.

Минус: каждый запрос = дополнительный сетевой вызов к auth-серверу. Если auth-сервер недоступен — всё приложение перестаёт работать.

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

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

Обновление токенов

access_token живёт недолго — 5–15 минут. refresh_token — дни или недели. Когда access_token истекает, приложение использует refresh_token, чтобы получить новый, без повторного входа пользователя.

Refresh Token Rotation — хорошая практика безопасности: при каждом обновлении IdP выдаёт новый refresh_token и инвалидирует старый. Если злоумышленник украл токен и попытался использовать его повторно — IdP заметит это и инвалидирует всю цепочку. Следующий же запрос оригинального клиента вернёт 401, что сигнализирует о компрометации.

вход выданы access A1 и refresh R1 12 мин access A1 истёк 12 мин R1 обменян на A2 и R2, R1 погашен 20 мин тот же R1 предъявлен второй раз 20 мин провайдер гасит всю цепочку 20 мин настоящий клиент с R2 получает 401

Смотрите на отметку 20 минут: повторно предъявленный R1 не продлевает доступ, а гасит всю цепочку, и настоящий клиент на своём R2 получает 401 - это и есть сигнал о краже.

Почему access живёт минуты, а не сутки и не минуту

Цифра «5–15 минут» выглядит взятой с потолка, и понять её проще всего через вопрос «что будет, когда доступ отозвали». Роль администратора сняли, сотрудника уволили, токен украли — а выданный JWT продолжает действовать, потому что проверяется подписью и ни у кого разрешения не спрашивает. Значит, срок жизни токена — это и есть окно, в течение которого отозванный доступ ещё работает.

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

Что делать, когда терпеть нельзя. Сокращать срок точечно: для административных ролей и денежных операций берут минуту-две, а не общее значение для всех. Отзывать не токен, а сессию: refresh_token отзывается мгновенно, и через срок жизни access пользователь просто не получит новый. А для случаев «прямо сейчас» держат отдельную проверку — сервис спрашивает у сервера авторизации статус сессии или читает признак блокировки из своего кэша, который обновляется событием.

Из того же рассуждения следует правило, которое экономит много отладки: в токене живёт только то, что не меняется за его срок. Роли в токене опасны именно тем, что меняются: сняли роль в провайдере, а в токене она есть ещё десять минут. Поэтому длинный срок жизни и роли внутри вместе не сочетаются.

Выход: почему после «Выйти» пользователь остаётся внутри

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

Убрать токен у клиента. В схеме с BFF это удаление серверной сессии и cookie (ответ с истёкшим Set-Cookie), без BFF — удаление токенов из памяти приложения. Этот шаг делают всегда, и сам по себе он не отзывает ничего: кто успел скопировать токен, продолжит им пользоваться до конца срока.

Отозвать refresh. Долгий доступ даёт refresh_token, и его отзывают явно — у сервера авторизации для этого есть отдельная ручка отзыва. После отзыва продлить сессию нельзя, и доступ гаснет через срок жизни access-токена. Это и есть настоящая граница выхода: не мгновенно, но не дольше тех самых 5–15 минут.

Закончить сессию у провайдера. Если этого не сделать, пользователь нажимает «Выйти», потом «Войти» — и оказывается внутри без ввода пароля: сессия в Keycloak или у Google жива, и вход проходит молча. Человек читает это как «выход не работает», и он прав. Отвечает за шаг переход на адрес завершения сессии у провайдера (end_session_endpoint) с указанием id_token и адреса возврата.

Есть и четвёртая часть, про которую вспоминают последней: остальные приложения. Один вход через провайдера открыл доступ в три приложения, и выход из одного не должен оставлять открытыми два других. За это отвечает выход по обратному каналу: провайдер сам вызывает заранее настроенный адрес каждого приложения и сообщает, что сессия закончилась, а приложение удаляет свою серверную сессию. Работает это только когда адрес настроен у каждого клиента — иначе провайдер просто никого не оповещает, молча.

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

Авторизация: что пользователю разрешено

После того как система знает, кто пользователь, нужно решить — что ему можно. Есть три основных подхода.

RBAC — роли

Пользователю назначают роль (ADMIN, EDITOR, VIEWER), роль определяет доступные операции. Проверяется до входа в бизнес-логику.

@RestController
public class ArticleController {

    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/articles/{id}")
    public void delete(@PathVariable Long id) {
        articleService.delete(id);
    }

    @PreAuthorize("hasAnyRole('ADMIN', 'EDITOR')")
    @PutMapping("/articles/{id}")
    public ArticleDto update(@PathVariable Long id, @RequestBody UpdateRequest body) {
        return articleService.update(id, body);
    }
}

Подходит, когда набор ролей фиксирован и небольшой. Не подходит, когда нужно проверять свойства конкретного объекта.

ABAC — атрибуты

Решение о доступе принимается на основе атрибутов: пользователя, ресурса, действия и контекста. Правила выносятся в отдельный компонент политики.

@Component("access")
public class AccessPolicy {

    public boolean canEditArticle(Long articleId, UserPrincipal user) {
        Article article = articleRepository.findById(articleId).orElse(null);
        if (article == null) return false;

        return user.hasRole("EDITOR")
            && article.getDepartment().equals(user.getDepartment())
            && article.getStatus() == ArticleStatus.DRAFT;
    }
}

// В контроллере:
// @PreAuthorize("@access.canEditArticle(#id, authentication.principal)")

Здесь условие сложнее: пользователь может редактировать статью только если он редактор, статья из его отдела и ещё в статусе черновика. RBAC с этим не справится — нужно знать данные конкретного объекта.

Это вид схемы с высоты. Полный разбор — компонент доступа против проверки внутри обработчика команды, состояние объекта как атрибут, списки, которые фильтруют вместо проверки, и обязательный тест «чужой объект отвечает 404» — в статье ABAC: владение ресурсом.

Resource-Based — владелец

Частный случай ABAC: пользователь видит и изменяет только свои объекты.

@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable Long id, Authentication auth) {
    Order order = orderRepository.findById(id)
        .orElseThrow(() -> new NotFoundException("Order not found"));

    Long currentUserId = ((UserPrincipal) auth.getPrincipal()).getId();
    if (!order.getUserId().equals(currentUserId)) {
        throw new AccessDeniedException("Not your order");
    }

    return orderMapper.toDto(order);
}

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

Когда что использовать

МодельКогда подходит
RBACФиксированные роли, грубая проверка
ABACДоступ зависит от свойств объекта или контекста
Resource-BasedПользователь работает только со своими данными
RBAC + ABACRBAC на уровне API Gateway/BFF, ABAC внутри сервиса

Авторизация в микросервисах

В микросервисной архитектуре запрос проходит через несколько уровней, и на каждом проверяется своё:

API Gateway — технический контроль: токен есть, подпись верна, срок не истёк, лимиты не превышены. Пробрасывает идентификатор пользователя в downstream-сервисы.

BFF / Application Layer — грубая проверка по роли: этому пользователю вообще доступен этот эндпоинт?

Доменный сервис — тонкая проверка: пользователь — владелец этого конкретного объекта? Статус объекта позволяет операцию? Это нельзя вынести на Gateway — он не знает доменную модель.

Правило: Gateway решает «кто», сервис решает «что можно».

Это карта уровней, а не инструкция. Каждый уровень с примером одного запроса, прошедшего все три, с разными кодами отказа, с разбором того, почему владение нельзя поднять на шлюз, и с тем же набором уровней для запроса не от человека, а от сервиса, — в статье Где делать проверку: Gateway, BFF или домен.

Когда всё это избыточно

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

Внутренняя система на несколько десятков человек. Одно приложение, одна база, пользователи из одного отдела. Серверная сессия и форма входа закрывают вопрос целиком: сессия отзывается мгновенно, выход работает по-настоящему, ролей четыре и лежат они в базе. Ставить отдельный провайдер идентичности, чтобы одно приложение получало токены само у себя, значит добавить сервис, базу, обновления и новую точку отказа ради схемы, которая ничего здесь не решает. Развилка появляется, когда приложений становится больше одного и людям надоедает входить в каждое; тогда и стоит читать статью про Keycloak.

Сессия честнее токенов, если клиент только браузер. Схема с BFF и так заканчивается серверной сессией в cookie, а токены в ней — внутренняя механика между вашим приложением и вашим провайдером. Если внешних клиентов нет и мобильного не планируется, сессия в хранилище даёт то, чего у токенов нет из коробки: мгновенный отзыв, список активных сессий пользователя и работающий выход без всяких обратных каналов.

Непрозрачный токен там, где важен отзыв. Когда сервисов немного и они рядом с сервером авторизации, непрозрачный токен с коротким кэшем проверок проще: отзыв действует почти сразу, и не нужно ничего придумывать про окно в 5–15 минут. JWT выигрывает при масштабе — много сервисов, много запросов и нежелание зависеть от провайдера на каждом вызове.

Общее правило: сложность схемы входа оправдана числом клиентов и числом сервисов, а не желанием «сделать правильно». Лишний провайдер идентичности в маленькой системе даёт не безопасность, а ещё один сервис, за обновлениями которого никто не следит.

Коротко

  • Аутентификация — кто ты, авторизация — что тебе можно. Это разные задачи.
  • Session ID инвалидируется мгновенно, JWT проверяется локально без обращения к серверу.
  • Для браузера — токены только в HttpOnly cookie, никогда в localStorage.
  • OAuth2 + PKCE — стандарт для SPA и мобильных приложений: пользователь входит у IdP, приложение пароля не видит.
  • Для мобильных — вход через системный браузер (не WebView), токены в Keychain.
  • Refresh Token Rotation защищает от повторного использования украденного токена.
  • RBAC — роли, ABAC — атрибуты объекта и контекста, Resource-Based — владелец; в микросервисах шлюз проверяет токен, а доменный сервис — бизнес-правила.
  • Срок жизни access — это окно, в котором отозванный доступ ещё работает: отсюда 5–15 минут, точечно короче для админов и денег, и в токене живёт только то, что за его срок не меняется.
  • Выход — три действия: убрать токен у клиента, отозвать refresh, закончить сессию у провайдера; остальным приложениям о выходе сообщает обратный канал, если у них настроен адрес.
  • Сложность схемы оправдана числом клиентов и сервисов: для внутренней системы на десятки человек серверная сессия честнее токенов, а непрозрачный токен с кэшем проверок проще там, где важен быстрый отзыв.

Что почитать дальше