Build CoreOrdered learning track

Token Authentication Pattern

Learn Java Authentication Pattern - Part 016

Token Authentication Pattern untuk Java authentication: bearer token, JWT, opaque token, reference token, token extraction, validation pipeline, Spring Security Resource Server, JAX-RS filters, claim design, transport, failure modes, dan decision matrix.

13 min read2587 words
PrevNext
Lesson 1640 lesson track09–22 Build Core
#java#authentication#token#bearer-token+7 more

Part 016 — Token Authentication Pattern

Target part ini: memahami token authentication sebagai pattern, bukan sekadar “pakai JWT”. Kita akan membahas bearer token, JWT, opaque token, reference token, extraction, validation, claim design, Spring Security Resource Server, JAX-RS implementation, API boundaries, storage, leakage, replay, revocation, observability, dan decision matrix.

Token authentication sering disederhanakan menjadi kalimat:

Use JWT so the API is stateless.

Kalimat itu tidak salah sepenuhnya, tapi terlalu dangkal.

Token authentication adalah desain pemindahan sebagian authentication state dari server-side session store ke credential yang dibawa oleh client.

Pertanyaan yang harus dijawab bukan “JWT atau bukan?”

Pertanyaan yang lebih benar:

Who issues the token?
Who consumes it?
What does it prove?
How is it validated?
How is it scoped?
How long does it live?
Can it be revoked?
Where can it leak?
What happens when signing keys rotate?
What boundary does this token cross?

Part ini membangun mental model sebelum Part 017 masuk lebih detail ke JWT production-grade.


1. Token Authentication Mental Model

Token adalah credential.

Bearer token berarti:

Whoever possesses the token can use it.

Tidak ada proof tambahan bahwa pemakai token adalah pihak asli yang menerima token, kecuali token dikombinasikan dengan mekanisme lain seperti mTLS, DPoP, atau request signing.

Basic flow:

Mental split:

Token issuer   = system that creates token
Token holder   = client that stores/sends token
Token consumer = API/resource server that validates token

A production architecture must define all three.


2. Token Is Not Always Authentication

OAuth 2.0 access tokens are primarily authorization artifacts for accessing protected resources.

OpenID Connect ID tokens represent authentication event information about an end-user for a relying party.

Internal application tokens may represent service identity, user delegation, or session exchange.

Do not mix them casually.

Bad mental model:

Any token with sub means user is logged in.

Better:

Token semantics come from issuer, audience, token type, protocol, and validation contract.

Examples:

TokenMeaning
Session cookie valuePointer to server-side session.
OAuth access tokenPermission to access resource server.
OIDC ID tokenAuthentication claims for client/RP.
Refresh tokenCredential to obtain new access tokens.
API keyLong-lived client/application secret.
Password reset tokenOne-time recovery authorization.
CSRF tokenRequest integrity/session binding signal.

A token is not self-explanatory.

You must define its contract.


3. Bearer Token Pattern

RFC 6750 defines bearer token usage for OAuth 2.0 protected resources. The dominant HTTP transport is the Authorization header:

Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

Bearer invariant:

Possession is sufficient.

Therefore:

Token leakage equals potential account/service access.

Never treat bearer tokens as harmless metadata.

Common leak paths:

  • logs,
  • browser local storage compromise,
  • query string,
  • Referer header,
  • crash reports,
  • analytics scripts,
  • reverse proxy access logs,
  • misconfigured CORS,
  • mobile app backups,
  • copied curl commands,
  • third-party SDKs,
  • error monitoring payloads.

Recommended transport:

Authorization: Bearer <token>

Avoid:

GET /api/orders?access_token=<token>

Query parameter tokens leak too easily into logs, browser history, caches, proxies, analytics, and Referer.


4. Token Families

Three common token shapes:

TypeDescription
Opaque tokenRandom string. API validates via lookup/introspection.
JWTStructured signed JSON claims token. API can validate locally.
Reference tokenPointer to server-side token record, similar to opaque token.

Opaque Token

YhAr9ff3PrX1z0M9AV0E0lMmb2ZM2s

API cannot infer meaning without calling issuer/introspection/store.

Pros:

  • easy revocation,
  • no claim leakage to client,
  • small,
  • central policy control,
  • good for sensitive systems.

Cons:

  • validation requires network/store lookup,
  • issuer availability matters,
  • latency higher,
  • introspection endpoint must scale.

JWT

base64url(header).base64url(payload).base64url(signature)

API can validate signature and claims locally.

Pros:

  • local validation,
  • scalable resource server,
  • no per-request introspection,
  • clear claim contract,
  • good for distributed APIs.

Cons:

  • revocation is harder,
  • claim leakage risk,
  • key rotation complexity,
  • audience/issuer mistakes are common,
  • stale authorization until expiry.

Reference Token

Reference token is conceptually opaque:

token -> server-side token record

It may be implemented as opaque token with database/Redis lookup.


5. JWT vs Opaque Token Decision Matrix

RequirementJWTOpaque Token
Local validationStrongWeak
Immediate revocationWeak unless denylist/introspectionStrong
Minimize exposed claimsWeak unless encrypted/minimalStrong
Low latency at APIStrongDepends on cache/introspection
Centralized policyWeakerStronger
Multi-service scalabilityStrongRequires introspection scale
Key rotation complexityHigherLower for consumers
Stale authorization riskHigherLower
DebuggabilityHigherLower
Sensitive regulated workflowUse carefully, short-livedOften safer

Rule of thumb:

Use JWT when local validation and distributed scale matter more than immediate revocation.
Use opaque/reference token when central control and revocation matter more than local validation.

But do not make it binary.

Common architecture:

Browser session -> BFF
BFF obtains opaque/session-bound token
Internal APIs receive short-lived JWT
Refresh tokens remain only at trusted backend

6. Resource Server Mental Model

A Java API that validates bearer tokens is often called a resource server.

Its job:

Extract token.
Validate token.
Build authenticated principal.
Apply authorization.
Return correct failure response.

Pipeline:

Important distinction:

401 = authentication missing/invalid
403 = authenticated but not allowed

Do not return 403 for invalid token. That makes clients and diagnostics worse.


7. Token Validation Pipeline

For JWT-like tokens:

1. Extract token from Authorization header.
2. Reject multiple competing credentials unless explicitly supported.
3. Parse header without trusting it.
4. Enforce allowed algorithms.
5. Resolve key safely.
6. Verify signature.
7. Validate issuer.
8. Validate audience.
9. Validate expiry / not-before / issued-at.
10. Validate token type/use.
11. Validate subject format.
12. Validate tenant claim if multi-tenant.
13. Validate scope/roles mapping.
14. Build principal.
15. Clear context after request.

For opaque tokens:

1. Extract token.
2. Call introspection endpoint or local token store.
3. Check active=true.
4. Validate issuer/client/audience if returned.
5. Validate expiry/scope/subject.
6. Build principal.
7. Cache introspection carefully if needed.

Invariant:

A parsed token is not a valid token.

Parsing only means bytes became JSON/claims.

Validation means cryptographic and semantic checks passed.


8. Spring Security Resource Server

Spring Security has OAuth 2.0 Resource Server support for bearer tokens, including JWT and opaque token modes.

JWT configuration sketch:

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
            )
            .build();
    }

    @Bean
    Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(new ScopeToAuthorityConverter());
        return converter;
    }
}

Properties:

spring.security.oauth2.resourceserver.jwt.issuer-uri=https://idp.example.com/realms/acme

Opaque token configuration sketch:

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2
            .opaqueToken(Customizer.withDefaults())
        )
        .build();
}

Properties:

spring.security.oauth2.resourceserver.opaque-token.introspection-uri=https://idp.example.com/oauth2/introspect
spring.security.oauth2.resourceserver.opaque-token.client-id=resource-server
spring.security.oauth2.resourceserver.opaque-token.client-secret=${INTROSPECTION_SECRET}

Important:

Resource server validates tokens. It should not authenticate users by password.

Password login belongs to an auth server/login service/client flow, not every API.


9. JAX-RS / Jakarta Filter Implementation

For non-Spring JAX-RS service, use request filter:

@Provider
@Priority(Priorities.AUTHENTICATION)
public class BearerTokenAuthenticationFilter implements ContainerRequestFilter {

    private final TokenVerifier tokenVerifier;

    public BearerTokenAuthenticationFilter(TokenVerifier tokenVerifier) {
        this.tokenVerifier = tokenVerifier;
    }

    @Override
    public void filter(ContainerRequestContext requestContext) {
        String authorization = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);

        Optional<String> token = extractBearerToken(authorization);
        if (token.isEmpty()) {
            return; // anonymous; authorization layer may reject later
        }

        TokenPrincipal principal;
        try {
            principal = tokenVerifier.verify(token.get());
        } catch (InvalidTokenException ex) {
            requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED)
                .header(HttpHeaders.WWW_AUTHENTICATE, "Bearer error=\"invalid_token\"")
                .build());
            return;
        }

        SecurityContext original = requestContext.getSecurityContext();
        requestContext.setSecurityContext(new TokenSecurityContext(original, principal));
    }

    private Optional<String> extractBearerToken(String authorization) {
        if (authorization == null) return Optional.empty();
        if (!authorization.regionMatches(true, 0, "Bearer ", 0, 7)) return Optional.empty();
        String value = authorization.substring(7).trim();
        if (value.isBlank()) return Optional.empty();
        return Optional.of(value);
    }
}

SecurityContext wrapper:

public final class TokenSecurityContext implements SecurityContext {
    private final SecurityContext delegate;
    private final TokenPrincipal principal;

    public TokenSecurityContext(SecurityContext delegate, TokenPrincipal principal) {
        this.delegate = delegate;
        this.principal = principal;
    }

    @Override
    public Principal getUserPrincipal() {
        return principal;
    }

    @Override
    public boolean isUserInRole(String role) {
        return principal.roles().contains(role);
    }

    @Override
    public boolean isSecure() {
        return delegate != null && delegate.isSecure();
    }

    @Override
    public String getAuthenticationScheme() {
        return "Bearer";
    }
}

Avoid building your own cryptography. Use mature JOSE/JWT libraries or framework integration.


10. Claim Design

A token claim is part of an API contract.

Common JWT registered claims:

ClaimMeaning
issIssuer. Who issued the token.
subSubject. Who/what token is about.
audAudience. Intended recipient/resource.
expExpiration time.
nbfNot valid before.
iatIssued at.
jtiToken identifier.

Common custom claims:

ClaimUse
scopeOAuth-style permissions.
rolesApplication roles, if appropriate.
tenant_idActive tenant context.
azpAuthorized party/client id in some profiles.
client_idClient identity.
amrAuthentication methods used.
acrAuthentication context/assurance class.
sidSession id reference, common in OIDC logout/session contexts.

Minimal example payload:

{
  "iss": "https://idp.example.com/realms/acme",
  "sub": "user_01HZX...",
  "aud": "orders-api",
  "exp": 1783051200,
  "iat": 1783050600,
  "jti": "tok_01J...",
  "scope": "orders:read orders:write",
  "tenant_id": "tenant_acme",
  "amr": ["pwd", "totp"],
  "acr": "aal2"
}

Claim design rules:

Do not put secrets in tokens.
Do not put large permission graphs in tokens.
Do not put mutable business data in long-lived tokens.
Do not put PII unless required and accepted by privacy/security review.
Do include issuer, audience, expiry, subject, and token type/use.
Do define tenant semantics explicitly.

11. Token Type Confusion

A common failure:

API accepts ID token as access token.

Why dangerous:

  • ID token audience may be client app, not API,
  • ID token may not contain API scopes,
  • validation rules differ,
  • attacker may replay token intended for another component.

Token type invariant:

Each consumer must accept only the token type intended for that consumer.

Practical defenses:

  • validate aud,
  • validate iss,
  • require token type claim when available,
  • use separate signing keys or issuers for different token classes if necessary,
  • configure resource server with issuer and audience,
  • reject tokens missing expected API scope.

12. Audience Confusion

Audience confusion occurs when one service accepts token intended for another service.

Example:

Token aud = profile-api
Request sent to payment-api
payment-api only validates signature and exp
payment-api accepts token

This is broken.

Correct:

payment-api must require aud contains payment-api

Spring custom audience validator sketch:

@Bean
JwtDecoder jwtDecoder() {
    NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation("https://idp.example.com/realms/acme");

    OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(
        "https://idp.example.com/realms/acme"
    );
    OAuth2TokenValidator<Jwt> audience = jwt ->
        jwt.getAudience().contains("orders-api")
            ? OAuth2TokenValidatorResult.success()
            : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Invalid audience", null));

    decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audience));
    return decoder;
}

13. Scope and Authority Mapping

OAuth scope is not the same as application role.

scope = delegated permission granted to client/token
role  = application/domain grouping assigned to subject

Spring Security convention often maps scopes to authorities like:

SCOPE_orders:read
SCOPE_orders:write

Example mapping:

final class ScopeToAuthorityConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        String scope = jwt.getClaimAsString("scope");
        if (scope == null || scope.isBlank()) return List.of();

        return Arrays.stream(scope.split(" "))
            .filter(s -> !s.isBlank())
            .map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
            .toList();
    }
}

Endpoint:

@PreAuthorize("hasAuthority('SCOPE_orders:read')")
@GetMapping("/api/orders/{id}")
OrderDto getOrder(@PathVariable String id) {
    ...
}

Be careful with role claims from external IdPs.

Do not blindly trust arbitrary roles claim unless:

  • issuer is trusted,
  • mapping is explicit,
  • tenant boundary is checked,
  • role namespace is controlled,
  • token audience is correct.

14. Token Lifetime

Access token should usually be short-lived.

Why:

Bearer tokens are hard to revoke once issued, especially JWTs.

Typical patterns:

TokenLifetime
Access token for browser/BFF5–15 minutes
Internal service token5–30 minutes
Mobile access token5–15 minutes
Refresh tokenLonger, rotated, high protection
Password reset tokenVery short, one-time
Email verification tokenShort to moderate, one-time

Do not set long-lived JWT access tokens just to avoid refresh flow complexity.

That moves complexity into incident response.

Rule:

The harder a token is to revoke, the shorter it should live.

15. Refresh Token Boundary

Refresh token is not just a longer access token.

It is a powerful credential used to mint new access tokens.

Therefore:

Refresh tokens need stronger storage and rotation than access tokens.

Common policy:

  • never expose refresh token to untrusted JavaScript when avoidable,
  • rotate refresh token on each use,
  • detect reuse,
  • bind refresh token to client/session/device where possible,
  • store only hash of refresh token server-side,
  • revoke token family on reuse.

This series will detail refresh token rotation in Part 018.

For now, the key boundary:

Access token goes to resource server.
Refresh token stays at token endpoint/client boundary.

Do not send refresh tokens to every API.


16. Token Storage on Client

Client storage depends on client type.

ClientCommon storage
Backend servermemory/secret store/session store.
Browser SPAdifficult; avoid long-lived tokens in localStorage.
Browser + BFFtoken stored server-side, browser gets session cookie.
Mobile appsecure enclave/keystore/keychain where possible.
CLIOS credential store or protected file.
Machine servicesecret manager/workload identity.

Browser warning:

localStorage is readable by JavaScript. XSS can steal tokens.

Cookie warning:

Cookies are automatically sent. CSRF matters unless SameSite/CSRF defenses are correct.

BFF pattern often works well for browser apps:

Browser has secure session cookie to BFF.
BFF stores/obtains access token server-side.
APIs are called by BFF, not directly by browser.

Diagram:


17. Token Revocation

Revocation difficulty depends on token type.

Opaque token:

Delete/mark inactive in token store. Introspection returns active=false.

JWT:

Already issued token remains valid until exp unless resource servers check denylist/introspection/version.

JWT revocation options:

  1. Short expiry.
  2. Denylist by jti until expiry.
  3. Subject/session version check.
  4. Token introspection despite JWT format.
  5. Back-channel revocation events to resource servers.
  6. Key rotation as emergency broad invalidation.

Each has trade-off.

Denylist example:

revoked:jti:{jti} -> true, TTL = token_exp - now

Resource server check:

if (revokedTokenStore.isRevoked(jwt.getId())) {
    throw new BadCredentialsException("Token revoked");
}

But this reintroduces server-side lookup.

That is not wrong.

It means you chose revocation over pure statelessness.


18. Token Authentication in Microservices

Common patterns:

Edge Validation Only

Gateway validates token.
Internal services trust gateway headers.

Risk:

  • header spoofing if network boundary weak,
  • internal service exposed accidentally,
  • hard to enforce service-specific audience.

Every Service Validates Token

Each resource server validates bearer token.

Pros:

  • stronger zero-trust posture,
  • service-specific authorization,
  • less gateway magic.

Cons:

  • duplicated config,
  • key fetching/caching across services,
  • consistent claim mapping needed.

Token Exchange

External user token exchanged for internal service-specific token.

Pros:

  • least privilege per service,
  • better audience boundaries,
  • reduced token overexposure.

Cons:

  • more infrastructure,
  • more moving parts,
  • debugging complexity.

This series will go deeper in Part 032.


19. Logging and Redaction

Bearer tokens must be treated like passwords.

Never log:

Authorization header
access_token
refresh_token
id_token
reset token
verification token

Redaction examples:

public String redactAuthorization(String value) {
    if (value == null) return null;
    if (value.regionMatches(true, 0, "Bearer ", 0, 7)) {
        return "Bearer <redacted>";
    }
    return "<redacted>";
}

Safe fingerprint:

public String tokenFingerprint(String token) {
    byte[] mac = hmacSha256(logFingerprintKey, token.getBytes(StandardCharsets.US_ASCII));
    return HexFormat.of().formatHex(mac).substring(0, 16);
}

Use fingerprint for correlation:

token_fingerprint=9ac3e8d2f01ab443

Not raw token.


20. Error Semantics

For invalid/missing bearer token, use 401 Unauthorized with WWW-Authenticate header.

Examples:

Missing token on protected resource:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="orders-api"

Invalid token:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token"

Insufficient scope:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:read"

Do not leak excessive details:

invalid_token because signature key kid=... not found and issuer=... wrong

Detailed reason belongs in internal logs, not user-facing response.


21. Token Cache

Resource servers often cache:

  • JWK signing keys,
  • opaque token introspection result,
  • revoked token denylist lookups,
  • account/tenant version.

Caching rules:

Cache must not outlive token expiry.
Cache must respect revocation requirements.
Cache key must include issuer/token identity.
Cache failure must not fail open.

Opaque introspection cache example:

cache key = SHA-256(token)
cache ttl = min(exp - now, 60 seconds)

Do not cache active introspection for 30 minutes if token expires in 5 minutes.

Do not cache negative introspection too aggressively during clock skew or issuer outages.


22. Multi-Tenant Token Validation

Multi-tenant auth introduces tenant confusion.

Bad:

Token has sub=user123 and tenant_id=acme.
API path is /tenants/other/orders.
API only checks sub.

Correct:

Token tenant context must match requested tenant context unless explicit cross-tenant permission exists.

Validation layers:

Issuer tenant realm / issuer URL
Audience API
Subject
Tenant claim
Membership/role
Requested resource tenant

Example:

void assertTenantAccess(TokenPrincipal principal, UUID requestedTenantId) {
    if (!principal.tenantId().equals(requestedTenantId)
            && !principal.authorities().contains("GLOBAL_SUPPORT")) {
        throw new AccessDeniedException("Tenant mismatch");
    }
}

Do not rely on frontend-selected tenant alone.


23. Service-to-Service Token Authentication

For machine-to-machine calls, token subject may be a client/service, not a user.

Example claims:

{
  "iss": "https://idp.example.com",
  "sub": "service:billing-worker",
  "aud": "invoice-api",
  "exp": 1783051200,
  "scope": "invoice:generate",
  "client_id": "billing-worker"
}

Resource server must distinguish:

user principal vs service principal

Java model:

sealed interface AuthenticatedActor permits UserActor, ServiceActor {
    String subject();
}

record UserActor(String subject, UUID accountId, UUID tenantId) implements AuthenticatedActor {}
record ServiceActor(String subject, String clientId) implements AuthenticatedActor {}

Do not pretend service token is a user.

Audit records should preserve actor type:

actor_type=SERVICE actor_id=billing-worker on_behalf_of=user123? maybe

If a service acts on behalf of a user, model delegation explicitly.


24. Token Authentication Anti-Patterns

Anti-pattern: JWT as Session Replacement Without Logout Design

Issue 24-hour JWT and store in localStorage.

Consequence:

  • XSS steals token,
  • logout cannot revoke token,
  • role changes stale for 24 hours,
  • incident response weak.

Anti-pattern: Accept Token Without Audience Check

Signature valid, therefore accepted.

Consequence:

  • token substitution across APIs.

Anti-pattern: Trust alg Dynamically

Use whatever algorithm token header says.

Consequence:

  • algorithm confusion class of bugs.

Anti-pattern: Put Secrets in JWT

{
  "sub": "user123",
  "passwordHash": "...",
  "apiSecret": "..."
}

Consequence:

  • token holder can decode payload if token is only signed, not encrypted.

Anti-pattern: Use ID Token to Call APIs

Frontend sends ID token to backend API as access token.

Consequence:

  • wrong audience and wrong semantics.

Anti-pattern: Token in URL

/download?token=...

Consequence:

  • leaks through logs/history/referrers.

25. Production Checklist

[ ] Token issuer is explicitly defined.
[ ] Token consumer/resource server is explicitly defined.
[ ] Token type is explicit: access token, ID token, refresh token, internal token.
[ ] Resource server validates issuer.
[ ] Resource server validates audience.
[ ] Resource server validates expiry.
[ ] Resource server validates not-before when used.
[ ] Resource server validates signature/introspection result.
[ ] Resource server rejects unsupported algorithms.
[ ] Resource server rejects wrong token type.
[ ] Scope/role mapping is explicit.
[ ] Tenant boundary is validated.
[ ] Token lifetime is short enough for revocation needs.
[ ] Refresh token does not go to resource servers.
[ ] Tokens are not stored in logs.
[ ] Tokens are not sent in query strings.
[ ] Caches do not outlive token validity.
[ ] Opaque token introspection fails closed.
[ ] JWT key resolution is safe and bounded.
[ ] JWK cache and rotation are tested.
[ ] Revocation strategy is documented.
[ ] Incident response can invalidate affected tokens.

26. Testing Strategy

Validation tests:

  • missing token -> 401 for protected endpoint,
  • malformed token -> 401,
  • expired token -> 401,
  • wrong issuer -> 401,
  • wrong audience -> 401,
  • invalid signature -> 401,
  • missing required scope -> 403,
  • valid token with required scope -> 200,
  • ID token rejected as access token,
  • token for tenant A rejected for tenant B resource.

Security regression tests:

Token in query string is rejected.
Multiple Authorization headers are rejected or handled deterministically.
Authorization header is redacted from logs.
Unsupported alg is rejected.
Unknown kid does not trigger arbitrary URL fetch.
Introspection timeout fails closed.

Performance tests:

  • JWT validation throughput,
  • JWK cache miss latency,
  • opaque introspection p95/p99,
  • token cache hit ratio,
  • auth filter overhead,
  • effect of IdP outage on APIs.

Operational drills:

Rotate signing key.
Revoke one token.
Disable one account.
Disable one tenant.
Expire JWK cache.
Make introspection endpoint slow.
Leak one test token and trace detection.

27. Minimal TokenVerifier Abstraction

A clean codebase isolates token verification.

public interface TokenVerifier {
    TokenPrincipal verify(String token) throws InvalidTokenException;
}

public record TokenPrincipal(
    String issuer,
    String subject,
    String audience,
    UUID tenantId,
    Set<String> scopes,
    Set<String> roles,
    Instant issuedAt,
    Instant expiresAt,
    String tokenId,
    ActorType actorType
) implements Principal {
    @Override
    public String getName() {
        return subject;
    }
}

enum ActorType {
    USER,
    SERVICE,
    SYSTEM
}

Do not scatter raw claim parsing across controllers.

Wrong:

@GetMapping("/orders")
public List<Order> orders(@RequestHeader("Authorization") String auth) {
    String tenant = parseJwt(auth).get("tenant_id");
    ...
}

Better:

@GetMapping("/orders")
public List<Order> orders(Authentication authentication) {
    TokenPrincipal principal = (TokenPrincipal) authentication.getPrincipal();
    ...
}

28. API Gateway and Token Propagation

Gateway can validate external tokens, but internal services still need a trust model.

Options:

1. Gateway forwards original token.
2. Gateway forwards signed internal identity header.
3. Gateway exchanges external token for internal token.
4. Gateway terminates user auth and calls BFF/backend with service identity + user context.

Dangerous pattern:

Gateway forwards X-User-Id header and services trust it from anywhere.

If using headers:

  • strip incoming identity headers at edge,
  • add headers only after validation,
  • protect internal network,
  • use mTLS between gateway and services,
  • sign headers or use internal tokens,
  • ensure service cannot be reached directly from public internet.

Better for complex systems:

Use service-specific internal JWT or token exchange with correct audience.

29. Token Authentication and CSRF

Bearer token in Authorization header is not automatically sent by browser.

Therefore classic CSRF is less direct than cookie session auth.

But if token is stored in cookie and automatically sent:

CSRF is relevant again.

If browser JavaScript reads token and sends Authorization header:

XSS becomes primary token theft concern.

Trade-off:

Storage/TransportMain risk
HttpOnly cookieCSRF/browser cookie semantics.
localStorage + Authorization headerXSS token theft.
BFF server-side tokenBFF/session complexity, but better browser exposure boundary.

Do not say:

JWT removes CSRF.

More precise:

CSRF depends on whether credentials are automatically attached by the browser.

30. Token Observability

Metrics:

MetricMeaning
auth.token.validation.countToken validation attempts.
auth.token.validation.failure.countInvalid/expired/wrong audience etc.
auth.token.validation.latencyValidation latency.
auth.token.introspection.latencyOpaque token introspection latency.
auth.token.jwk.refresh.countJWK refresh events.
auth.token.jwk.failure.countJWK retrieval/parse failures.
auth.token.revoked.countRevoked token seen.
auth.token.audience_mismatch.countPossible token substitution attempts.
auth.token.issuer_mismatch.countMisrouting or attack signal.

Safe log fields:

issuer
audience
subject_hash
tenant_id
token_fingerprint
jti_hash
client_id
scope_count
validation_error_code

Unsafe log fields:

raw token
Authorization header
full PII claims
refresh token

31. Decision Guide

Use session authentication when:

  • browser app is first-party,
  • server controls state,
  • logout/revocation matters,
  • CSRF can be managed,
  • BFF pattern is acceptable.

Use token authentication when:

  • APIs are consumed by many clients,
  • service-to-service calls need identity,
  • OAuth/OIDC ecosystem is used,
  • distributed resource servers need local validation,
  • mobile/CLI/machine clients are involved.

Use opaque token when:

  • immediate revocation matters,
  • central introspection is acceptable,
  • token claims should not be visible,
  • resource server count is manageable.

Use JWT when:

  • high-scale local validation matters,
  • token lifetime is short,
  • issuer/audience/key rotation are well-managed,
  • stale authorization risk is acceptable or mitigated.

Use BFF when:

  • browser frontend needs API access,
  • you want to avoid exposing long-lived tokens to JavaScript,
  • backend can own token storage/refresh.

32. Exercises

Exercise 1 — JWT or Opaque?

For each scenario, choose token type and justify:

1. Public SPA calling internal APIs.
2. Mobile app calling user APIs.
3. Backend service calling billing API.
4. Admin console for regulated case management.
5. Partner API with revocation requirement.
6. Event ingestion service with high throughput.

Explain:

  • issuer,
  • audience,
  • lifetime,
  • revocation strategy,
  • storage,
  • validation path.

Exercise 2 — Audience Test

Write integration test:

Given token aud=profile-api
When calling orders-api
Then response is 401 invalid_token

Exercise 3 — Log Redaction Test

Test that request logs never contain:

Authorization: Bearer
access_token=
id_token=
refresh_token=

Exercise 4 — Token Expiry Drill

Simulate:

1. Issue access token expiring in 60 seconds.
2. Call API successfully.
3. Advance clock past exp.
4. Call API again.
5. Verify 401 and correct WWW-Authenticate semantics.

Closing Mental Model

Token authentication is not “stateless login”.

It is a distributed credential validation pattern.

A strong implementation defines:

issuer
holder
consumer
token type
audience
lifetime
validation rules
revocation story
storage boundary
leakage controls
observability

JWT is only one token format.

Opaque token is only one validation strategy.

Bearer token is only one proof model.

Once these are separated, architecture decisions become clearer and safer.

Part 017 will go deep into JWT production-grade usage: claims, JWS/JWE, algorithm allowlisting, kid, JWK rotation, issuer/audience validation, tenant boundaries, and failure modes.

Lesson Recap

You just completed lesson 16 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.