Spring · 10

JWT, OAuth2 & OIDC

Replace the server session with signed bearer tokens and understand the protocol that hands them out. This page covers the JWT format and its validation pitfalls, a Spring resource server, scope-to-authority mapping, issuing tokens, the OAuth2 grants (authorization code with PKCE, client credentials), OIDC login, refresh-token rotation, token storage and testing, with runnable Java demos of HS256, four attacks, PKCE and reuse detection.

15
Chapters
8
Diagrams
3
Java demos
10
Interview Q&A
01
Chapter One · Why It Exists

From Server Sessions to Self-Contained Tokens

The previous page ended with a session: the server remembers who you are, and the browser carries a meaningless cookie. That model is simple and revocable, but it ties every request to shared state. When you run ten API nodes, mobile clients, and a dozen internal services calling each other, "look up the session" becomes a bottleneck and a coupling point. The alternative is to make the credential carry its own proof: a signed token that any service can verify with a public key and no network call. That token format is usually a JWT (JSON Web Token), and the protocol around obtaining one safely is OAuth 2.0, with OpenID Connect (OIDC) layered on top to say who logged in.

Same goal, different place to keep the truth
SESSION: server remembers you Client JSESSIONID API node A look up session Session store (Redis) revoke = delete one row cost = a lookup on every request TOKEN: the request carries the proof Client Bearer eyJ... Any API node verify signature Handler no lookup no shared state, scales horizontally cost = cannot revoke before exp

Three questions organise the whole topic, and mixing them up is the root of most confusion in interviews and in production:

What is the token?

JWT is a format: signed JSON claims. Opaque random strings are an alternative format. The format says nothing about how you got it.

How do I get one safely?

OAuth 2.0 is a delegation protocol: grants (authorization code + PKCE, client credentials, refresh) that hand a client a token without handing it the user's password.

Who is the user?

OIDC is an identity layer on OAuth 2.0: adds the ID token, the openid scope, /userinfo and discovery so a client can log a user in.

Before — hand-rolled "token" in a filter (what many tutorials teach)
@Component class HomemadeJwtFilter extends OncePerRequestFilter { private final String secret = "secret"; // weak, hard-coded @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { String h = req.getHeader("Authorization"); if (h != null && h.startsWith("Bearer ")) { Claims c = Jwts.parser().setSigningKey(secret).parseClaimsJws(h.substring(7)).getBody(); // no iss/aud check, catches nothing var auth = new UsernamePasswordAuthenticationToken(c.getSubject(), null, List.of()); // roles? always empty SecurityContextHolder.getContext().setAuthentication(auth); } chain.doFilter(req, res); // invalid token: still continues! } }
After — the framework does it: resource server in three lines of config
spring: security: oauth2: resourceserver: jwt: issuer-uri: https://auth.example.com/realms/shop # discovery gives the JWKS URL; iss is validated
…and one line in the chain
http.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
⚠ What the "before" code gets wrong:
  • Invalid or missing tokens fall through to chain.doFilter; whether the request is rejected depends entirely on later rules, and exceptions from the parser become 500s instead of 401s.
  • No issuer, audience or algorithm checks: a token minted for a different API (or signed with alg: none on old library versions) can be accepted.
  • No WWW-Authenticate: Bearer error="invalid_token" header, no clock-skew handling, no key rotation, no tests with the real chain.
  • Deprecated or removed parser APIs: always write against the library version you ship.
02
Chapter Two · Mental Model

Three Mechanisms: Verify, Obtain, Identify

A client wants to call your API on behalf of a user
▶
It must first obtain an access token from the Authorization Server (OAuth 2.0 grant). Your API never sees the user's password
The client sends Authorization: Bearer <token>
▶
"Bearer" means whoever bears it may use it: possession is the only proof. That is why tokens are short-lived and must travel only over TLS
Your API is a resource server: it only validates
▶
BearerTokenAuthenticationFilter extracts the token, JwtDecoder verifies signature and claims, a converter maps claims to GrantedAuthoritys, and the usual authorisation rules run
The token is self-contained
▶
Validation needs only the public key (cached JWKS): no call to the Authorization Server per request, no session. The price: you cannot revoke it before exp unless you add extra machinery
Access tokens are short; users stay logged in for days
▶
A refresh token (long-lived, stored by the client, sent only to the Authorization Server) mints new access tokens, ideally with rotation and reuse detection
A client application must also log the user in (not only call APIs)
▶
OIDC adds an ID token (a JWT for the client, describing the authentication event) in the same code flow. The ID token is for the client; the access token is for the API
The resource-server filter: from a Bearer header to an Authentication
Client Authorization: Bearer eyJ... BearerToken Authentication Filter ProviderManager Jwt AuthProvider JwtDecoder parse + verify + validate Jwt Authentication Converter Security Context inside JwtDecoder (Nimbus) 1 parse 3 parts, read alg + kid 2 pick key (JWKS cache by kid) 3 verify signature with pinned alg 4 validators: exp, nbf, iss (+ your aud) Failure at any step: BadJwtException / JwtValidationException wrapped in InvalidBearerTokenException -> 401 + WWW-Authenticate: Bearer Success: Jwt claims -> authorities (SCOPE_x) -> Authentication AuthorizationFilter then applies hasAuthority / hasRole rules

Your Spring application can play one or both of two roles: resource server (validates bearer tokens: spring-boot-starter-oauth2-resource-server) and client (obtains tokens or logs users in: spring-boot-starter-oauth2-client). A third piece, the authorization server, is normally a product (Keycloak, Okta, Auth0, Entra ID, Cognito) or the separate Spring Authorization Server project; writing your own is the last resort, not the default.

03
Chapter Three · JWT Anatomy

Structure, Signing and the Validation Checklist

A JWT used as an access token is a JWS (JSON Web Signature): base64url(header) . base64url(payload) . base64url(signature). The signature covers the first two parts exactly as transmitted, so changing one byte of the payload invalidates it. (A JWE is the encrypted variant with five parts; it is rare for access tokens and everything below assumes JWS.)

A JWT is three base64url strings joined by dots
HEADER {"alg":"RS256", "typ":"JWT", "kid":"2026-key-1"} which key / algorithm PAYLOAD (claims) {"iss":"https://auth.example.com", "sub":"u-42", "aud":"orders-api", "scope":"orders:read", "iat":1800000000, "exp":1800000900} who, for whom, until when SIGNATURE RS256( base64url(header) + "." + base64url(payload), authServer.privateKey ) proof nobody edited it . . Each part is base64url-encoded (NOT encrypted): anyone holding the token can read the claims. Only the signature stops tampering; only the verifier key decides trust.

Look inside a real token

Decode without any library (this is why you must never put secrets in claims)
$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIs... $ echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null {"sub":"alice","iss":"https://auth.example.com","aud":"orders-api","scope":"orders:read","exp":1800000900}

Standard (registered) claims

ClaimMeaningValidate it?
issIssuer: who minted the tokenYes: exact match to the one you trust
subSubject: stable user/client idUse as principal; do not use email as identity key
audAudience: which API(s) the token is forYes: otherwise a token for API A works on API B
expExpiry (seconds since epoch)Yes (default validator, 60 s skew)
nbf / iatNot before / issued atnbf checked by default; iat informational
jtiUnique token idNeeded for a denylist / replay detection
scope / scpPermissions granted (OAuth2 convention, space-delimited string or array)Mapped to SCOPE_* authorities

Which signature algorithm?

AlgorithmKey modelUse whenWatch out
HS256 (HMAC-SHA-256)One shared secret signs and verifiesSingle application or tightly coupled servicesEvery verifier can also forge; secret must be at least 256 bits of randomness
RS256 (RSA + SHA-256)Private key signs, public key verifiesDefault for identity providers; many resource serversSlower signing; 2048-bit keys minimum
ES256 (ECDSA P-256)Private signs, public verifiesSame as RS256 with smaller tokens and keysLibrary support and key handling are slightly less uniform
noneNo signatureNeverReject it explicitly

Attacks that a correct validator stops

AttackWhat the attacker doesDefence
Payload tamperingEdits scope/sub, keeps the old signatureSignature verification (always happens before claims are trusted)
alg: noneHeader says "no signature needed"Never choose the algorithm from the token; pin it per key (jwsAlgorithms, MacAlgorithm)
Algorithm confusionChanges RS256 to HS256 and signs with the public key as if it were an HMAC secretPin the expected algorithm to the key type; Nimbus JWSKeySelector rejects mismatched pairs
Key injection via jku / x5u / jwk headersPoints the verifier at an attacker-controlled keyTake keys only from the configured JWKS URI; Spring/Nimbus do not follow these headers
Wrong audience / issuerReplays a token minted for another service or another tenantValidate aud and iss (Chapter 4)
Expired token replayReuses a stolen tokenexp check plus short TTL; HTTPS only
Weak HMAC secretBrute-forces "secret" offline from one captured tokenRandom 32+ bytes from a secret manager; prefer asymmetric keys
⚠ Real production gotchas:
  • Decoding is not verifying. Libraries expose both; a method named decode/parseClaimsJwt (no "Jws") happily returns unverified claims. Only trust claims returned by a verified path such as Spring's JwtDecoder.
  • Sensitive data in the payload (email, roles are fine; password hashes, national ids, internal ids you do not want exposed are not): it is readable by the client, by proxies, and in logs that record headers.
  • Huge tokens: 50 roles and group names push the header past the 8 KB limit of many proxies (431 Request Header Fields Too Large). Keep tokens small, fetch details from the API.
  • Clock drift between servers causes sporadic Jwt expired at ... or Jwt used before ... errors; keep NTP running. Spring allows 60 seconds of skew by default.
  • Using a JWT as a session (long exp, stuffing user state in it): you inherit all the revocation problems with none of the session store's benefits.
04
Chapter Four · Resource Server

Validating Bearer Tokens in Spring Security

Add spring-boot-starter-oauth2-resource-server. Boot then needs to know how to verify signatures. You choose one of three property styles, and which one you pick decides what is validated for free.

PropertyWhat Boot buildsValidated automaticallyWhen
jwt.issuer-uriNimbusJwtDecoder from OIDC/OAuth discovery (/.well-known/openid-configuration or the RFC 8414 endpoint), lazily on first requestSignature, exp, nbf, iss equals the issuer-uriNormal case with an IdP
jwt.jwk-set-uriDecoder using that JWKS URL; no discoverySignature, exp, nbf (not iss)IdP without discovery; add an issuer validator yourself
jwt.public-key-locationDecoder with one static PEM public keySignature, exp, nbfTokens signed by an app you control; no rotation
jwt.audiencesAdds an aud must-contain validatoraudCombine with any of the above
application.yml
spring: security: oauth2: resourceserver: jwt: issuer-uri: https://auth.example.com/realms/shop audiences: orders-api # reject tokens minted for other APIs jws-algorithms: RS256 # pin; default is RS256 anyway # jwk-set-uri: https://auth.example.com/realms/shop/protocol/openid-connect/certs
ResourceServerConfig.java
@Configuration @EnableWebSecurity @EnableMethodSecurity class ResourceServerConfig { @Bean SecurityFilterChain api(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders:read") .requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("SCOPE_orders:write") .anyRequest().authenticated()) .oauth2ResourceServer(oauth -> oauth .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(csrf -> csrf.disable()); // bearer header, no cookie: nothing for CSRF to protect return http.build(); } // jwtAuthenticationConverter(): Chapter 5 }

Taking control of the decoder

When the properties are not enough (custom validation, multiple issuers, tests), publish your own JwtDecoder bean; Boot's auto-configured one backs off. A decoder is a parser plus a list of OAuth2TokenValidator<Jwt>s. The default list contains JwtTimestampValidator (exp/nbf with 60 s skew); JwtValidators.createDefaultWithIssuer(...) adds JwtIssuerValidator.

JwtDecoderConfig.java — issuer + audience + clock skew
@Bean JwtDecoder jwtDecoder(@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer) { NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); // fetches discovery now OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer); // timestamp + iss OAuth2TokenValidator<Jwt> audience = jwt -> jwt.getAudience().contains("orders-api") ? OAuth2TokenValidatorResult.success() : OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "audience must contain orders-api", null)); decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audience)); return decoder; }
Asymmetric signing and key rotation with a JWKS endpoint
Authorization Server signs with PRIVATE key kid = "k2" /oauth2/jwks (JWKS) {"keys":[ {"kid":"k1","kty":"RSA","n":...}, {"kid":"k2","kty":"RSA","n":...}]} publishes keys Resource Server cache keys by kid verify with PUBLIC key fetch Token header: kid = "k2" RS256 key cache by kid unknown kid -> refetch verify: find key for kid Rotation: publish new key k3 first, start signing with it, keep k2 until all k2 tokens have expired. Resource servers never hold a secret: a leak of the resource server cannot mint tokens.

What a failed request looks like

Expired token, as the client sees it
GET /api/orders/7 HTTP/1.1 Authorization: Bearer eyJhbGciOiJSUzI1NiIs... HTTP/1.1 401 WWW-Authenticate: Bearer error="invalid_token", error_description="Jwt expired at 2026-10-29T10:15:00Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

Three failure families map to three statuses: no token at all on a protected route gives 401 with a bare WWW-Authenticate: Bearer; an invalid token gives 401 with error="invalid_token" (bad signature, expired, wrong issuer/audience); a valid token without the needed authority gives 403 with error="insufficient_scope" from BearerTokenAccessDeniedHandler. Clients should refresh on the first and re-authorise on the third.

⚠ Real production gotchas:
  • Issuer mismatch (An error occurred while attempting to decode the Jwt: The iss claim is not valid): the token's iss is the public hostname (https://auth.example.com) but the API reaches the IdP over an internal hostname in issuer-uri. Use jwk-set-uri for fetching keys and a separate issuer validator for the public value, or make the IdP advertise one issuer everywhere.
  • Application will not start (or first request fails) because the IdP is down: NimbusJwtDecoder.withIssuerLocation and JwtDecoders.fromIssuerLocation contact the IdP immediately; the property-driven issuer-uri setup defers it to the first request. Choose deliberately and add a health check.
  • Everything is 401 even with a good token: the filter chain for that path is not the one with oauth2ResourceServer (another chain matched first), or the request goes through a gateway that strips Authorization. Enable logging.level.org.springframework.security=TRACE and look for BearerTokenAuthenticationFilter.
  • Algorithm mismatch: IdP signs with ES256 or PS256 but the default expects RS256: Another algorithm expected, or no matching key(s) found. Set jws-algorithms.
  • Key rotation breaks production: using public-key-location (a PEM baked into the image) for an IdP that rotates keys. Use JWKS.
  • Skipping audience validation because "it works" in dev with one API: the first sibling service that shares the issuer can call yours with its own tokens.
05
Chapter Five · Authorities

Mapping Claims to Scopes, Roles and Principals

After the decoder succeeds, JwtAuthenticationProvider asks a Converter<Jwt, AbstractAuthenticationToken> to turn the Jwt into the Authentication that goes into the SecurityContext. The default (JwtAuthenticationConverter with JwtGrantedAuthoritiesConverter) reads the scope claim (falling back to scp), splits a space-delimited string, and prefixes each value with SCOPE_. So "scope": "orders:read orders:write" becomes authorities SCOPE_orders:read and SCOPE_orders:write, and the principal name is the sub claim.

IdPWhere permissions live in the tokenYou need
Generic OAuth2 / Spring Authorization Serverscope (string)Defaults
Azure AD / Entra IDscp (delegated) and roles (app roles)Default handles scp; map roles yourself
Keycloakscope plus realm_access.roles and resource_access.<client>.roles (nested JSON)Custom converter
Okta / Auth0scp or scope; roles often via a custom claim or permissions arrayCheck the actual token, then configure the claim name
Combine scopes and Keycloak realm roles
@Bean JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); // SCOPE_ prefix, claims scope/scp Converter<Jwt, Collection<GrantedAuthority>> realmRoles = jwt -> { Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access"); if (realmAccess == null || !(realmAccess.get("roles") instanceof Collection<?> roles)) { return List.of(); } return roles.stream() .<GrantedAuthority>map(r -> new SimpleGrantedAuthority("ROLE_" + r)) // so hasRole("ADMIN") works .toList(); }; JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(jwt -> { Set<GrantedAuthority> all = new LinkedHashSet<>(scopes.convert(jwt)); all.addAll(realmRoles.convert(jwt)); return all; }); converter.setPrincipalClaimName("preferred_username"); // default is "sub" return converter; }

In controllers the principal is a Jwt, so you can read claims directly without touching the decoder again:

Using the Jwt in a handler
@GetMapping("/api/me") Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) { return Map.of( "subject", jwt.getSubject(), "scopes", jwt.getClaimAsString("scope"), "expiresAt", jwt.getExpiresAt(), "tenant", jwt.getClaimAsString("tenant_id")); } @PreAuthorize("hasAuthority('SCOPE_orders:write') and #cmd.customerId() == authentication.token.subject") public Order create(NewOrder cmd) { ... } // authentication is a JwtAuthenticationToken; .token is the Jwt
⚠ Real production gotchas:
  • All requests 403 although the user "has the role": roles are in a custom claim, but the default converter only reads scope. Print authentication.getAuthorities() and compare with the real token payload (decode it).
  • Scope vs role confusion: scopes describe what the client may do on the user's behalf; roles describe what the user is. A rule like hasRole("ADMIN") on a client-credentials token (no user at all) can never be true. Model service-to-service rules with scopes.
  • Double prefix: converter already adds ROLE_ and the claim value is ROLE_ADMIN, giving ROLE_ROLE_ADMIN. Set the prefix to empty (setAuthorityPrefix("")) when the claim already carries the prefix.
  • Authorities frozen into the token: a user's role is removed in the IdP but old access tokens keep it until exp. That is the cost of stateless tokens; shorten the TTL for privileged roles or check the database for sensitive actions.
06
Chapter Six · Issuing Tokens

JwtEncoder, and When Not to Do It

If your system is a single Spring Boot application with its own user table, minting a short-lived JWT yourself after login is legitimate and avoids a separate Authorization Server. Spring Security ships the building blocks (JwtEncoder backed by Nimbus), so you do not need a third-party JWT library. The same NimbusJwtDecoder verifies it with the matching key. As soon as you need SSO, social login, multiple clients, consent, or token revocation, use a real Authorization Server instead; that is a product, not a controller.

TokenConfig.java — an RSA key pair, encoder and decoder
@Configuration class TokenConfig { @Bean KeyPair rsaKeyPair() throws NoSuchAlgorithmException { // demo only: load from a keystore / secret store in production KeyPairGenerator g = KeyPairGenerator.getInstance("RSA"); g.initialize(2048); return g.generateKeyPair(); } @Bean JwtEncoder jwtEncoder(KeyPair kp) { RSAKey jwk = new RSAKey.Builder((RSAPublicKey) kp.getPublic()) .privateKey(kp.getPrivate()) .keyID("app-key-1") .build(); return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk))); } @Bean JwtDecoder jwtDecoder(KeyPair kp) { return NimbusJwtDecoder.withPublicKey((RSAPublicKey) kp.getPublic()).build(); // RS256 by default } }
TokenService.java and the login endpoint
@Service class TokenService { private final JwtEncoder encoder; TokenService(JwtEncoder encoder) { this.encoder = encoder; } String accessToken(Authentication auth) { Instant now = Instant.now(); String scope = auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(" ")); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer("https://api.example.com") .subject(auth.getName()) .audience(List.of("orders-api")) .issuedAt(now) .expiresAt(now.plus(15, ChronoUnit.MINUTES)) // short: no revocation .id(UUID.randomUUID().toString()) // jti, for a denylist if you need one .claim("scope", scope) .build(); JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).build(); return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); } } @RestController class AuthController { private final AuthenticationManager manager; private final TokenService tokens; AuthController(AuthenticationManager manager, TokenService tokens) { this.manager = manager; this.tokens = tokens; } record Login(@NotBlank String username, @NotBlank String password) {} record TokenResponse(String access_token, String token_type, long expires_in) {} @PostMapping("/auth/token") // permitAll in the chain TokenResponse token(@Valid @RequestBody Login body) { Authentication auth = manager.authenticate( UsernamePasswordAuthenticationToken.unauthenticated(body.username(), body.password())); return new TokenResponse(tokens.accessToken(auth), "Bearer", 900); } }
⚠ Real production gotchas:
  • Generating the key pair at startup (as in the demo) invalidates every token on each restart and gives every instance a different key: all but one node reject tokens. Load a persistent key and publish a JWKS if other services verify.
  • Also exposing a password-grant style endpoint (/auth/token with username/password) to third-party clients: this is the OAuth2 "password grant", removed in OAuth 2.1 because clients see the password. Fine for your own first-party UI; not for partners.
  • No rate limit or lockout on the token endpoint: it is a free password-guessing oracle. Put the encoder cost, a rate limiter and monitoring in front.
  • Tokens that never expire because expiresAt was omitted: Nimbus accepts a token without exp unless you add a validator that requires it.
07
Chapter Seven · OAuth2 Roles & Grants

Who Does What, and Which Flow to Use

OAuth 2.0 (RFC 6749, with best-practice updates folded into the draft OAuth 2.1) defines four roles and several grant types: recipes for how a client obtains an access token. Choosing the grant is a question of who the client is and whether a user is present.

The four OAuth2 roles
Resource Owner the user (alice) Client web app / SPA / service Authorization Server (Keycloak, Okta, Spring AS) Resource Server your Spring API 1 grants consent 2 asks for tokens 3 access token 4 Bearer token JWKS / introspect The user never gives the client their password; they authenticate at the Authorization Server. A Spring Boot API is almost always a Resource Server; a Spring MVC web app with login is a Client; a Spring AS is the Auth Server.
GrantClient type / situationUser involved?Status
Authorization Code + PKCEWeb apps, SPAs, mobile and desktop appsYes (browser redirect)The default for anything with a user
Client CredentialsBackend service calling another serviceNo: token represents the client itselfStandard for machine-to-machine
Refresh TokenAny client that received a refresh tokenNot at that momentUsed to renew access tokens
Device AuthorizationTVs, CLIs, devices without a browserYes (on a second device)Niche but standard
ImplicitOld SPAsYesRemoved in OAuth 2.1: tokens leaked via URL fragment
Resource Owner PasswordLegacy first-party appsClient sees the passwordRemoved in OAuth 2.1

Client credentials in Spring: service-to-service

No user, no redirect. The client authenticates to the token endpoint with its own client_id and secret (or a signed assertion / mTLS in stricter setups), receives an access token with scopes granted to the service, caches it until shortly before exp (there is no refresh token in this grant), and attaches it to outgoing calls.

application.yml — a client registration
spring: security: oauth2: client: provider: idp: issuer-uri: https://auth.example.com/realms/shop registration: billing: provider: idp client-id: orders-service client-secret: change-me-from-a-secret-store authorization-grant-type: client_credentials scope: billing:write
OutboundClientConfig.java — a WebClient that adds and refreshes the token
@Configuration class OutboundClientConfig { @Bean OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository registrations, OAuth2AuthorizedClientService clients) { var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients); manager.setAuthorizedClientProvider( OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build()); // fetch when missing/expired return manager; } @Bean WebClient billingClient(OAuth2AuthorizedClientManager manager) { var oauth = new ServletOAuth2AuthorizedClientExchangeFilterFunction(manager); oauth.setDefaultClientRegistrationId("billing"); return WebClient.builder() .baseUrl("https://billing.internal") .apply(oauth.oauth2Configuration()) // adds Authorization: Bearer <token> .build(); } }

This uses AuthorizedClientServiceOAuth2AuthorizedClientManager (not the request-scoped variant) because there is no HTTP request or user session behind a scheduled job. Requires spring-boot-starter-webflux on the classpath for WebClient, even in a servlet application.

⚠ Real production gotchas:
  • Client secret in source control or an image layer: inject from the platform secret store. A leaked client secret is a permanent credential until rotated.
  • Calling the token endpoint per request (hand-written RestTemplate code with no cache): rate-limits and latency spikes at the IdP. Use the authorized-client manager, which caches by principal and registration.
  • Over-broad scopes on the service client (* or admin): treat each service identity like a user with least privilege.
  • Using the end user's access token to call downstream APIs (token passthrough): the downstream sees a token whose aud is your API. Prefer token exchange (RFC 8693) or client credentials with explicit audience.
08
Chapter Eight · Authorization Code + PKCE

The Login Dance, Step by Step

The authorization code grant splits the exchange into two channels. In the front channel (the browser, visible to the user, extensions and logs) the client only learns a short-lived, single-use authorization code. In the back channel (server to server over TLS) the client trades that code for tokens. Anything secret travels only in the back channel.

Authorization Code flow with PKCE
Browser (user) Client app (Spring) Authorization Server Resource Server 1 GET /oauth2/authorization/idp 2 302 /authorize?challenge,state 3 follow redirect 4 login page + consent (user types password HERE) 5 302 redirect_uri?code=abc&state 6 GET /login/oauth2/code/idp 7 POST /token code + verifier 8 access + id + refresh token 9 GET /api Authorization: Bearer ... 10 200 JSON Check at step 7: SHA-256(code_verifier) == stored code_challenge ? stolen code without v = invalid_grant state (CSRF) and exact redirect_uri must match; the code is single-use and lives ~60 seconds Back-channel (7, 8): server to server over TLS. Front-channel (1-6): visible to the browser, so no secrets.

What PKCE adds

The code can be stolen from the front channel: a malicious app registered for the same custom URL scheme on a phone, a leaked Referer, a browser extension, a proxy log. PKCE (Proof Key for Code Exchange, RFC 7636, pronounced "pixie") binds the code to the client that started the flow. The client invents a random code_verifier and sends only its hash, code_challenge = BASE64URL(SHA-256(verifier)), with the authorization request. When it redeems the code at the token endpoint it must present the verifier; the server hashes it and compares. A thief who has only the code cannot redeem it. PKCE is mandatory for public clients (SPAs, mobile) and recommended for every authorization-code client in OAuth 2.1.

Pkce.java — verified against the RFC 7636 test vector
import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Base64; /** PKCE (RFC 7636): the client proves it is the one that started the flow. Plain JDK. */ public class Pkce { static final Base64.Encoder URL = Base64.getUrlEncoder().withoutPadding(); static String challengeS256(String verifier) throws Exception { byte[] digest = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)); return URL.encodeToString(digest); } public static void main(String[] args) throws Exception { // 1. test vector from RFC 7636 appendix B: our implementation must reproduce it exactly String rfcVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; String expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; System.out.println("RFC 7636 vector matches : " + challengeS256(rfcVerifier).equals(expected)); // 2. what a real client does: 32 random bytes -> 43-char verifier byte[] raw = new byte[32]; new SecureRandom().nextBytes(raw); String verifier = URL.encodeToString(raw); String challenge = challengeS256(verifier); System.out.println("verifier length : " + verifier.length() + " chars (spec: 43..128)"); System.out.println("challenge length : " + challenge.length() + " chars (SHA-256, base64url)"); // 3. the authorization server stores only the challenge issued with the code String stored = challenge; System.out.println("legit token request : " + (challengeS256(verifier).equals(stored) ? "code accepted" : "REJECTED invalid_grant")); String stolenCodeAttacker = URL.encodeToString(new SecureRandom().generateSeed(32)); System.out.println("attacker with stolen code: " + (challengeS256(stolenCodeAttacker).equals(stored) ? "code accepted" : "REJECTED invalid_grant (does not know the verifier)")); } }
Output
RFC 7636 vector matches : true verifier length : 43 chars (spec: 43..128) challenge length : 43 chars (SHA-256, base64url) legit token request : code accepted attacker with stolen code: REJECTED invalid_grant (does not know the verifier)

Spring Boot as the OAuth2 client (login with an IdP)

application.yml
spring: security: oauth2: client: registration: idp: client-id: shop-web client-secret: change-me-from-a-secret-store scope: openid,profile,email redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" # must match the IdP registration EXACTLY provider: idp: issuer-uri: https://auth.example.com/realms/shop
WebSecurityConfig.java — oauth2Login with PKCE for a confidential client
@Configuration @EnableWebSecurity class WebSecurityConfig { @Bean SecurityFilterChain web(HttpSecurity http, ClientRegistrationRepository registrations) throws Exception { var resolver = new DefaultOAuth2AuthorizationRequestResolver(registrations, "/oauth2/authorization"); resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce()); // add PKCE http .authorizeHttpRequests(a -> a .requestMatchers("/", "/error", "/assets/**").permitAll() .anyRequest().authenticated()) .oauth2Login(o -> o.authorizationEndpoint(ae -> ae.authorizationRequestResolver(resolver))) .logout(l -> l.logoutSuccessHandler(oidcLogoutHandler(registrations))); return http.build(); } private LogoutSuccessHandler oidcLogoutHandler(ClientRegistrationRepository registrations) { var handler = new OidcClientInitiatedLogoutSuccessHandler(registrations); // RP-initiated logout at the IdP handler.setPostLogoutRedirectUri("{baseUrl}/"); return handler; } }

Behind oauth2Login() two filters do the work: OAuth2AuthorizationRequestRedirectFilter (handles /oauth2/authorization/{registrationId}, builds the /authorize URL with state, nonce for OIDC, and the PKCE challenge, and stores the request in the session) and OAuth2LoginAuthenticationFilter (handles /login/oauth2/code/*, verifies state, calls the token endpoint, validates the ID token, and creates an OAuth2AuthenticationToken whose principal is an OidcUser). Spring Security applies PKCE automatically for public clients (client authentication method none); for confidential clients you opt in as above.

⚠ Real production gotchas:
  • redirect_uri_mismatch / invalid_redirect_uri from the IdP: the registered URI must match character for character, including scheme. Behind a TLS-terminating proxy the app builds http:// URLs unless you set server.forward-headers-strategy=framework (or native) and the proxy sends X-Forwarded-Proto.
  • [authorization_request_not_found] after the redirect back: the session holding the stored request (and state) did not reach the callback: cookie SameSite=Strict, a different node without session replication, or the user took too long. Use Lax and shared sessions.
  • Wildcard redirect URIs registered at the IdP: an attacker's URL on the allowed domain receives the code. Register exact URIs only.
  • Skipping state validation in hand-written clients: login CSRF (an attacker logs the victim into the attacker's account). The Spring filters do this; custom clients must too.
  • Client secret embedded in an SPA or mobile app: it is public the moment you ship it. Public clients use PKCE and no secret; or route through a backend (BFF, Chapter 10).
09
Chapter Nine · OIDC

Identity on Top of OAuth2

OAuth 2.0 alone does not say who the user is; it only says the client may call certain APIs. Early "login with OAuth" implementations guessed identity by calling a profile API with the access token, which let a malicious app replay a token obtained for a different app. OpenID Connect fixes that by standardising the identity artefacts: the openid scope, the ID token (a JWT addressed to the client with aud = client id), a nonce that ties it to the request, a /userinfo endpoint, and a discovery document at /.well-known/openid-configuration listing every endpoint and the JWKS URI.

ID tokenAccess tokenRefresh token
AudienceThe client (aud = client_id)The resource server (aud = API)The authorization server
PurposeProve who authenticated, when and how (sub, auth_time, nonce, acr)Authorise API calls (scope)Obtain new access tokens
FormatAlways a JWTJWT or opaqueOpaque, usually
Who may read itThe clientThe API (client should treat as opaque)Only the authorization server
Send it to your API?NeverYes, as BearerNever
Client asks for scope=openid profile email
▶
The openid scope is what switches the protocol from plain OAuth2 to OIDC; profile and email select claim groups
Token endpoint response contains id_token
▶
OidcAuthorizationCodeAuthenticationProvider validates it: signature from the IdP's JWKS, iss, aud contains the client id, exp, and nonce equals the one stored in the session
Extra claims too big for the token
▶
Spring calls /userinfo with the access token and merges the result into the OidcUser; the sub from both must match
Using the logged-in user
@GetMapping("/profile") String profile(@AuthenticationPrincipal OidcUser user, Model model) { model.addAttribute("name", user.getFullName()); model.addAttribute("email", user.getEmail()); model.addAttribute("subject", user.getSubject()); // stable key: store THIS, not the email model.addAttribute("loggedInAt", user.getAuthenticatedAt()); return "profile"; } // Map IdP groups to roles for the web app @Bean GrantedAuthoritiesMapper authoritiesMapper() { return authorities -> authorities.stream() .flatMap(a -> a instanceof OidcUserAuthority oidc ? Stream.concat(Stream.of(a), Optional.ofNullable(oidc.getIdToken().getClaimAsStringList("groups")).orElse(List.of()).stream() .<GrantedAuthority>map(g -> new SimpleGrantedAuthority("ROLE_" + g.toUpperCase()))) : Stream.of(a)) .collect(Collectors.toSet()); }
⚠ Real production gotchas:
  • Sending the ID token to the API as a Bearer token: it "works" because it is a signed JWT from the same issuer, but its aud is the client, not the API. With audience validation on, the API rightly rejects it with 401.
  • Using email as the user key: emails change and can be unverified (email_verified=false). Key your users on (iss, sub).
  • getClaimAsStringList("groups") returns null when the claim is absent (NPE in the mapper above): guard it, and remember many IdPs only include groups when you request a specific scope or enable a mapper.
  • Logging out only locally: the Spring session ends but the IdP session lives, so clicking "login" silently logs back in. Use RP-initiated logout (OidcClientInitiatedLogoutSuccessHandler) and/or back-channel logout.
  • Treating OIDC as authorisation: "authenticated by Google" says nothing about what the user may do in your app.
10
Chapter Ten · Refresh, Storage & Revocation

Living With Tokens You Cannot Take Back

A bearer access token is valid until exp no matter what happens to the user's account. Three practices keep that risk bounded: short lifetimes (5-15 minutes), refresh-token rotation, and choosing where tokens live so the common attack (XSS) cannot steal them.

Refresh token rotation

With rotation every refresh returns a new refresh token and marks the old one used. Tokens issued from one login form a family. If a used token is ever presented again, either the thief or the real client is replaying it, and the server cannot tell which: it revokes the whole family, forcing a fresh login. The real Spring Authorization Server and most IdPs expose this as "rotate refresh tokens" / "reuse detection" settings.

Refresh-token rotation with reuse detection
rt-1 issued at login rt-2 rotated: rt-1 used rt-3 rotated: rt-2 used rt-1 again attacker replays it refresh refresh rt-1 marked USED family fam-A reuse! Server revokes the whole family: rt-3 (held by the real user) dies too, both parties must log in again Why: a refresh token that appears twice means two parties hold it; one of them is not the user. Run it yourself in Chapter 12.
RefreshRotation.java — families and reuse detection
import java.util.*; /** Refresh-token rotation with reuse detection (token families). In-memory stand-in for a DB table. */ public class RefreshRotation { record Token(String id, String familyId, String user) {} static final Map<String, Token> ACTIVE = new HashMap<>(); // refresh token -> record static final Set<String> USED = new HashSet<>(); // already-rotated tokens static final Set<String> REVOKED_FAMILIES = new HashSet<>(); static int seq = 0; static String issue(String family, String user) { String t = "rt-" + (++seq); ACTIVE.put(t, new Token(t, family, user)); return t; } static String refresh(String presented) { if (USED.contains(presented)) { // replay of an old token => theft signal String fam = findFamily(presented); REVOKED_FAMILIES.add(fam); ACTIVE.values().removeIf(t -> t.familyId().equals(fam)); throw new IllegalStateException("reuse detected, family " + fam + " revoked"); } Token t = ACTIVE.remove(presented); if (t == null) throw new IllegalStateException("unknown refresh token"); if (REVOKED_FAMILIES.contains(t.familyId())) throw new IllegalStateException("family revoked"); USED.add(presented); FAMILY_OF.put(presented, t.familyId()); return issue(t.familyId(), t.user()); } static final Map<String, String> FAMILY_OF = new HashMap<>(); static String findFamily(String used) { return FAMILY_OF.get(used); } static void call(String label, String token) { try { System.out.printf("%-38s -> new token %s%n", label, refresh(token)); } catch (Exception e) { System.out.printf("%-38s -> %s%n", label, e.getMessage()); } } public static void main(String[] args) { String rt1 = issue("fam-A", "alice"); System.out.println("login -> " + rt1); call("legit client refreshes with " + rt1, rt1); // rt-2 call("legit client refreshes with rt-2", "rt-2"); // rt-3 call("ATTACKER replays stolen " + rt1, rt1); // reuse call("legit client tries rt-3 (now revoked)", "rt-3"); System.out.println("-> user must log in again; the whole family is dead"); } }
Output
login -> rt-1 legit client refreshes with rt-1 -> new token rt-2 legit client refreshes with rt-2 -> new token rt-3 ATTACKER replays stolen rt-1 -> reuse detected, family fam-A revoked legit client tries rt-3 (now revoked) -> unknown refresh token -> user must log in again; the whole family is dead

Where does the browser keep the token?

Where the token lives decides what an XSS bug can steal
localStorage / memory token readable by any JS XSS = full token theft no CSRF (header) HttpOnly cookie (direct) JS cannot read it XSS cannot steal, but can use CSRF back: SameSite + token BFF (backend for frontend) tokens stay on the server browser holds a session cookie XSS cannot exfiltrate tokens simple, common, riskiest better against theft recommended for browser SPAs Browser SPA session cookie BFF: Spring Cloud Gateway oauth2Login + TokenRelay Bearer Resource Server The BFF is a confidential OAuth2 client: it holds the refresh token and client secret; the browser never sees a JWT.
StorageXSS impactCSRFVerdict
localStorage / sessionStorageAny injected script reads and exfiltrates the token; attacker uses it from anywhereNot applicable (explicit header)Common, convenient, highest theft risk
In-memory JS variableScript can still read it while the tab is open; gone on refreshNot applicableBetter; needs silent re-login on reload
HttpOnly + Secure + SameSite cookie holding the JWTCannot be read, but injected script can still make authenticated requestsReturns: needs SameSite and CSRF defenceReasonable if CSRF handled
BFF: server holds tokens, browser has a session cookieTokens cannot be stolen; requests still forgeable by XSSSession CSRF protection appliesRecommended for browser apps

Revocation options when "stateless" is not enough

ApproachHowCost
Short TTL onlyAccept up to 15 minutes of residual accessNone; the default
Denylist by jti / subA validator checks a fast store (Redis) for revoked ids; entries expire with the tokenA lookup per request; you are back to partial state
Opaque tokens + introspectionoauth2ResourceServer(o -> o.opaqueToken(...)) calls the IdP's /introspect (RFC 7662) each time (cacheable briefly)Network hop per request; instant revocation
Key rotationRemove the signing key: everything becomes invalidNuclear option for key compromise
Version claimUser row has tokenVersion; tokens carry it; mismatch rejectsA DB read per request; or cache with short TTL
⚠ Real production gotchas:
  • Refresh token in localStorage with 30-day life and no rotation: one XSS bug yields a month of access. Rotate, bind to the client, and keep it HttpOnly or server-side.
  • Race on rotation: two browser tabs refresh with the same token at once; the second looks like a replay and the user is logged out. Serialise refreshes in the client (single-flight) and allow a small grace window if your IdP supports it.
  • Logout does not log out: deleting the token on the client only; the server-side session at the IdP and any unexpired access token remain. Revoke the refresh token at the IdP (/revoke, RFC 7009).
  • Password change does not end sessions: expected for pure JWT; implement family revocation on password reset.
  • Logging Authorization headers in a gateway or APM: bearer tokens end up readable by everyone with log access. Mask them.
11
Chapter Eleven · Testing

Testing Secured Endpoints Without a Real IdP

spring-security-test provides MockMvc request post-processors that put an authenticated principal in place without parsing a real token. That is perfect for testing authorisation rules (who can call what). It deliberately skips the JwtDecoder, so signature and expiry logic need separate tests (a decoder unit test with a locally generated key, or an integration test against a Testcontainers Keycloak). See the Testing page for the surrounding slice-test setup.

OrderControllerSecurityTest.java
@WebMvcTest(OrderController.class) @Import(ResourceServerConfig.class) class OrderControllerSecurityTest { @Autowired MockMvc mvc; @MockBean OrderService orders; // Boot 3.4+: prefer @MockitoBean @MockBean JwtDecoder jwtDecoder; // the chain needs a decoder bean to start; jwt() bypasses it at runtime @Test void anonymous_gets_401() throws Exception { mvc.perform(get("/api/orders/7")) .andExpect(status().isUnauthorized()) .andExpect(header().string("WWW-Authenticate", startsWith("Bearer"))); } @Test void read_scope_can_read() throws Exception { mvc.perform(get("/api/orders/7") .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:read")))) .andExpect(status().isOk()); } @Test void read_scope_cannot_write() throws Exception { mvc.perform(post("/api/orders") .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:read"))) .contentType(MediaType.APPLICATION_JSON).content("{}")) .andExpect(status().isForbidden()); // 403, not 401: token valid, scope missing } @Test void claims_reach_the_controller() throws Exception { mvc.perform(get("/api/me").with(jwt().jwt(j -> j.subject("alice").claim("tenant_id", "t-9")) .authorities(new SimpleGrantedAuthority("SCOPE_orders:read")))) .andExpect(jsonPath("$.subject").value("alice")) .andExpect(jsonPath("$.tenant").value("t-9")); } }

For session/form applications use @WithMockUser(roles = "ADMIN") or .with(user("alice").roles("ADMIN")), and add .with(csrf()) to state-changing requests, otherwise a correctly secured endpoint returns 403 and the test "fails for the right reason" in the wrong way. For OIDC login use .with(oidcLogin().idToken(t -> t.subject("u-1"))).

⚠ Real production gotchas:
  • Only testing the happy path: every protected route needs anonymous (401), wrong authority (403), and right authority (200) cases; the 403 test is the one that catches missing method security.
  • @WebMvcTest fails with No qualifying bean of type 'JwtDecoder' (or the issuer-uri is fetched at startup): provide a mock JwtDecoder or exclude/override the resource-server auto-configuration for the slice.
  • Tests green, production 401: jwt() skipped real validation. Add at least one integration test that signs a token with a test key and runs the real decoder (audience, issuer, expiry cases).
  • Hard-coded expired tokens in fixtures that start failing months later: generate tokens in the test with a fixed Clock.
12
Chapter Twelve · Build It Yourself

HS256 JWT From Scratch, Four Attacks, One Naive Verifier

Nothing demystifies a JWT like computing one. The program below creates and verifies an HS256 token with only javax.crypto.Mac and java.util.Base64, then runs the attacks from Chapter 3 against two verifiers: a correct one (pinned algorithm, constant-time signature check, issuer, audience and expiry validation with 60 s skew) and a deliberately naive one that trusts the token's own alg header. Use it to build intuition; in production always use the Nimbus-backed decoder.

MiniJwt.java
import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.*; import java.util.regex.*; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; /** HS256 JWT from scratch: sign, verify, and four classic attacks. Plain JDK only. */ public class MiniJwt { static final byte[] KEY = "change-me-32-bytes-minimum-secret!".getBytes(StandardCharsets.UTF_8); static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); static final Base64.Decoder UNB64 = Base64.getUrlDecoder(); static String b64(String s) { return B64.encodeToString(s.getBytes(StandardCharsets.UTF_8)); } static String unb64(String s) { return new String(UNB64.decode(s), StandardCharsets.UTF_8); } static byte[] hmac(String data) throws Exception { Mac m = Mac.getInstance("HmacSHA256"); m.init(new SecretKeySpec(KEY, "HmacSHA256")); return m.doFinal(data.getBytes(StandardCharsets.UTF_8)); } static String sign(String payloadJson) throws Exception { String head = b64("{\"alg\":\"HS256\",\"typ\":\"JWT\"}"), body = b64(payloadJson); return head + "." + body + "." + B64.encodeToString(hmac(head + "." + body)); } /** Tiny flat-JSON reader: enough for string and number claims. */ static String claim(String json, String name) { Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*(\"([^\"]*)\"|(-?\\d+))").matcher(json); return m.find() ? (m.group(2) != null ? m.group(2) : m.group(3)) : null; } /** WRONG: trusts the alg header supplied by the attacker. */ static String naiveVerify(String token) throws Exception { String[] p = token.split("\\.", -1); String alg = claim(unb64(p[0]), "alg"); if (!"none".equalsIgnoreCase(alg)) { byte[] expected = hmac(p[0] + "." + p[1]); if (!MessageDigest.isEqual(expected, UNB64.decode(p[2]))) throw new SecurityException("bad signature"); } return unb64(p[1]); } /** RIGHT: pins the algorithm, checks signature in constant time, then validates claims. */ static String verify(String token, String issuer, String audience, long now) throws Exception { String[] p = token.split("\\.", -1); if (p.length != 3) throw new SecurityException("malformed token"); if (!"HS256".equals(claim(unb64(p[0]), "alg"))) throw new SecurityException("unexpected alg " + claim(unb64(p[0]), "alg")); if (!MessageDigest.isEqual(hmac(p[0] + "." + p[1]), UNB64.decode(p[2]))) throw new SecurityException("signature mismatch"); String json = unb64(p[1]); if (!issuer.equals(claim(json, "iss"))) throw new SecurityException("wrong issuer " + claim(json, "iss")); if (!audience.equals(claim(json, "aud"))) throw new SecurityException("wrong audience " + claim(json, "aud")); String exp = claim(json, "exp"); if (exp == null) throw new SecurityException("missing exp"); if (now > Long.parseLong(exp) + 60) throw new SecurityException("expired " + (now - Long.parseLong(exp)) + "s ago"); // 60s skew return json; } static void attempt(String label, String token, long now) { try { System.out.printf("%-34s ACCEPTED %s%n", label, verify(token, "https://auth.example.com", "orders-api", now)); } catch (Exception e) { System.out.printf("%-34s REJECTED %s%n", label, e.getMessage()); } } public static void main(String[] args) throws Exception { long now = 1_800_000_000L; String good = sign("{\"sub\":\"alice\",\"iss\":\"https://auth.example.com\",\"aud\":\"orders-api\",\"scope\":\"orders:read\",\"exp\":" + (now + 900) + "}"); String[] parts = good.split("\\."); System.out.println("header = " + unb64(parts[0])); System.out.println("payload = " + unb64(parts[1])); System.out.println("token = " + good.substring(0, 40) + "... (" + good.length() + " chars)"); System.out.println(); attempt("1 valid token", good, now); attempt("2 same token 20 min later", good, now + 1200); attempt("3 other audience", sign("{\"sub\":\"alice\",\"iss\":\"https://auth.example.com\",\"aud\":\"billing-api\",\"exp\":" + (now + 900) + "}"), now); attempt("4 wrong issuer", sign("{\"sub\":\"alice\",\"iss\":\"https://evil.example.com\",\"aud\":\"orders-api\",\"exp\":" + (now + 900) + "}"), now); // attack: edit payload, keep old signature String forgedBody = b64("{\"sub\":\"alice\",\"iss\":\"https://auth.example.com\",\"aud\":\"orders-api\",\"scope\":\"orders:admin\",\"exp\":" + (now + 900) + "}"); String tampered = parts[0] + "." + forgedBody + "." + parts[2]; attempt("5 payload edited, old signature", tampered, now); // attack: alg=none, empty signature String none = b64("{\"alg\":\"none\",\"typ\":\"JWT\"}") + "." + forgedBody + "."; attempt("6 alg=none (pinned verifier)", none, now); try { System.out.println("7 alg=none (naive verifier) ACCEPTED " + naiveVerify(none)); } catch (Exception e) { System.out.println("7 alg=none (naive verifier) REJECTED " + e.getMessage()); } System.out.println(); System.out.println("8 payload is only encoded, not encrypted: anyone can read it"); System.out.println(" base64url decode of part 2 -> " + unb64(parts[1]).substring(0, 31) + "..."); } }

Real output (the token body is deterministic because exp is derived from a fixed "now"):

Output
header = {"alg":"HS256","typ":"JWT"} payload = {"sub":"alice","iss":"https://auth.example.com","aud":"orders-api","scope":"orders:read","exp":1800000900} token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ... (223 chars) 1 valid token ACCEPTED {"sub":"alice","iss":"https://auth.example.com","aud":"orders-api","scope":"orders:read","exp":1800000900} 2 same token 20 min later REJECTED expired 300s ago 3 other audience REJECTED wrong audience billing-api 4 wrong issuer REJECTED wrong issuer https://evil.example.com 5 payload edited, old signature REJECTED signature mismatch 6 alg=none (pinned verifier) REJECTED unexpected alg none 7 alg=none (naive verifier) ACCEPTED {"sub":"alice","iss":"https://auth.example.com","aud":"orders-api","scope":"orders:admin","exp":1800000900} 8 payload is only encoded, not encrypted: anyone can read it base64url decode of part 2 -> {"sub":"alice","iss":"https://a...
Case 5: edit scope to orders:admin, keep the signature
▶
The HMAC of the new payload no longer matches: signature mismatch. Payload edits are always caught, because verification happens over the exact transmitted bytes
Case 6 vs case 7: same forged token, two verifiers
▶
The pinned verifier rejects alg: none; the naive one reads the algorithm from the attacker-controlled header, skips verification, and returns the forged orders:admin claims. This is the real alg: none vulnerability that hit several JWT libraries
Cases 2, 3, 4: valid signature but wrong claims
▶
A properly signed token can still be unacceptable: expired, minted for another audience, or by another issuer. Signature proves integrity; claim validation proves applicability
Case 8: payload is merely encoded
▶
Anyone, including the browser's JavaScript and every proxy, can read the claims. Signing is not secrecy
Try these experiments:
  • Change KEY to the 5-byte string "abcde" and write a ten-line brute-force loop over a wordlist: weak HMAC secrets fall in seconds, which is the whole argument for random 256-bit keys.
  • Replace MessageDigest.isEqual with Arrays.equals; behaviour is identical, only timing differs. That is exactly why the bug is invisible in tests.
  • Remove the aud check and mint a token for billing-api: it is now accepted. Sibling-service token replay in 1 line.
13
Chapter Thirteen · Pitfalls

Symptoms, Causes and Fixes

SymptomLikely causeDiagnose / fix
401, WWW-Authenticate: Bearer error="invalid_token", error_description="Jwt expired at ..."Token past exp (or client clock far off)Refresh before expiry; check client/server NTP
401, Another algorithm expected, or no matching key(s) foundAlgorithm not RS256, or kid not in the JWKSSet jws-algorithms; compare token header kid with the JWKS document
401, The iss claim is not validInternal vs public issuer URLAlign issuer; add explicit issuer validator
401, The aud claim is not validToken minted for another API or for the client (ID token)Configure the IdP audience mapper; send the access token
403 insufficient_scopeValid token, missing SCOPE_xRequest the scope in the grant; check consent; decode the token
403 on admin although IdP shows roleRole claim not mappedCustom JwtAuthenticationConverter (Chapter 5)
App cannot start: connection refused to .well-known/openid-configurationEager decoder creation with IdP downUse property-driven issuer-uri (lazy) or jwk-set-uri; add retries
Browser login loops back to login pageSession cookie dropped (SameSite/secure/proxy scheme)Check forward-headers-strategy, cookie flags, [authorization_request_not_found]
Random 401s only after IdP key rotationJWKS cached without refetch, or static PEMUse JWKS; Nimbus refetches on unknown kid
Everything 401 only via the gatewayGateway strips or renames the Authorization headerTrace both sides; enable TokenRelay filter on Spring Cloud Gateway
431 Request Header Fields Too LargeToken with hundreds of group claimsTrim claims, use opaque tokens, raise server.max-http-request-header-size only as a last resort
Never

Trust claims you did not verify. Accept alg from the token. Put secrets in claims. Skip aud. Store long-lived tokens in localStorage. Ship a client secret in a SPA.

Always

Validate signature, exp, iss, aud. Keep access tokens short. Use PKCE. Rotate refresh tokens. Fetch keys from JWKS, never from token headers.

14
Chapter Fourteen · Interview Angles

Ten Questions You Will Be Asked

1. What is a JWT and what is in it?

Three base64url parts: header (alg, kid), payload (claims: iss, sub, aud, exp, scope...), signature over the first two. Signed (JWS), not encrypted: claims are readable. Trap: "JWT is secure because it is encrypted" is wrong.

2. JWT vs OAuth2 vs OIDC?

JWT = token format. OAuth2 = delegated authorisation protocol that yields access tokens. OIDC = authentication layer on OAuth2 (ID token, userinfo, discovery). A JWT can exist with none of them.

3. How does a Spring Boot API validate a JWT?

oauth2ResourceServer().jwt(): BearerTokenAuthenticationFilter to JwtAuthenticationProvider to NimbusJwtDecoder (JWKS lookup by kid, signature, exp/nbf/iss validators) to JwtAuthenticationConverter (scope to SCOPE_ authorities). Trap: audience is not checked unless you configure it.

4. Why PKCE, and for whom?

Binds the authorization code to the client that started the flow so a stolen code is useless. Mandatory for public clients; recommended for all in OAuth 2.1. The verifier never travels in the front channel; only its SHA-256 hash does.

5. Which grant for service-to-service?

Client credentials: client authenticates with its own credentials, token has no user. Cache until expiry; no refresh token. Trap: using the user's token downstream (passthrough).

6. How do you revoke a JWT?

You mostly cannot: use short TTL plus refresh rotation; add a jti denylist, token version, or opaque tokens with introspection when you need instant revocation. Rotating keys revokes everything.

7. Access token vs ID token vs refresh token?

Access token goes to the API (aud = API). ID token is for the client to learn who logged in (aud = client). Refresh token goes only to the authorization server to get new access tokens.

8. HS256 vs RS256?

HS256: one shared secret signs and verifies, so every verifier can forge. RS256: private key signs, public key (JWKS) verifies, so resource servers hold no secret and keys can rotate by kid. Trap: algorithm-confusion attack when the verifier lets the token pick the algorithm.

9. Where should an SPA keep tokens?

Not in localStorage if you can avoid it; best is a BFF that holds tokens server-side and gives the browser an HttpOnly session cookie. A cookie-held token brings CSRF back.

10. Why can a request with a valid token still get 403?

Authentication succeeded (401 would be failure) but authorisation failed: missing scope/role, wrong mapping, or method security rule on ownership. Check the authorities in the Authentication.

15
Chapter Fifteen · Summary

Cheat Sheet and Where to Go Next

NeedUseSpring piece
Protect an API with bearer tokensResource serveroauth2ResourceServer().jwt(), JwtDecoder
Map claims to permissionsAuthorities converterJwtAuthenticationConverter
Users log in with an IdPAuthorization Code + PKCE + OIDCoauth2Login(), OidcUser
Service calls serviceClient credentialsAuthorized client manager + WebClient filter
Keep users logged inRefresh tokens with rotationIdP feature; client single-flight
Browser SPA safetyBFFSpring Cloud Gateway TokenRelay
Instant revocationOpaque tokens + introspectionoauth2ResourceServer().opaqueToken()
Test authorisation rulesMockMvc post-processorsjwt(), oidcLogin(), csrf()
∑ JWT, OAuth2 & OIDC — what to remember
  • JWT is a signed format, OAuth2 is a delegation protocol, OIDC is identity on top of it; keep the three apart.
  • A resource server verifies, it does not issue: signature via JWKS plus exp, iss and (you add) aud; map scopes to SCOPE_* authorities.
  • Never let the token choose the algorithm or the key; pin both and fetch keys only from the configured JWKS.
  • Authorization Code + PKCE for anything with a user; client credentials for machines; implicit and password grants are gone.
  • Short access tokens + rotating refresh tokens are your revocation strategy; add denylists or introspection only when the business demands instant revocation.
  • Where the token lives matters: prefer a BFF to localStorage; cookies bring CSRF back.
  • Test the negative paths and keep one integration test with the real decoder.
Related pages: