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.
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.
Three questions organise the whole topic, and mixing them up is the root of most confusion in interviews and in production:
JWT is a format: signed JSON claims. Opaque random strings are an alternative format. The format says nothing about how you got it.
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.
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.
- 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: noneon 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.
Three Mechanisms: Verify, Obtain, Identify
Authorization: Bearer <token>BearerTokenAuthenticationFilter extracts the token, JwtDecoder verifies signature and claims, a converter maps claims to GrantedAuthoritys, and the usual authorisation rules runexp unless you add extra machineryYour 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.
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.)
Look inside a real token
Standard (registered) claims
| Claim | Meaning | Validate it? |
|---|---|---|
iss | Issuer: who minted the token | Yes: exact match to the one you trust |
sub | Subject: stable user/client id | Use as principal; do not use email as identity key |
aud | Audience: which API(s) the token is for | Yes: otherwise a token for API A works on API B |
exp | Expiry (seconds since epoch) | Yes (default validator, 60 s skew) |
nbf / iat | Not before / issued at | nbf checked by default; iat informational |
jti | Unique token id | Needed for a denylist / replay detection |
scope / scp | Permissions granted (OAuth2 convention, space-delimited string or array) | Mapped to SCOPE_* authorities |
Which signature algorithm?
| Algorithm | Key model | Use when | Watch out |
|---|---|---|---|
| HS256 (HMAC-SHA-256) | One shared secret signs and verifies | Single application or tightly coupled services | Every verifier can also forge; secret must be at least 256 bits of randomness |
| RS256 (RSA + SHA-256) | Private key signs, public key verifies | Default for identity providers; many resource servers | Slower signing; 2048-bit keys minimum |
| ES256 (ECDSA P-256) | Private signs, public verifies | Same as RS256 with smaller tokens and keys | Library support and key handling are slightly less uniform |
none | No signature | Never | Reject it explicitly |
Attacks that a correct validator stops
| Attack | What the attacker does | Defence |
|---|---|---|
| Payload tampering | Edits scope/sub, keeps the old signature | Signature verification (always happens before claims are trusted) |
alg: none | Header says "no signature needed" | Never choose the algorithm from the token; pin it per key (jwsAlgorithms, MacAlgorithm) |
| Algorithm confusion | Changes RS256 to HS256 and signs with the public key as if it were an HMAC secret | Pin the expected algorithm to the key type; Nimbus JWSKeySelector rejects mismatched pairs |
Key injection via jku / x5u / jwk headers | Points the verifier at an attacker-controlled key | Take keys only from the configured JWKS URI; Spring/Nimbus do not follow these headers |
| Wrong audience / issuer | Replays a token minted for another service or another tenant | Validate aud and iss (Chapter 4) |
| Expired token replay | Reuses a stolen token | exp check plus short TTL; HTTPS only |
| Weak HMAC secret | Brute-forces "secret" offline from one captured token | Random 32+ bytes from a secret manager; prefer asymmetric keys |
- 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'sJwtDecoder. - 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 ...orJwt 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.
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.
| Property | What Boot builds | Validated automatically | When |
|---|---|---|---|
jwt.issuer-uri | NimbusJwtDecoder from OIDC/OAuth discovery (/.well-known/openid-configuration or the RFC 8414 endpoint), lazily on first request | Signature, exp, nbf, iss equals the issuer-uri | Normal case with an IdP |
jwt.jwk-set-uri | Decoder using that JWKS URL; no discovery | Signature, exp, nbf (not iss) | IdP without discovery; add an issuer validator yourself |
jwt.public-key-location | Decoder with one static PEM public key | Signature, exp, nbf | Tokens signed by an app you control; no rotation |
jwt.audiences | Adds an aud must-contain validator | aud | Combine with any of the above |
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.
What a failed request looks like
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.
- Issuer mismatch (
An error occurred while attempting to decode the Jwt: The iss claim is not valid): the token'sissis the public hostname (https://auth.example.com) but the API reaches the IdP over an internal hostname inissuer-uri. Usejwk-set-urifor 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.withIssuerLocationandJwtDecoders.fromIssuerLocationcontact the IdP immediately; the property-drivenissuer-urisetup 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 stripsAuthorization. Enablelogging.level.org.springframework.security=TRACEand look forBearerTokenAuthenticationFilter. - Algorithm mismatch: IdP signs with
ES256orPS256but the default expects RS256:Another algorithm expected, or no matching key(s) found. Setjws-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.
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.
| IdP | Where permissions live in the token | You need |
|---|---|---|
| Generic OAuth2 / Spring Authorization Server | scope (string) | Defaults |
| Azure AD / Entra ID | scp (delegated) and roles (app roles) | Default handles scp; map roles yourself |
| Keycloak | scope plus realm_access.roles and resource_access.<client>.roles (nested JSON) | Custom converter |
| Okta / Auth0 | scp or scope; roles often via a custom claim or permissions array | Check the actual token, then configure the claim name |
In controllers the principal is a Jwt, so you can read claims directly without touching the decoder again:
- All requests 403 although the user "has the role": roles are in a custom claim, but the default converter only reads
scope. Printauthentication.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 isROLE_ADMIN, givingROLE_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.
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.
- 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/tokenwith 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
expiresAtwas omitted: Nimbus accepts a token withoutexpunless you add a validator that requires it.
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.
| Grant | Client type / situation | User involved? | Status |
|---|---|---|---|
| Authorization Code + PKCE | Web apps, SPAs, mobile and desktop apps | Yes (browser redirect) | The default for anything with a user |
| Client Credentials | Backend service calling another service | No: token represents the client itself | Standard for machine-to-machine |
| Refresh Token | Any client that received a refresh token | Not at that moment | Used to renew access tokens |
| Device Authorization | TVs, CLIs, devices without a browser | Yes (on a second device) | Niche but standard |
| Implicit | Old SPAs | Yes | Removed in OAuth 2.1: tokens leaked via URL fragment |
| Resource Owner Password | Legacy first-party apps | Client sees the password | Removed 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.
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.
- 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
RestTemplatecode 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
audis your API. Prefer token exchange (RFC 8693) or client credentials with explicit audience.
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.
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.
Spring Boot as the OAuth2 client (login with an IdP)
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.
redirect_uri_mismatch/invalid_redirect_urifrom the IdP: the registered URI must match character for character, including scheme. Behind a TLS-terminating proxy the app buildshttp://URLs unless you setserver.forward-headers-strategy=framework(ornative) and the proxy sendsX-Forwarded-Proto.[authorization_request_not_found]after the redirect back: the session holding the stored request (andstate) did not reach the callback: cookieSameSite=Strict, a different node without session replication, or the user took too long. UseLaxand 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
statevalidation 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).
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 token | Access token | Refresh token | |
|---|---|---|---|
| Audience | The client (aud = client_id) | The resource server (aud = API) | The authorization server |
| Purpose | Prove who authenticated, when and how (sub, auth_time, nonce, acr) | Authorise API calls (scope) | Obtain new access tokens |
| Format | Always a JWT | JWT or opaque | Opaque, usually |
| Who may read it | The client | The API (client should treat as opaque) | Only the authorization server |
| Send it to your API? | Never | Yes, as Bearer | Never |
scope=openid profile emailopenid scope is what switches the protocol from plain OAuth2 to OIDC; profile and email select claim groupsid_tokenOidcAuthorizationCodeAuthenticationProvider validates it: signature from the IdP's JWKS, iss, aud contains the client id, exp, and nonce equals the one stored in the session/userinfo with the access token and merges the result into the OidcUser; the sub from both must match- 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
audis the client, not the API. With audience validation on, the API rightly rejects it with 401. - Using
emailas the user key: emails change and can be unverified (email_verified=false). Key your users on(iss, sub). getClaimAsStringList("groups")returnsnullwhen 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.
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.
Where does the browser keep the token?
| Storage | XSS impact | CSRF | Verdict |
|---|---|---|---|
localStorage / sessionStorage | Any injected script reads and exfiltrates the token; attacker uses it from anywhere | Not applicable (explicit header) | Common, convenient, highest theft risk |
| In-memory JS variable | Script can still read it while the tab is open; gone on refresh | Not applicable | Better; needs silent re-login on reload |
| HttpOnly + Secure + SameSite cookie holding the JWT | Cannot be read, but injected script can still make authenticated requests | Returns: needs SameSite and CSRF defence | Reasonable if CSRF handled |
| BFF: server holds tokens, browser has a session cookie | Tokens cannot be stolen; requests still forgeable by XSS | Session CSRF protection applies | Recommended for browser apps |
Revocation options when "stateless" is not enough
| Approach | How | Cost |
|---|---|---|
| Short TTL only | Accept up to 15 minutes of residual access | None; the default |
Denylist by jti / sub | A validator checks a fast store (Redis) for revoked ids; entries expire with the token | A lookup per request; you are back to partial state |
| Opaque tokens + introspection | oauth2ResourceServer(o -> o.opaqueToken(...)) calls the IdP's /introspect (RFC 7662) each time (cacheable briefly) | Network hop per request; instant revocation |
| Key rotation | Remove the signing key: everything becomes invalid | Nuclear option for key compromise |
| Version claim | User row has tokenVersion; tokens carry it; mismatch rejects | A DB read per request; or cache with short TTL |
- Refresh token in
localStoragewith 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
Authorizationheaders in a gateway or APM: bearer tokens end up readable by everyone with log access. Mask them.
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.
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"))).
- 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.
@WebMvcTestfails withNo qualifying bean of type 'JwtDecoder'(or the issuer-uri is fetched at startup): provide a mockJwtDecoderor 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.
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.
Real output (the token body is deterministic because exp is derived from a fixed "now"):
scope to orders:admin, keep the signaturesignature mismatch. Payload edits are always caught, because verification happens over the exact transmitted bytesalg: 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- Change
KEYto 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.isEqualwithArrays.equals; behaviour is identical, only timing differs. That is exactly why the bug is invisible in tests. - Remove the
audcheck and mint a token forbilling-api: it is now accepted. Sibling-service token replay in 1 line.
Symptoms, Causes and Fixes
| Symptom | Likely cause | Diagnose / 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) found | Algorithm not RS256, or kid not in the JWKS | Set jws-algorithms; compare token header kid with the JWKS document |
401, The iss claim is not valid | Internal vs public issuer URL | Align issuer; add explicit issuer validator |
401, The aud claim is not valid | Token minted for another API or for the client (ID token) | Configure the IdP audience mapper; send the access token |
403 insufficient_scope | Valid token, missing SCOPE_x | Request the scope in the grant; check consent; decode the token |
| 403 on admin although IdP shows role | Role claim not mapped | Custom JwtAuthenticationConverter (Chapter 5) |
App cannot start: connection refused to .well-known/openid-configuration | Eager decoder creation with IdP down | Use property-driven issuer-uri (lazy) or jwk-set-uri; add retries |
| Browser login loops back to login page | Session cookie dropped (SameSite/secure/proxy scheme) | Check forward-headers-strategy, cookie flags, [authorization_request_not_found] |
| Random 401s only after IdP key rotation | JWKS cached without refetch, or static PEM | Use JWKS; Nimbus refetches on unknown kid |
| Everything 401 only via the gateway | Gateway strips or renames the Authorization header | Trace both sides; enable TokenRelay filter on Spring Cloud Gateway |
| 431 Request Header Fields Too Large | Token with hundreds of group claims | Trim claims, use opaque tokens, raise server.max-http-request-header-size only as a last resort |
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.
Validate signature, exp, iss, aud. Keep access tokens short. Use PKCE. Rotate refresh tokens. Fetch keys from JWKS, never from token headers.
Ten Questions You Will Be Asked
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
Cheat Sheet and Where to Go Next
| Need | Use | Spring piece |
|---|---|---|
| Protect an API with bearer tokens | Resource server | oauth2ResourceServer().jwt(), JwtDecoder |
| Map claims to permissions | Authorities converter | JwtAuthenticationConverter |
| Users log in with an IdP | Authorization Code + PKCE + OIDC | oauth2Login(), OidcUser |
| Service calls service | Client credentials | Authorized client manager + WebClient filter |
| Keep users logged in | Refresh tokens with rotation | IdP feature; client single-flight |
| Browser SPA safety | BFF | Spring Cloud Gateway TokenRelay |
| Instant revocation | Opaque tokens + introspection | oauth2ResourceServer().opaqueToken() |
| Test authorisation rules | MockMvc post-processors | jwt(), oidcLogin(), csrf() |
- 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,issand (you add)aud; map scopes toSCOPE_*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.
- Previous: Spring Security Architecture — the filter chain, SecurityContext, CSRF and CORS this page builds on.
- Configuration & Profiles — keeping client secrets and issuer URLs out of the repository.
- Testing Spring Applications — slice tests, Testcontainers Keycloak and security tests.
- REST Controllers & the MVC Request Path — error bodies for 401/403 and CORS for browser clients.
- Interview Q&A — more security questions in the full question bank.