Build CoreOrdered learning track

JWT Production-Grade Usage

Learn Java Authentication Pattern - Part 017

JWT Production-Grade Usage untuk Java authentication: JWS, JWE, claims, issuer, audience, JWK, key rotation, algorithm allowlist, token confusion, Spring Security JwtDecoder, JAX-RS validation, dan failure modes.

9 min read1681 words
PrevNext
Lesson 1740 lesson track09–22 Build Core
#java#authentication#jwt#jose+6 more

Part 017 — JWT Production-Grade Usage

Target part ini: memahami JWT sebagai format token yang harus divalidasi dengan kontrak ketat, bukan sebagai “base64 JSON yang ditandatangani”. Kita akan membahas JWS/JWE, claims, signature, JWK, key rotation, kid, algorithm allowlist, issuer, audience, token confusion, claim mapping, Spring Security, JAX-RS, observability, dan testing.

JWT sering dipilih karena terasa sederhana.

Header.Payload.Signature

Tetapi kesederhanaan bentuknya sering menipu.

JWT bukan authentication system. JWT bukan authorization system. JWT bukan session system. JWT hanyalah format token.

Yang membuat JWT aman bukan karena ia bernama JWT, tetapi karena seluruh validation contract-nya benar:

issuer benar
signature benar
algorithm benar
audience benar
time claims benar
token type benar
key benar
subject semantics benar
claims tidak disalahartikan
boundary tidak tertukar

RFC 8725 adalah dokumen Best Current Practices untuk JWT dan secara eksplisit memperingatkan implementer terhadap masalah seperti weak signatures, weak symmetric keys, incorrect composition, plaintext leakage, substitution attacks, dan cross-JWT confusion.


1. Mental Model: JWT Is a Signed Statement

JWT adalah statement terstruktur dari issuer.

Contoh mental model:

Issuer says:
  subject user:123
  may call audience billing-api
  until 2026-07-03T10:15:00Z
  with scope invoice:read
  under key kid=2026-07-rsa-01

Resource server tidak “login ulang” user. Resource server hanya memverifikasi statement tersebut.

Invariant:

A JWT is accepted only if the verifier can prove that the trusted issuer signed exactly the token being used for exactly this API boundary and exactly this token purpose.

2. JWT Parts: Header, Claims, Signature

A compact signed JWT usually looks like this:

eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDctcnNhLTAxIiwidHlwIjoiYXQrand0In0
.
eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsInN1YiI6InVzZXI6MTIzIiwiYXVkIjoiYmlsbGluZy1hcGkiLCJleHAiOjE3ODMwNjA5MDAsInNjb3BlIjoiaW52b2ljZTpyZWFkIn0
.
signature

Split:

Header     = cryptographic metadata
Payload    = claims / statement body
Signature  = integrity proof

The header is not trusted before verification. The payload is not trusted before verification. The kid is not trusted before verification.

But the verifier needs the header to choose how to verify. That is where many JWT bugs start.


3. JWS vs JWE

JWT can be:

FormMeaningCommon Usage
JWSsigned tokenAccess token, ID token
JWEencrypted tokenConfidential claims between parties
Nested JWTsigned then encrypted or encrypted then signedSpecialized protocols

Most application teams use JWS.

Important distinction:

JWS gives integrity, not confidentiality.

If you put sensitive data inside a signed JWT, anyone holding the token can decode the payload.

Bad JWT payload:

{
  "sub": "user:123",
  "email": "alice@example.com",
  "national_id": "...",
  "salary": "...",
  "permissions": ["..."]
}

Better:

{
  "iss": "https://idp.example.com",
  "sub": "user:123",
  "aud": "billing-api",
  "scope": "invoice:read",
  "exp": 1783060900,
  "iat": 1783060600,
  "jti": "tok_01J..."
}

Keep JWT claims minimal.


4. Production Rule: Do Not Trust Decoded JWT

A common debugging habit becomes a production bug:

String[] parts = token.split("\\.");
String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
JsonNode claims = objectMapper.readTree(payload);
String userId = claims.get("sub").asText();

This code extracts identity from an unverified token.

Correct pipeline:

extract token
parse enough header for verification plan
choose trusted key by trusted key source + allowed kid
verify signature
validate registered claims
validate private claims
map principal/authorities
set security context

Never run authorization decisions from an unverified decoded payload.


5. Registered Claims That Matter

JWT has registered claim names. Production systems usually care about these:

ClaimMeaningProduction Rule
ississuerMust match exact trusted issuer
subsubjectMust be stable, non-reassignable, domain-scoped
audaudienceMust include this API, not just any API
expexpiryMust be required and enforced
nbfnot beforeEnforce with small clock skew
iatissued atUseful for max token age and diagnostics
jtitoken idUseful for replay detection/revocation/audit

Production invariant:

No accepted access token without iss, sub, aud, exp.

For OAuth-style APIs, also validate token purpose/type.

Common forms:

{
  "typ": "at+jwt"
}

or:

{
  "token_use": "access"
}

or issuer-specific claim:

{
  "azp": "frontend-client"
}

Do not assume an ID token is an access token. Do not assume an access token is an ID token.


6. Claim Semantics: sub Is Not Always a User

sub means subject.

It may represent:

human user
service account
device
batch process
external user
federated identity
anonymous-but-bound session

Bad code:

UUID userId = UUID.fromString(jwt.getSubject());
User user = userRepository.findById(userId).orElseThrow();

Better:

AuthenticatedSubject subject = subjectMapper.map(jwt);

switch (subject.kind()) {
    case HUMAN_USER -> handleUser(subject);
    case SERVICE_ACCOUNT -> handleService(subject);
    case DEVICE -> handleDevice(subject);
}

A robust auth model treats subject kind as explicit.

Example claim design:

{
  "iss": "https://idp.example.com",
  "sub": "user:01J2...",
  "sub_type": "human_user",
  "tenant_id": "tenant:acme",
  "aud": "case-management-api",
  "scope": "case:read case:update",
  "exp": 1783060900
}

Do not reuse bare integer IDs across tenants.


7. Audience Validation Is Not Optional

Audience prevents token substitution across APIs.

Bad architecture:

All APIs accept any token from the issuer.

Failure:

A token issued for profile-api is replayed against payment-api.

Better:

billing-api accepts aud=billing-api only
case-api accepts aud=case-api only
admin-api accepts aud=admin-api only

For composite systems, prefer explicit audience list:

{
  "aud": ["case-api", "document-api"]
}

Avoid broad audience:

{
  "aud": "all-services"
}

That turns the token into a platform-wide bearer credential.


8. Issuer Validation Must Be Exact

Issuer validation must not be fuzzy.

Bad:

iss startsWith https://idp.example.com
iss contains example.com
iss is configured by request tenant parameter

Better:

iss == https://idp.example.com/realms/acme

Multi-tenant issuer validation requires tenant resolution before validation.

Never let the token alone choose its own trusted issuer.

Bad pattern:

read iss from token
fetch discovery document from that iss
trust returned keys

That is issuer confusion.


9. Algorithm Allowlist

The alg header is attacker-controlled input until verified.

Production rule:

The verifier chooses allowed algorithms from configuration, not from token preference.

Bad:

Algorithm algorithm = Algorithm.valueOf(header.alg());
verify(token, algorithm);

Better:

Allowed algorithms for this issuer:
  RS256 only
  or ES256 only
  or PS256 only

Do not allow none. Do not allow algorithm family switching unless intentionally configured. Do not accept HS256 and RS256 for the same issuer unless you deeply understand the key confusion risks.

Safer default for many enterprise systems:

Issuer uses asymmetric signing.
Resource servers hold only public keys.

This reduces blast radius if one API is compromised.


10. Key Management: kid Is a Selector, Not Authority

kid helps select a key. It does not make the key trusted.

Bad mental model:

JWT says kid=abc, so fetch/use key abc.

Better:

JWT says kid=abc.
Verifier searches only trusted JWK set for issuer X.
If exactly one allowed key matches kid and alg/use constraints, verify.

JWK validation checklist:

JWK Set URI is configured, not token-controlled.
Key belongs to expected issuer.
Key algorithm/use is compatible.
Key id matches token kid.
Key material has sufficient strength.
Cache honors safe TTL.
Unknown kid triggers bounded refresh.

Avoid token header parameters that point to remote keys unless your JOSE library is explicitly configured to reject or safely handle them:

jku
x5u
x5c
kid path traversal tricks

For most business APIs:

Ignore remote key hints in token header.
Use configured JWK Set URI only.

11. Key Rotation Model

JWT key rotation requires overlap.

Rules:

publish new public key before signing with it
keep old public key until all old tokens expire
rotate private signing key under controlled runbook
monitor unknown kid errors
never delete old key immediately after rotation

Emergency key compromise is different:

stop signing with compromised key
remove public key if accepting tokens is worse than breaking sessions
revoke grants/sessions as needed
force re-authentication
notify dependent services

Key rotation is not only cryptography. It is distributed systems operations.


12. Spring Security JWT Validation

Spring Security Resource Server validates JWTs through JwtDecoder and maps successful validation to JwtAuthenticationToken in the SecurityContext.

Minimal resource server configuration:

@Configuration
@EnableWebSecurity
class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/invoices/**").hasAuthority("SCOPE_invoice:read")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            )
            .build();
    }
}

Configuration:

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

With issuer metadata, Spring can discover the JWK Set URI and validate issuer-related metadata depending on configuration and provider behavior.

Production systems should still make validation expectations explicit.


13. Explicit Audience Validator in Spring

Spring validates standard time claims and issuer when configured appropriately, but audience validation is often application-specific.

Example:

@Configuration
class JwtValidationConfig {

    @Bean
    JwtDecoder jwtDecoder(
            @Value("${security.jwt.issuer}") String issuer,
            @Value("${security.jwt.jwk-set-uri}") String jwkSetUri,
            @Value("${security.jwt.audience}") String audience
    ) {
        NimbusJwtDecoder decoder = NimbusJwtDecoder
            .withJwkSetUri(jwkSetUri)
            .jwsAlgorithm(SignatureAlgorithm.RS256)
            .build();

        OAuth2TokenValidator<Jwt> issuerValidator = JwtValidators.createDefaultWithIssuer(issuer);
        OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(audience);

        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            issuerValidator,
            audienceValidator,
            new TokenTypeValidator("at+jwt")
        ));

        return decoder;
    }
}

Audience validator:

final class AudienceValidator implements OAuth2TokenValidator<Jwt> {
    private final String requiredAudience;

    AudienceValidator(String requiredAudience) {
        this.requiredAudience = Objects.requireNonNull(requiredAudience);
    }

    @Override
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        if (jwt.getAudience().contains(requiredAudience)) {
            return OAuth2TokenValidatorResult.success();
        }

        OAuth2Error error = new OAuth2Error(
            "invalid_token",
            "Token audience does not include required resource server audience",
            null
        );
        return OAuth2TokenValidatorResult.failure(error);
    }
}

Token type validator:

final class TokenTypeValidator implements OAuth2TokenValidator<Jwt> {
    private final String requiredType;

    TokenTypeValidator(String requiredType) {
        this.requiredType = requiredType;
    }

    @Override
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        String typ = jwt.getHeaders().get("typ") instanceof String value ? value : null;

        if (requiredType.equals(typ)) {
            return OAuth2TokenValidatorResult.success();
        }

        return OAuth2TokenValidatorResult.failure(new OAuth2Error(
            "invalid_token",
            "Token type is not accepted by this resource server",
            null
        ));
    }
}

Do not leak details to clients. Internal logs can contain structured reason codes. HTTP response should remain generic.


14. Mapping JWT Claims to Authorities

Spring Security commonly maps OAuth scopes to authorities like:

scope invoice:read -> SCOPE_invoice:read

Example custom converter:

@Configuration
class JwtAuthorityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(new DomainJwtAuthorityConverter());

        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.GET, "/api/invoices/**")
                    .hasAuthority("SCOPE_invoice:read")
                .requestMatchers(HttpMethod.POST, "/api/invoices/**")
                    .hasAuthority("SCOPE_invoice:write")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(converter))
            )
            .build();
    }
}

Converter:

final class DomainJwtAuthorityConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

    @Override
    public Collection<GrantedAuthority> convert(Jwt jwt) {
        Set<GrantedAuthority> authorities = new LinkedHashSet<>();

        String scope = jwt.getClaimAsString("scope");
        if (scope != null) {
            Arrays.stream(scope.split(" "))
                .filter(s -> !s.isBlank())
                .map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
                .forEach(authorities::add);
        }

        List<String> permissions = jwt.getClaimAsStringList("permissions");
        if (permissions != null) {
            permissions.stream()
                .filter(p -> p.startsWith("perm:"))
                .map(SimpleGrantedAuthority::new)
                .forEach(authorities::add);
        }

        return List.copyOf(authorities);
    }
}

Production warning:

Mapping is authorization policy.
Do not treat arbitrary token arrays as trusted roles without issuer-specific contract.

15. Do Not Put Mutable Authorization Truth in Long-Lived JWTs

JWT is self-contained. That is both useful and dangerous.

If a user loses a role at 10:00 but their token expires at 11:00, a resource server that only trusts JWT claims may keep accepting old authorization until expiry.

Options:

StrategyEffectTrade-off
Short access token lifetimeLimits stale privilege windowMore refresh traffic
Opaque token/introspectionReal-time stateNetwork dependency
Permission version claimAllows local stale-checkNeeds shared version store
Session/token revocation listEmergency cutoffAdds state to stateless model
Fine-grained authorization lookupAlways currentLatency and coupling

Claim design example:

{
  "sub": "user:123",
  "tenant_id": "tenant:acme",
  "authz_version": 42,
  "scope": "case:read"
}

Resource server may compare authz_version with cached account/tenant version. If token version is stale, reject or re-introspect.


16. ID Token vs Access Token

OpenID Connect ID tokens are for the client/relying party. OAuth access tokens are for resource servers.

Bad:

Frontend sends ID token to API.
API accepts it as bearer token.

Why bad:

ID token audience is usually client_id, not API.
Claims semantics differ.
Token type differs.
Nonce semantics belong to OIDC login flow.

Better:

Frontend obtains access token for API audience.
API accepts access token only.
API rejects ID token.

Validation rule:

audience must be the API
not the frontend client id
not any trusted client

17. Cross-JWT Confusion

Cross-JWT confusion happens when one kind of JWT is accepted where another kind is expected.

Examples:

ID token accepted as access token
logout token accepted as access token
authorization response JWT accepted as API token
internal service token accepted as user token
tenant A token accepted in tenant B context

Defense:

use explicit typ where protocol supports it
validate issuer
audience
token purpose
required claims
prohibited claims
subject kind
tenant binding

Resource server validation should be profile-specific.

Do not write one generic “JWTValidator” that accepts all tokens from all issuers for all purposes.


18. Token Confusion Across Tenants

Multi-tenant JWT systems fail when token tenant and request tenant diverge.

Example:

GET /tenants/beta/cases/123
Authorization: Bearer <token with tenant_id=acme>

Validation must include cross-check:

resolved request tenant == token tenant claim == issuer/realm tenant

Example Spring filter after JWT authentication:

@Component
final class TenantBindingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain
    ) throws ServletException, IOException {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication instanceof JwtAuthenticationToken jwtAuth) {
            String tenantFromPath = extractTenantFromPath(request);
            String tenantFromToken = jwtAuth.getToken().getClaimAsString("tenant_id");

            if (!Objects.equals(tenantFromPath, tenantFromToken)) {
                response.sendError(HttpServletResponse.SC_FORBIDDEN);
                return;
            }
        }

        chain.doFilter(request, response);
    }
}

This is not authentication alone. It is authentication boundary binding.


19. JAX-RS JWT Validation Filter

In non-Spring JAX-RS systems, use a request filter.

Skeleton:

@Provider
@Priority(Priorities.AUTHENTICATION)
public final class JwtAuthenticationFilter implements ContainerRequestFilter {
    private final JwtVerifier verifier;

    public JwtAuthenticationFilter(JwtVerifier verifier) {
        this.verifier = verifier;
    }

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

        if (token == null) {
            requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
            return;
        }

        VerifiedJwt verified = verifier.verify(token);

        SecurityContext original = requestContext.getSecurityContext();
        requestContext.setSecurityContext(new JwtSecurityContext(verified, original.isSecure()));
    }

    private static String extractBearerToken(String header) {
        if (header == null) return null;
        if (!header.startsWith("Bearer ")) return null;
        return header.substring("Bearer ".length()).trim();
    }
}

Custom SecurityContext:

public final class JwtSecurityContext implements SecurityContext {
    private final VerifiedJwt jwt;
    private final boolean secure;

    public JwtSecurityContext(VerifiedJwt jwt, boolean secure) {
        this.jwt = jwt;
        this.secure = secure;
    }

    @Override
    public Principal getUserPrincipal() {
        return () -> jwt.subject();
    }

    @Override
    public boolean isUserInRole(String role) {
        return jwt.authorities().contains(role);
    }

    @Override
    public boolean isSecure() {
        return secure;
    }

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

But do not implement JOSE cryptography manually. Use mature libraries. Your custom code should orchestrate validation policy, not parse ASN.1 or verify signatures from scratch.


20. JWK Cache Design

JWK fetching is an operational dependency.

Failure modes:

IdP unavailable at API startup
JWK endpoint slow
unknown kid flood
key rotation not propagated
stale cache after emergency key revocation
cache stampede across pods

Design rules:

cache JWK set with bounded TTL
refresh on unknown kid with rate limit
fail closed for invalid tokens
consider startup behavior explicitly
monitor JWK fetch latency/error
support emergency cache eviction

Unknown kid should not trigger unbounded remote calls.

Pseudo-flow:


21. JWT Size and Transport Cost

JWTs are sent on every request.

Large tokens hurt:

network bandwidth
proxy/header limits
log redaction risk
mobile battery
cache key size
latency under high RPS

Bad claim design:

{
  "roles": [
    "role1",
    "role2",
    "role3",
    "... hundreds more ..."
  ],
  "full_profile": { "...": "..." }
}

Better:

{
  "sub": "user:123",
  "tenant_id": "tenant:acme",
  "scope": "case:read case:update",
  "authz_version": 12
}

JWT should carry stable, compact, security-critical context. Not the full user object.


22. Logging and Redaction

Never log full JWTs.

Bad:

log.info("Invalid token {}", token);

Better:

log.warn("jwt_validation_failed reason={} issuer={} kid={} jti_hash={} client_ip={}",
    reason,
    safeIssuer,
    safeKid,
    hashJti(jti),
    clientIp
);

Safe fields:

issuer
kid
algorithm
jti hash
subject hash
failure reason code
request id
client id
resource audience

Unsafe fields:

full token
signature
raw Authorization header
email if not needed
PII-heavy custom claims

23. Error Semantics

External response should be generic:

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

Avoid exposing:

signature invalid
kid unknown
expired 27 seconds ago
audience mismatch
issuer mismatch

Internally, log structured reason code:

JWT_EXPIRED
JWT_NOT_YET_VALID
JWT_BAD_SIGNATURE
JWT_UNKNOWN_KID
JWT_AUDIENCE_MISMATCH
JWT_ISSUER_MISMATCH
JWT_TYPE_MISMATCH
JWT_TENANT_MISMATCH

This preserves security while enabling operations.


24. Revocation Reality

Self-contained JWTs are hard to revoke immediately.

Options:

OptionDescriptionFit
Short expiryWait for expiryMost common access token strategy
Revocation list by jtiCheck token id in Redis/DBEmergency or high-risk APIs
Subject/session versionReject stale token versionAccount-level revocation
Opaque tokenIntrospect every timeCentralized revocation
mTLS/DPoP-bound tokenReduces stolen bearer replayHigh-security systems

Do not promise “stateless JWT logout” if your business requirement is immediate revocation.

Correct statement:

Self-contained JWTs allow local validation, but immediate revocation requires state, short TTL, introspection, or sender constraint.

25. JWT Validation Test Matrix

A production-grade JWT validator must reject:

missing token
malformed token
unsigned token
wrong algorithm
algorithm none
wrong signature
unknown kid
wrong issuer
wrong audience
expired token
not-yet-valid token
missing exp
missing sub
missing aud
ID token sent to API
access token for different API
wrong tenant
oversized token
invalid scope format
clock skew abuse

Example test names:

@Test void rejectsTokenWithWrongAudience() {}
@Test void rejectsIdTokenAsAccessToken() {}
@Test void rejectsTokenFromUntrustedIssuer() {}
@Test void rejectsTokenSignedWithUnknownKid() {}
@Test void rejectsExpiredTokenOutsideClockSkew() {}
@Test void rejectsTenantMismatchBetweenPathAndClaim() {}
@Test void doesNotLogRawAuthorizationHeader() {}

Tests should include real signed tokens from test keys. Do not test only mocked Jwt objects.


26. Local Development Without Bad Habits

Development shortcuts often become production vulnerabilities.

Avoid:

jwt.validation.enabled=false
accept unsigned tokens locally
hardcode prod-like shared HMAC secret in repo
skip audience validation in tests
use one global test token forever

Better:

run local test issuer
use generated ephemeral keys
publish local JWK set
validate issuer/audience exactly
use short-lived test tokens
include negative token fixtures

For integration tests, create a deterministic test key pair:

src/test/resources/jwks/test-private.jwk
src/test/resources/jwks/test-public-jwks.json

Never commit production keys.


27. Production Checklist

JWT acceptance checklist:

[ ] Trusted issuer configured explicitly
[ ] Trusted JWK Set URI configured explicitly
[ ] Algorithm allowlist configured
[ ] Signature validation mandatory
[ ] iss validated exactly
[ ] aud validated for this resource server
[ ] exp required and enforced
[ ] nbf enforced with bounded clock skew
[ ] token purpose/type validated
[ ] ID token rejected as access token
[ ] tenant binding validated where relevant
[ ] subject kind understood
[ ] authorities mapped from issuer-specific contract
[ ] full token never logged
[ ] JWK cache monitored
[ ] key rotation tested
[ ] emergency key compromise runbook exists
[ ] revocation strategy documented

28. Decision Matrix

RequirementJWT Fit?Reason
Low-latency local API validationGoodNo per-request introspection needed
Immediate revocationWeak aloneNeeds state/short TTL/introspection
Very large authorization graphWeakToken bloat/stale permissions
Multi-service propagationGood with disciplineMust validate audience and token exchange
Browser session logoutWeak aloneBrowser cookies/session store usually better
Cross-organization federationGoodOIDC/JWT ecosystem is mature
Confidential claimsWeak unless JWEJWS is readable by holder
High-risk stolen token replay defenseBearer JWT weakConsider mTLS/DPoP/sender-constrained tokens

29. A Senior Engineer's Rule of Thumb

Use JWT when:

services need local verification
access tokens are short-lived
issuer and audience are strict
claims are compact and stable
key rotation is operationally mature
revocation requirements are realistic

Avoid JWT as primary mechanism when:

permissions change constantly
logout must revoke immediately everywhere
claims contain sensitive business data
teams cannot operate key rotation
APIs cannot enforce audience/token type consistently

JWT is powerful because it removes a network call. JWT is dangerous because it removes a network call.

The lost network call was also a chance to check current state.


30. References

Lesson Recap

You just completed lesson 17 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.