Authentication and authorization patterns

Простыми словами: аутентификация против авторизации, JWT и сессии, OAuth2 с PKCE для браузера и мобильных приложений, RBAC и ABAC, защита сервис-сервис, PII в логах и аудит — когда что выбрать и почему.

← back to section

Every application sooner or later faces two questions: who is this user and what are they allowed to do. These are different problems with different tools, and they are easy to confuse. Let's break it down from scratch.

Authentication and authorization — what's the difference

Authentication answers the question "who are you?". The user proves their identity: enters a login and password, presents a token, passes Face ID. The result — the system knows it is dealing with a specific user, for example John with userId=42.

Authorization answers the question "what may you do?". Already knowing who the user is, the system decides: may John delete someone else's article, open the admin page, view someone else's order.

In practice they run one after another: first authentication, then authorization. Returning 403 Forbidden without verifying identity is a mistake.

Tokens: JWT, Opaque, and Session ID

Once the user has logged in, the server has to somehow "remember" them across subsequent requests. There are three approaches:

Session ID — the server creates a session, stores it (usually in Redis), and hands the client only a short identifier in a cookie. On every request the server loads the session from storage.

JWT (JSON Web Token) — a token that carries the data inside itself. Structure: header.payload.signature. The payload holds userId, roles, expiration. The signature lets the server verify the token locally, without hitting a database. Size — usually 300–800 bytes.

eyJhbGciOiJSUzI1NiJ9.            ← Header:  {"alg": "RS256"}
eyJzdWIiOiI0MiIsInJvbGVzIjpb     ← Payload: {"sub": "42", "roles": ["ADMIN"], "exp": 1710000000}
IkFETUlOIl19.
SflKxwRJSMeKKF2QT4fwpMe...       ← Signature: RSA signature

Opaque Token — just an identifier string like a3f8b2c1-4d5e-.... There is no data inside. To verify it you have to call the authorization server (that call is named introspection).

TypeVerificationInvalidationSize
Session IDCall to RedisInstant (delete from Redis)Small
JWTLocally (signature)Not possible until expiryMedium
OpaqueCall to auth serverInstantSmall

OAuth 2.0 and OpenID Connect

Applications used to ask the user for their login and password, then act on their behalf against other services. This is insecure: the application sees the password and can do anything.

OAuth 2.0 solves this problem: the user logs in directly with a trusted service (for example Keycloak or Google), and the application receives only a limited access token — without the password.

OpenID Connect (OIDC) is a layer on top of OAuth 2.0. It adds an id_token to the access token — a JWT with information about the user (name, email, roles). It is OIDC that turns OAuth from a "what is the application allowed to do" protocol into a "who is this user" protocol.

TokenPurpose
access_tokenWhat the application is allowed to do
refresh_tokenObtain a new access_token without logging in again
id_tokenWho the user is (OIDC only)

Authentication for a browser application (SPA)

The browser is an insecure environment. JavaScript code is visible to everyone through DevTools. The localStorage and sessionStorage stores are accessible to any script on the page. A single malicious script in a third-party library — and all tokens are compromised. This is called an XSS attack.

The browser can do one important thing: it supports cookies with the HttpOnly flag. Such a cookie is invisible to JavaScript altogether — only the browser sees it. It is exactly this property that every secure SPA approach relies on.

The simplest option. The user enters a login and password, the server verifies them, creates a session in Redis, and returns its identifier in an HttpOnly cookie.

Set-Cookie: SESSION=abc123;
    HttpOnly;       ← JavaScript cannot read it
    Secure;         ← only over HTTPS
    SameSite=Lax;   ← protection against cross-site requests
    Max-Age=86400   ← 1 day

Pros: the session is easy to delete (instantly logging the user out), simple to implement.

Cons: requires Redis to store sessions, not suitable for mobile applications.

The modern standard. The user logs in through an external Identity Provider (Keycloak, Okta, Entra ID) — the application never sees their password. Tokens are kept on the server, and only a session cookie goes to the browser.

PKCE (Proof Key for Code Exchange) — an addition to the flow that protects against theft of the intermediate authorization code. The application generates a random code_verifier, sends its hash with the request, and the code_verifier itself — when exchanging the code for a token. An interceptor without the code_verifier gets nothing.

Simplified, the flow looks like this:

  1. The SPA calls the backend — no cookie → 401.
  2. The backend redirects the browser to the IdP login page.
  3. The user enters their password on the IdP site (not the application's).
  4. The IdP returns a code in the callback.
  5. The backend exchanges the code + code_verifier for tokens at the IdP.
  6. The tokens are stored on the server (Redis), the browser receives an HttpOnly cookie.
  7. Subsequent requests carry the cookie — the backend fetches the tokens from Redis.

Why this is good: tokens never reach JavaScript, XSS is not a threat, only the IdP sees the password, the IdP can enforce MFA and single sign-on (SSO).

Configuration for 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

An alternative when an external IdP is not needed. The server issues a JWT and puts it in an HttpOnly cookie. Verification is local via the signature, Redis is not needed.

The main drawback: a JWT cannot be "revoked" before it expires. If you need instant logout — you have to maintain a list of revoked tokens (a blacklist), which effectively brings us back to server-side storage.

@Component
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 auth = new JwtAuthenticationToken(jwt,
                    jwt.getClaimAsStringList("roles").stream()
                        .map(r -> new SimpleGrantedAuthority("ROLE_" + r))
                        .toList());
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException ignored) { }
        }
        chain.doFilter(req, res);
    }
}

JWT in localStorage — why this is bad

Tutorials often store the token in localStorage. It is convenient but dangerous: any JavaScript on the page can read it. A single vulnerability in a third-party analytics or advertising SDK — and users' tokens are compromised.

If you use JWT for the browser — only in an HttpOnly cookie.

What to choose for an SPA

ApproachWhen to use
OAuth2 + PKCE via BFFThe best choice when an IdP is available (Keycloak, Okta)
Session (cookie + Redis)A simple option without an external IdP
JWT in an HttpOnly cookieStateless, but invalidation is harder
JWT in localStorageDo not use in production

Authentication for a mobile application

A mobile application is a different environment. It has secure storage (iOS Keychain, Android EncryptedSharedPreferences) where tokens can be placed safely. There is no browser cookie — tokens are sent in the Authorization: Bearer <token> header.

The same Authorization Code Flow + PKCE, but with two differences:

  • No client_secret — a mobile application cannot store a secret safely. That's fine: PKCE takes over its role.
  • Login through the system browser (Chrome Custom Tabs on Android, ASWebAuthenticationSession on iOS), not an embedded WebView. A WebView would let the application intercept the entered data — the system browser does not allow that.
// 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()

After a successful login the access_token and refresh_token are stored in the Keychain / EncryptedSharedPreferences.

JWT Bearer Token

The application obtains a JWT from the authorization server and adds it to every request. The backend verifies the signature locally using the IdP's public keys (JWKS).

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

When the access_token expires (usually after 5–15 minutes), the application automatically refreshes it using the refresh_token, without bothering the user with another login.

Opaque Token + Introspection

The application obtains an opaque token. On every request the backend asks the authorization server: "is this token valid?" — and gets back the user's data.

Pro: instant invalidation — the auth server revokes the token, and the next request returns 401.

Con: every request = an extra network call to the auth server. If the auth server is down — the whole application stops working.

Refreshing tokens

The access_token is short-lived — 5–15 minutes. The refresh_token — days or weeks. When the access_token expires, the application uses the refresh_token to obtain a new one, without the user logging in again.

Refresh Token Rotation — a good security practice: on every refresh the IdP issues a new refresh_token and invalidates the old one. If an attacker stole a token and tried to reuse it — the IdP notices this and invalidates the whole chain. The very next request from the original client returns 401, signaling compromise.

Authorization: what the user is allowed to do

Once the system knows who the user is, it has to decide what they may do. There are three main approaches.

RBAC — roles

The user is assigned a role (ADMIN, EDITOR, VIEWER), and the role determines the available operations. It is checked before entering the business logic.

@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);
    }
}

Suitable when the set of roles is fixed and small. Not suitable when you need to check properties of a specific object.

ABAC — attributes

The access decision is made based on attributes: of the user, the resource, the action, and the context. The rules are extracted into a dedicated policy component.

@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;
    }
}

// In the controller:
// @PreAuthorize("@access.canEditArticle(#id, authentication.principal)")

Here the condition is more complex: a user may edit an article only if they are an editor, the article is from their department, and it is still in draft status. RBAC cannot handle this — you need to know the data of the specific object.

Resource-Based — the owner

A special case of ABAC: the user sees and modifies only their own objects.

@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);
}

When to use which

ModelWhen it fits
RBACFixed roles, coarse-grained check
ABACAccess depends on object properties or context
Resource-BasedThe user works only with their own data
RBAC + ABACRBAC at the API Gateway/BFF level, ABAC inside the service

Authorization in microservices

In a microservice architecture a request passes through several layers, and each one checks its own thing:

API Gateway — technical control: the token is present, the signature is valid, it hasn't expired, rate limits aren't exceeded. It forwards the user's identifier to downstream services.

BFF / Application Layer — a coarse role check: is this endpoint available to this user at all?

Domain service — a fine-grained check: is the user the owner of this specific object? Does the object's status permit the operation? This cannot be pushed onto the Gateway — it doesn't know the domain model.

The rule: the Gateway decides "who", the service decides "what is allowed".

In short

  • Authentication — who you are, authorization — what you may do. These are different problems.
  • Session ID is invalidated instantly, JWT is verified locally without calling the server.
  • For the browser — tokens only in an HttpOnly cookie, never in localStorage.
  • OAuth2 + PKCE — the standard for SPAs and mobile apps: the user logs in with the IdP, the application never sees the password.
  • For mobile — login through the system browser (not a WebView), tokens in the Keychain.
  • Refresh Token Rotation protects against reuse of a stolen token.
  • RBAC — roles, ABAC — object and context attributes, Resource-Based — the owner.
  • In microservices: the Gateway verifies the token, the domain service checks the business rules.

Further reading