1
0

Spring Security 7.1 JWT authentication on Spring Boot 4.1

Runnable companion for https://ankurm.com/spring-security-7-1-jwt-authentication-guide/

- login -> token issue -> OncePerRequestFilter -> SecurityContext, end to end
- HS256 and RS256 variants (RS256 publishes a real JWKS endpoint)
- the same API secured by the built-in oauth2ResourceServer().jwt(), for comparison
- 11 documentation chapters under docs/, interlinked with the code
- docs/output/ is real captured output, regenerated by scripts/run-all.sh
- 13 passing tests pinning the 401-vs-403 contract and the CSRF failure

Verified against Spring Boot 4.1.1, Spring Security 7.1.1, JDK 25.0.4.1.
This commit is contained in:
2026-08-22 06:22:25 +00:00
commit 4a8dab6739
57 changed files with 4339 additions and 0 deletions

97
docs/01-architecture.md Normal file
View File

@@ -0,0 +1,97 @@
# 01 — Architecture
[← README](../README.md) · [next: filter chain and ordering →](02-filter-chain-and-ordering.md)
There are two paths through this application, and confusing them is the source of most
JWT bugs. The **login path** runs once and is stateful in the only sense that matters:
it sees a password. The **request path** runs on every subsequent call and sees nothing
but a string.
## The login path
```
POST /api/auth/login {"username":"alice","password":"..."}
|
v
AuthController <-- the ONLY place a password is read
|
| authenticationManager.authenticate(
| UsernamePasswordAuthenticationToken.unauthenticated(user, pass))
v
ProviderManager
|
v
DaoAuthenticationProvider
| loadUserByUsername -> UserDetails
| passwordEncoder.matches(raw, encoded)
v
Authentication (authenticated=true, authorities=[ROLE_USER, SCOPE_profile:read])
|
v
TokenService.issueAccessToken(authentication)
| JwtClaimsSet: iss aud sub jti iat nbf exp scope roles token_type
| NimbusJwtEncoder.encode(...)
v
200 {"accessToken":"eyJ...","refreshToken":"eyJ...","tokenType":"Bearer",...}
```
Note what does **not** happen: no session is created, no `SecurityContext` is saved, no
cookie is set. The `Authentication` object built here is used to fill in claims and is
then discarded.
## The request path
```
GET /api/me
Authorization: Bearer eyJ...
|
v
FilterChainProxy ---------------------------------------------+
| |
| 1 DisableEncodeUrlFilter |
| 2 WebAsyncManagerIntegrationFilter |
| 3 SecurityContextHolderFilter loads context |
| 4 HeaderWriterFilter |
| 5 JwtAuthenticationFilter <-- ours |
| resolve Bearer token |
| jwtDecoder.decode(token) |
| verify signature |
| exp / nbf (+/- 60s skew), iss, aud |
| token_type == "access", jti not revoked |
| JwtAuthenticationToken -> SecurityContext |
| 6 RequestCacheAwareFilter |
| 7 SecurityContextHolderAwareRequestFilter |
| 8 AnonymousAuthenticationFilter |
| 9 SessionManagementFilter |
| 10 ExceptionTranslationFilter catches what follows |
| 11 AuthorizationFilter permitAll / hasRole |
| |
+--------------------------------------------------------+
|
v
DispatcherServlet -> @PreAuthorize -> controller
```
That list is not from memory. It is printed by `GET /api/public/filters`, which reads
`FilterChainProxy.getFilterChains()` at runtime — see
[`FilterChainReport`](../src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java)
and step 19 of [`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt).
## Where each concern lives
| concern | class | doc |
|---|---|---|
| password check | [`AppUsers`](../src/main/java/com/ankurm/jwtauth/config/AppUsers.java) + `DaoAuthenticationProvider` | — |
| token minting | [`TokenService`](../src/main/java/com/ankurm/jwtauth/auth/TokenService.java) | [05](05-hs256-vs-rs256.md) |
| token verifying | [`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) | [02](02-filter-chain-and-ordering.md) |
| claim validation | [`JwtValidatorFactory`](../src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java) | [07](07-edge-cases.md) |
| key material | [`Hs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) / [`Rs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) | [05](05-hs256-vs-rs256.md) |
| authorization rules | [`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) | [03](03-401-vs-403.md) |
| revocation | [`RevokedTokenStore`](../src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java) | [07](07-edge-cases.md) |
## The one-sentence version
A JWT deployment is an **issuer** that trades a password for a signed claims set, and a
**verifier** that trades a signed claims set for an `Authentication` — and every failure
mode in this repository comes from one of the two doing slightly less checking than the
other assumed.

View File

@@ -0,0 +1,157 @@
# 02 — Filter chain and ordering
[← architecture](01-architecture.md) · [next: 401 vs 403 →](03-401-vs-403.md)
## The rule
`FilterChainProxy` runs a fixed, sorted list. Your filter has to sit **after** the
context is loaded and **before** the decision is made. That leaves exactly one useful
region, and `UsernamePasswordAuthenticationFilter` is the conventional landmark for it:
```java
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
```
Spring Security does not care that the landmark filter is absent from your chain
(`formLogin` is disabled here). The ordering is by *position in a registry*, not by the
presence of a neighbour.
## The live chain
From `GET /api/public/filters` on the `hs256` profile — this is
[real output](output/curl-transcript-hs256.txt), step 19:
```
1 DisableEncodeUrlFilter
2 WebAsyncManagerIntegrationFilter
3 SecurityContextHolderFilter
4 HeaderWriterFilter
5 JwtAuthenticationFilter <-- ours
6 RequestCacheAwareFilter
7 SecurityContextHolderAwareRequestFilter
8 AnonymousAuthenticationFilter
9 SessionManagementFilter
10 ExceptionTranslationFilter
11 AuthorizationFilter
```
With the `resourceserver` profile the same slot is held by the framework's own filter,
and two more appear:
```
5 LogoutFilter
6 OAuth2ProtectedResourceMetadataFilter <-- new in Spring Security 7
7 BearerTokenAuthenticationFilter <-- theirs
```
`OAuth2ProtectedResourceMetadataFilter` is why every 401 in this repository carries
`resource_metadata="…/.well-known/oauth-protected-resource"` — RFC 9728. See
[doc 11](11-spring-security-7-changes.md).
## Four ways to place it wrong
### 1. After `AuthorizationFilter`
```java
.addFilterAfter(jwtFilter, AuthorizationFilter.class); // wrong
```
Authorization has already run and already answered 401. Your filter authenticates a
request whose response is committed. Symptom: **every protected endpoint 401s no matter
how good the token is.**
### 2. Before `SecurityContextHolderFilter`
The context holder has not been initialised for this request yet. Whatever you write is
either overwritten or leaks into the next request on the same pooled thread. Symptom:
**intermittent wrong-principal bugs under load** — the worst kind.
### 3. Registered twice
A `OncePerRequestFilter` that is also a `@Component` gets picked up by Boot's servlet
auto-registration *and* inserted into the security chain. It then runs on every request,
including ones no `SecurityFilterChain` matches.
```java
// If the filter must be a bean, suppress the servlet registration:
@Bean
FilterRegistrationBean<JwtAuthenticationFilter> disableAutoRegistration(
JwtAuthenticationFilter filter) {
FilterRegistrationBean<JwtAuthenticationFilter> reg = new FilterRegistrationBean<>(filter);
reg.setEnabled(false);
return reg;
}
```
This repository sidesteps it: `JwtAuthenticationFilter` is constructed with `new` inside
[`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) and is
never a bean.
### 4. Extending `GenericFilterBean` instead of `OncePerRequestFilter`
`OncePerRequestFilter` guards against re-entry via a request attribute. Without it, a
`FORWARD` to an error page, an async dispatch, or a nested `RequestDispatcher` runs
authentication a second time. Symptom: **`/error` responses lose the principal**, or
authentication side effects fire twice.
## The five details inside the filter
From
[`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java):
**1. No token is not an error.** Continue the chain. This is what keeps `permitAll()`
endpoints reachable.
```java
if (token == null) {
filterChain.doFilter(request, response);
return;
}
```
**2. A bad token *is* an error, and the chain stops.** The tempting alternative —
catching the exception and continuing anonymously — means a forged token produces a 403
on a protected endpoint and a silent 200 on a public one. Neither says "your token is
invalid", so the client retries forever with the same bad token.
**3. Use `SecurityContextHolderStrategy`, not the static setters.**
```java
private final SecurityContextHolderStrategy contextHolderStrategy =
SecurityContextHolder.getContextHolderStrategy();
```
Straight `SecurityContextHolder.setContext(...)` bypasses a strategy the application may
have swapped in — the usual reason is `DelegatingSecurityContextHolderStrategy` for
observability or virtual-thread propagation.
**4. Clear the context on failure.** Servlet containers pool threads. A `ThreadLocal`
left populated is a cross-request principal leak.
**5. Wrap decode failures in something that carries a `BearerTokenError`.**
```java
return new InvalidBearerTokenException(ex.getMessage(), ex);
```
`BearerTokenAuthenticationEntryPoint` only writes `error="invalid_token"` into
`WWW-Authenticate` when the exception carries a `BearerTokenError`. Wrap a `JwtException`
in a plain `AuthenticationServiceException` and the client gets a bare
`WWW-Authenticate: Bearer realm="…"` with no reason at all. Compare steps 11 and 13 of
the [transcript](output/curl-transcript-hs256.txt).
## `shouldNotFilter`
Skipping work for paths that can never carry a token is fine; skipping *authentication*
is not.
```java
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return request.getServletPath().startsWith("/actuator/health");
}
```
Do not use this to "make an endpoint public" — that belongs in `authorizeHttpRequests`.
A `shouldNotFilter` exemption also skips the filter for a request that legitimately
*carries* a token, so the endpoint silently loses the principal.

148
docs/03-401-vs-403.md Normal file
View File

@@ -0,0 +1,148 @@
# 03 — 401 vs 403
[← filter chain](02-filter-chain-and-ordering.md) · [next: CSRF vs permitAll →](04-csrf-permitall-403.md)
## The one-line rule
> **401** — I do not know who you are.
> **403** — I know who you are, and you may not do this.
Everything else follows from that. The confusion comes from the fact that Spring
Security decides which one to send in a filter your token never reaches, using an
`Authentication` your filter may or may not have installed.
## The actual decision
`ExceptionTranslationFilter` wraps the rest of the chain and catches exactly two
exception types:
```java
try {
filterChain.doFilter(request, response); // AuthorizationFilter runs in here
}
catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication(); // -> AuthenticationEntryPoint -> 401
}
else {
accessDenied(); // -> AccessDeniedHandler -> 403
}
}
```
Read the condition carefully. `AuthorizationFilter` throws `AccessDeniedException` for
*both* "no credentials" and "wrong credentials". The 401/403 split is decided by
`authenticated` — which is false when the current `Authentication` is anonymous or
`null`. So:
| you sent | context holds | `AuthorizationFilter` | translated to |
|---|---|---|---|
| nothing | `AnonymousAuthenticationToken` | `AccessDeniedException` | **401** |
| a valid token, insufficient authority | `JwtAuthenticationToken` | `AccessDeniedException` | **403** |
| an invalid token | *(filter cleared it and stopped)* | never reached | **401** |
The third row is the one people get wrong. An invalid token must not be allowed to fall
through to anonymous — otherwise a forged token on an admin endpoint yields 403, which
tells the caller "your token is fine, your role is not". It is not fine.
## What the wire looks like
All from [`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt).
**No token** — bare challenge, no error code, because there is nothing wrong with a
token that was never presented:
```
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo",
resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
```
**Tampered token**`invalid_token`, per RFC 6750 §3.1:
```
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token",
error_description="An error occurred while attempting to decode the Jwt:
Signed JWT rejected: Invalid signature",
error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", ...
```
**Valid token, missing role** — note this is a **403** that still carries a
`WWW-Authenticate` header:
```
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope",
error_description="The request requires higher privileges than provided by
the access token.",
error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
```
## Wiring it
```java
.exceptionHandling(ex -> ex
.authenticationEntryPoint(bearerTokenEntryPoint()) // 401
.accessDeniedHandler(bearerTokenAccessDeniedHandler()) // 403
);
@Bean
AuthenticationEntryPoint bearerTokenEntryPoint() {
BearerTokenAuthenticationEntryPoint entryPoint = new BearerTokenAuthenticationEntryPoint();
entryPoint.setRealmName("jwt-auth-demo");
return entryPoint;
}
@Bean
AccessDeniedHandler bearerTokenAccessDeniedHandler() {
return new BearerTokenAccessDeniedHandler();
}
```
> **Package trap.** `BearerTokenAuthenticationEntryPoint` is in
> `org.springframework.security.oauth2.server.resource.web`, while
> `BearerTokenAccessDeniedHandler` is one level deeper in `…resource.web.access` and
> `BearerTokenAuthenticationFilter` is in `…resource.web.authentication`. Three siblings,
> three packages. Auto-import will pick the wrong one.
The same `AuthenticationEntryPoint` bean is passed to `JwtAuthenticationFilter`, so a
401 looks identical whether it came from the filter or from `ExceptionTranslationFilter`.
Two different 401 shapes for the same logical failure is a needless client bug.
## Login failures are a third path
`POST /api/auth/login` calls `AuthenticationManager` **from a controller**, so a
`BadCredentialsException` is an ordinary MVC exception by the time anything
security-shaped could see it. `AuthenticationEntryPoint` is never invoked. It needs its
own `@RestControllerAdvice` — see
[`ApiExceptionHandler`](../src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java):
```java
@ExceptionHandler({BadCredentialsException.class, LockedException.class, DisabledException.class})
public ProblemDetail onAuthenticationFailure(AuthenticationException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
problem.setTitle("Authentication failed");
problem.setDetail("Invalid username or password");
return problem;
}
```
Every branch answers with the **same body**. `LockedException` and
`BadCredentialsException` producing different messages is a user-enumeration oracle:
"account locked" confirms the username exists. `AuthenticationFlowTests` pins this by
byte-comparing the two responses.
Without this handler the default is a 500 or a 403, depending on your error handling —
neither of which is what a client should see for a wrong password.
## Symptom → cause
| symptom | cause |
|---|---|
| 403 on every endpoint, even with a good token | CSRF — see [doc 04](04-csrf-permitall-403.md) |
| 401 on every endpoint, even with a good token | filter after `AuthorizationFilter`, or a decoder pinned to the wrong algorithm |
| 403 where you expected 401 | invalid token silently falling through to anonymous |
| 401 where you expected 403 | filter cleared the context on a *valid* token — usually a validator throwing |
| 500 on a wrong password | no `@RestControllerAdvice` for `AuthenticationException` |
| 403 with `WWW-Authenticate: Bearer` and no error code | not a token problem — `CsrfFilter` delegating to your bearer handler |

View File

@@ -0,0 +1,145 @@
# 04 — Why `permitAll()` still returns 403
[← 401 vs 403](03-401-vs-403.md) · [next: HS256 vs RS256 →](05-hs256-vs-rs256.md)
This is the single most reported "Spring Security is broken" bug, and it is not a bug.
## The symptom
```java
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.anyRequest().authenticated())
```
```
POST /api/auth/login
Content-Type: application/json
{"username":"alice","password":"alice-password"}
HTTP 403
WWW-Authenticate: Bearer
```
Reproduce it: `./scripts/run.sh hs256,csrfon` then `./scripts/csrf-demo.sh`. Captured in
[`csrf-vs-permitall.txt`](output/csrf-vs-permitall.txt).
Note the response body is empty and the header mentions `Bearer` — which sends people
hunting for a token problem. There is no token problem.
## The cause, in one number
`CsrfFilter` is filter **5**. `AuthorizationFilter` — the only filter in the entire chain
that has ever heard the word `permitAll` — is filter **12**.
From [`csrf-trace.txt`](output/csrf-trace.txt), a real `TRACE` log of that exact request:
```
DEBUG FilterChainProxy : Securing POST /api/auth/login
TRACE FilterChainProxy : Invoking DisableEncodeUrlFilter (1/12)
TRACE FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/12)
TRACE FilterChainProxy : Invoking SecurityContextHolderFilter (3/12)
TRACE FilterChainProxy : Invoking HeaderWriterFilter (4/12)
TRACE FilterChainProxy : Invoking CsrfFilter (5/12)
TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [X-XSRF-TOKEN] request header
TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [_csrf] request parameter
DEBUG CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/auth/login
```
The chain stops at 5 of 12. Filters 6 through 12 never run. `permitAll()` is a statement
about filter 12, and filter 12 is not reached, so `permitAll()` is not a statement about
this request at all.
`CsrfFilter` throws an `AccessDeniedException` subtype — `MissingCsrfTokenException` when
the repository had no stored token (the case above, with a fresh client), or
`InvalidCsrfTokenException` when one existed and did not match. Both log the same
"Invalid CSRF token found" line, so the message does not distinguish them. Neither
reaches `ExceptionTranslationFilter`, which sits at position 11 — downstream of the
filter that threw. `CsrfFilter` has its own `AccessDeniedHandler` and answers directly.
```
1 2 3 4 5 6 7 8 9 10 11 12
|----|----|----|----|----X .
| |
CsrfFilter AuthorizationFilter
403, chain stops knows about permitAll()
never invoked
```
## Why the header says `Bearer`
`CsrfConfigurer` reuses the `AccessDeniedHandler` you configured under
`exceptionHandling()`. Configure `BearerTokenAccessDeniedHandler` for your API — correct
for real authorization failures — and a CSRF rejection is rendered by it too. You get a
403 with `WWW-Authenticate: Bearer` and, because there is no OAuth 2.0 error in context,
no `error=` parameter. A bare `WWW-Authenticate: Bearer` on a 403 is the fingerprint of a
CSRF rejection, not a scope problem.
## Three fixes, in order of preference
### 1. Turn CSRF off — correct for a bearer-token API
```java
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
```
CSRF exists because browsers attach **ambient credentials** — cookies, HTTP Basic,
client certificates — to cross-origin requests automatically. A browser does not
automatically attach an `Authorization: Bearer` header. Your JavaScript has to read the
token out of memory and set it, and same-origin policy stops another site's script from
doing that. No ambient credential, nothing to forge.
The condition is strict, and both halves matter:
- the token is **never** in a cookie, and
- **no** cookie- or session-based authentication remains on any chain.
If you store the JWT in a cookie "for convenience", it *is* an ambient credential, and
you have re-created CSRF exactly. Disabling CSRF at that point is a real vulnerability.
See [doc 07 — token storage](07-edge-cases.md#token-storage).
### 2. Exempt the API, keep it for the browser chain
For a mixed application — a server-rendered admin UI plus a token API:
```java
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
```
Better still, split into two `SecurityFilterChain` beans with `securityMatcher()`, so the
API chain has no `CsrfFilter` at all rather than one that is told to look away.
### 3. Actually send the token
If the client is a browser SPA that keeps a session:
```java
.csrf(csrf -> csrf.spa()) // Spring Security 7.0+
```
`spa()` bundles `CookieCsrfTokenRepository`, BREACH protection via
`XorCsrfTokenRequestAttributeHandler`, and correct deferred-token loading. The client
reads `XSRF-TOKEN` and echoes it in `X-XSRF-TOKEN`.
Verified: the method does **not** exist in 6.4.7 or 6.5.1 and does exist in 7.0.0, so on
6.x you configure those three pieces individually.
## Related traps
**Only unsafe methods break.** `CsrfFilter` ignores `GET`, `HEAD`, `OPTIONS`, `TRACE`.
A `GET` on a `permitAll()` path works fine, which is why the failure looks intermittent
and endpoint-specific. Step B of the [demo output](output/csrf-vs-permitall.txt) shows
the same profile answering 200 to a `GET`.
**A 403 with an empty body on `POST` only** is CSRF until proven otherwise.
**`MockMvc` hides it.** `spring-security-test`'s `.with(csrf())` post-processor makes the
test pass while production fails. `CsrfBreaksPermitAllTests` deliberately has both: one
test asserting the 403 without it, one asserting the 200 with it.
**CORS is not CSRF.** A preflight `OPTIONS` failing is a `CorsFilter` problem. Spring
Security 7.1 added `PreFlightRequestFilter` CORS support ([gh-18926]); if preflight
requests are being rejected, look there, not at CSRF.
[gh-18926]: https://github.com/spring-projects/spring-security/issues/18926

180
docs/05-hs256-vs-rs256.md Normal file
View File

@@ -0,0 +1,180 @@
# 05 — HS256 vs RS256
[← CSRF vs permitAll](04-csrf-permitall-403.md) · [next: SecurityContext →](06-securitycontext-and-statelessness.md)
## The distinction that matters
| | HS256 | RS256 |
|---|---|---|
| key | one shared secret | private/public pair |
| who can **verify** | anyone who can sign | anyone at all |
| who can **sign** | anyone who can verify | only the private-key holder |
| signature size | 32 bytes | 256 bytes (RSA-2048) |
| sign cost | ~microseconds | ~100× HMAC |
| verify cost | ~microseconds | ~10× HMAC |
| key distribution | copy the secret everywhere | publish a JWKS URL |
The performance column is not the deciding one. **The deciding question is whether the
set of services that verify tokens is the same as the set you trust to mint them.**
With HS256 the answer is forced: verifying requires the signing secret, so every
verifier is also an issuer. One compromised read-only reporting service can mint an
admin token. If the answer is "no", you need RS256 (or ES256), and no amount of secret
rotation substitutes.
## HS256
```java
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
@Bean JwtEncoder jwtEncoder() {
return NimbusJwtEncoder.withSecretKey(this.secretKey)
.algorithm(MacAlgorithm.HS256)
.build();
}
@Bean JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withSecretKey(this.secretKey)
.macAlgorithm(MacAlgorithm.HS256)
.build();
}
```
Three things to notice.
**The builder method is `algorithm(..)`, not `jwsAlgorithm(..)`.** `NimbusJwtEncoder`'s
`SecretKeyJwtEncoderBuilder` (added in Spring Security 7.0) exposes exactly two methods:
`algorithm(MacAlgorithm)` and `jwkPostProcessor(Consumer<OctetSequenceKey.Builder>)`.
The decoder side, confusingly, *does* use `macAlgorithm(..)` / `signatureAlgorithm(..)`.
**The secret must be ≥ 256 bits.** Nimbus enforces the JWA rule that an HMAC key is at
least as long as its digest; a shorter one throws `KeyLengthException` at encoder
construction, not at first request.
[`Hs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) fails
fast with a clearer message. A short secret is also brute-forceable offline — the
attacker has the ciphertext, the plaintext, and unlimited attempts.
**A passphrase is not a key.** `"changeit-changeit-changeit-change"` is 32 bytes and
passes the length check while having perhaps 40 bits of entropy. Generate it:
```bash
openssl rand -base64 48
```
## RS256
```java
@Bean JwtEncoder jwtEncoder() {
return NimbusJwtEncoder.withKeyPair(this.publicKey, this.privateKey)
.algorithm(SignatureAlgorithm.RS256)
.jwkPostProcessor(jwk -> jwk.keyID("demo-rsa-2026-08"))
.build();
}
@Bean JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.publicKey)
.signatureAlgorithm(SignatureAlgorithm.RS256)
.build();
}
```
There is **no `keyId(..)` method** on the builder. The `kid` is set by post-processing
the Nimbus JWK builder — `jwkPostProcessor(jwk -> jwk.keyID(...))`. Without a `kid`,
key rotation is impossible: the verifier cannot tell which of two published keys to try.
### Publishing the public half
[`Rs256KeyConfig.JwkSetEndpoint`](../src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java)
serves a real JWK Set. From [`rs256-demo.txt`](output/rs256-demo.txt):
```json
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "demo-rsa-2026-08",
"n": "5NEDQPQW0Gz6iR5-UNl7J7660_Psd5q1f5VamK9KTS9f6YhPPIG8mfi6zWe8Xmxx..."
}
]
}
```
`n` and `e` only — the public modulus and exponent. A private key would additionally
carry `d`, `p`, `q`. **Audit for those letters** before exposing a JWKS endpoint: leaking
`d` hands over the signing key.
A separate resource server then needs no key material at all:
```java
@Bean JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withJwkSetUri("https://issuer.example.com/.well-known/jwks.json")
.build();
}
```
or, in `application.yaml`:
```yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://issuer.example.com
```
`issuer-uri` fetches OIDC discovery **at startup** and fails the context if the issuer is
unreachable. `jwk-set-uri` fetches lazily. In an environment where the issuer boots
alongside the resource server, `issuer-uri` produces a startup-ordering dependency that
`jwk-set-uri` does not.
## Rotation
RS256 rotates without downtime because the verifier can hold several keys:
1. Generate a new pair with a new `kid`.
2. Publish **both** public keys in the JWK Set.
3. Wait for caches to refresh (`NimbusJwtDecoder` caches, and honours `Cache-Control`).
4. Switch the issuer to sign with the new `kid`.
5. Wait one full access-token TTL, so no live token references the old key.
6. Remove the old key from the JWK Set.
HS256 has no equivalent. The secret is symmetric, so steps 2 and 4 are the same step, and
every token signed with the old secret is invalid the moment you rotate. The workarounds
are a decoder that tries both secrets during a window, or a hard cutover that logs
everyone out.
## Algorithm confusion — pin the algorithm
The classic JWT attack: take an RS256 token, change the header to `alg: HS256`, and sign
it with the **public key as the HMAC secret**. A verifier that reads `alg` from the token
and looks up "the key" will verify it, because the public key is public.
Spring Security is not vulnerable by default — `NimbusJwtDecoder.withPublicKey(...)`
defaults to RS256 and will not switch families. But pin it anyway, because the intent
should be in the code rather than in a default:
```java
NimbusJwtDecoder.withPublicKey(publicKey)
.signatureAlgorithm(SignatureAlgorithm.RS256)
.build();
```
The related `alg: none` attack is a non-issue here — Nimbus refuses unsigned JWTs for a
configured verifier — but the same principle applies: never let the token choose how it
is verified.
## Which to pick
**HS256** — one service issues and consumes its own tokens; the secret never leaves that
deployment unit; you want the smallest tokens and the cheapest verification. A monolith.
**RS256 / ES256** — more than one service verifies; a third party verifies; you need
rotation without a flag day; compliance requires the signing key in an HSM or KMS. Any
real microservice estate.
ES256 deserves a mention: same asymmetric properties as RS256 with 64-byte signatures
instead of 256, and Spring Security supports it out of the box via
`NimbusJwtEncoder.withKeyPair(ECPublicKey, ECPrivateKey)`. If you are choosing today and
your clients can handle EC, it is the better default.

View File

@@ -0,0 +1,130 @@
# 06 — SecurityContext and statelessness
[← HS256 vs RS256](05-hs256-vs-rs256.md) · [next: edge cases →](07-edge-cases.md)
## What "stateless" actually requires
Three separate settings, and setting only one of them is the usual mistake.
```java
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityContext(context -> context
.securityContextRepository(new NullSecurityContextRepository()))
.csrf(csrf -> csrf.disable())
```
`SessionCreationPolicy.STATELESS` stops **Spring Security** from creating or using a
session. It does not stop your application: any `request.getSession()`, any
`@SessionAttributes`, any Spring Session integration still creates one. And it does not
stop the `SecurityContextRepository` from being consulted.
`NullSecurityContextRepository` closes the second half. Without it the default is
`DelegatingSecurityContextRepository(RequestAttributeSecurityContextRepository,
HttpSessionSecurityContextRepository)` — so a `SecurityContext` you save goes into an
`HttpSession`, and a session cookie appears in a response you believed was stateless.
Verify rather than assume: the transcript prints `Set-Cookie` if one appears. In
[`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt), none does.
## `SecurityContextHolderFilter` and explicit save
Spring Security 6 replaced `SecurityContextPersistenceFilter` with
`SecurityContextHolderFilter`. The difference is one line of behaviour:
| | loads context | saves context |
|---|---|---|
| `SecurityContextPersistenceFilter` (legacy) | yes | **automatically**, at the end of the request |
| `SecurityContextHolderFilter` (6.0+ default) | yes | **no — you must call `saveContext`** |
Anything that authenticates a request must now say so explicitly:
```java
SecurityContext context = this.contextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
this.contextHolderStrategy.setContext(context);
this.contextRepository.saveContext(context, request, response); // <-- easy to forget
```
For a genuinely stateless API `saveContext` on a `NullSecurityContextRepository` is a
no-op, so omitting it appears to work — until an `ERROR` dispatch, a `FORWARD`, or an
async re-dispatch clears the `ThreadLocal` and the principal vanishes on `/error`.
[`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java)
uses `RequestAttributeSecurityContextRepository`, which survives a dispatch without ever
touching a session — the right middle ground.
## Always create the context, never mutate the shared one
```java
// wrong - mutates a context that may be shared
SecurityContextHolder.getContext().setAuthentication(auth);
// right
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
```
The first form has been discouraged since 5.7 and is a real race in multi-threaded
handling.
## Use the strategy, not the static methods
```java
private final SecurityContextHolderStrategy contextHolderStrategy =
SecurityContextHolder.getContextHolderStrategy();
```
`SecurityContextHolder`'s static methods delegate to whatever strategy is installed, but
capturing the strategy once is what the framework's own filters do, and it is the only
form that keeps working when the application swaps in a delegating strategy — the usual
reasons being observability, tenant propagation, or structured concurrency.
## The thread boundary
`SecurityContextHolder` is a `ThreadLocal`. It does not cross threads. `GET
/api/async-demo` proves it — from the [transcript](output/curl-transcript-hs256.txt),
step 20:
```json
{
"onRequestThread" : "root",
"onPlainExecutor" : "null (context did not cross the thread)",
"onDelegatingExecutor" : "root"
}
```
Same request, same instant, three answers. The middle one is what a `@Async` method, a
plain `CompletableFuture.supplyAsync`, or a raw executor sees.
Fixes, in order of scope:
```java
// one executor
new DelegatingSecurityContextExecutorService(Executors.newVirtualThreadPerTaskExecutor());
// one task
new DelegatingSecurityContextRunnable(task);
new DelegatingSecurityContextCallable<>(task);
// the whole application - context inherited by child threads
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
```
`MODE_INHERITABLETHREADLOCAL` is the tempting one and the wrong one for a servlet
container: threads are **pooled**, so "child" is whatever thread the pool happens to
spawn, and a context can be inherited by a task belonging to a different request. Wrap
executors instead.
For `@Async` specifically, Spring Security's
`DelegatingSecurityContextAsyncTaskExecutor` wraps the task executor; ankurm.com has a
[dedicated guide to context propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/).
## Virtual threads
Boot 4.1 on JDK 25 makes `spring.threads.virtual.enabled=true` unremarkable. `ThreadLocal`
works on a virtual thread exactly as on a platform thread, so the `SecurityContext`
behaves identically. The one thing that changes: virtual threads are *not* pooled, so
the cross-request leak from a stale `ThreadLocal` is far less likely — which is a reason
to be *more* careful, not less, because the bug becomes rarer and harder to reproduce
rather than absent. Clear the context on the failure path regardless.

286
docs/07-edge-cases.md Normal file
View File

@@ -0,0 +1,286 @@
# 07 — Edge cases
[← SecurityContext](06-securitycontext-and-statelessness.md) · [next: testing →](08-testing.md)
Eighteen things that bite. Each is stated as the surprise, then the cause, then the fix.
---
## 1. `aud` is not validated by default {#audience}
`JwtValidators.createDefaultWithIssuer(issuer)` validates `exp`, `nbf` and `iss`. It does
**not** validate `aud`. In an estate where every service trusts the same issuer, a token
minted for the reporting API is accepted by the payments API without complaint. That is a
confused-deputy vulnerability arriving by default.
```java
OAuth2TokenValidator<Jwt> audience =
new JwtClaimValidator<List<String>>(JwtClaimNames.AUD, aud -> aud.contains("payments-api"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer), audience));
```
See [`AudienceValidator`](../src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java)
and [`JwtValidatorFactory`](../src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java).
---
## 2. A refresh token is a valid access token {#refresh-token-as-access-token}
Both are signed by the same key. Both have a valid `exp`, `iss`, `aud`. Every default
validator passes. If the only difference is the TTL, a stolen refresh token is a
*long-lived* access token.
Proof, from two runs of the same code —
[loose](output/resource-server-loose.txt) vs [strict](output/resource-server-strict.txt):
```
# 4. REFRESH token presented as an access token.
HTTP 200 <-- profiles: hs256,resourceserver
HTTP 401 <-- profiles: hs256,resourceserver,strict
```
The 200 is worth reading closely: authorities come back as `["FACTOR_BEARER"]` — no roles,
no scopes. The caller is authenticated as alice with no privileges, so `/api/me` succeeds
while `/api/admin/stats` does not. A partial compromise is still a compromise.
Fix: a `token_type` claim and a validator that checks it —
[`AccessTokenTypeValidator`](../src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java).
---
## 3. Sixty seconds of clock skew
`JwtTimestampValidator` allows **60 seconds** of clock skew by default, so a token is
still accepted a minute after `exp`. From
[`expiry-and-clock-skew.txt`](output/expiry-and-clock-skew.txt), with a 2-second TTL:
```
# T+0s - fresh token HTTP 200
# T+5s - exp has passed, still within the skew window HTTP 200
# T+65s - past exp + 60s HTTP 401
```
This is correct behaviour and usually what you want. It matters in two places: a test
that sleeps past `exp` and asserts 401 will fail, and a "revoke by shortening TTL"
strategy has a minute of lag. To tighten it:
```java
new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(5)),
new JwtIssuerValidator(issuerUri));
```
---
## 4. A JWT cannot be revoked {#logout-and-revocation}
"Logout" that deletes the token client-side is not revocation — the token stays valid
until `exp` and works from anywhere it was copied. The minimum viable fix is a `jti`
claim plus a denylist checked on every request:
[`RevokedTokenStore`](../src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java).
```java
if (this.revokedTokens.isRevoked(jwt.getId())) {
throw invalidToken("Token has been revoked");
}
```
Entries need only outlive the token's own `exp`, so the store self-prunes; in production
this is Redis with a TTL. Steps 1718 of the
[transcript](output/curl-transcript-hs256.txt) show a cryptographically valid token
refused after logout.
Accept the trade-off honestly: you have reintroduced a per-request lookup on shared
state, which is the thing JWTs were supposed to avoid. Short access-token TTLs (515
minutes) plus a denylist only for high-value events (password change, logout-all,
compromise) is the usual compromise.
---
## 5. Rotate refresh tokens, or replay is undetectable
If a refresh token is reusable, a stolen one is usable until it expires and you will
never know. Rotation — issue a new refresh token and revoke the presented one — turns
replay into a signal.
```java
this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt()); // spend it
```
Steps 1516 of the [transcript](output/curl-transcript-hs256.txt): the second use of the
same refresh token is a 401. In production, a replay should invalidate the **whole
token family** for that user, since either the client or the attacker is now holding a
stale token and you cannot tell which.
---
## 6. Token storage: `localStorage` vs cookies {#token-storage}
| | `localStorage` | `httpOnly` cookie |
|---|---|---|
| XSS | readable by any injected script | not readable |
| CSRF | immune (not ambient) | vulnerable — needs CSRF protection back on |
| mobile / non-browser | fine | awkward |
There is no free option. `localStorage` trades XSS exposure for CSRF immunity; cookies
do the reverse. If you pick cookies, **you must re-enable CSRF** — see
[doc 04](04-csrf-permitall-403.md). The failure mode is picking cookies for XSS safety
and keeping `csrf.disable()` from the tutorial you started with.
The strongest common pattern: short-lived access token in memory only (never persisted),
refresh token in an `httpOnly`, `Secure`, `SameSite=Strict` cookie scoped to the refresh
endpoint, with CSRF protection on that one endpoint.
---
## 7. JWTs are signed, not encrypted
Base64url is not encryption. Step 6 of the
[transcript](output/curl-transcript-hs256.txt) decodes a token with `base64 -d` and no
key. Anything in the claims is readable by the holder, by proxies that log the header, and
by anything that ends up with the string.
Never put in claims: email addresses, phone numbers, internal user IDs you would not
publish, permission structures that describe your authorization model, PII of any kind.
If the payload must be confidential, that is JWE (`nimbus-jose-jwt` supports it), not JWS —
and the usual right answer is to put an opaque identifier in the token and look the rest up.
---
## 8. Bigger tokens are a real cost
`Authorization` headers travel on **every** request. From
[`rs256-demo.txt`](output/rs256-demo.txt), a modest RS256 token is 758 characters;
the signature alone is 342. Add a `permissions` array with 200 entries and you are near
common proxy header limits (nginx `large_client_header_buffers` defaults to 8 KB; some
API gateways are stricter). The failure is a **431** or a silent truncation, not a
security error, and it appears only for your most privileged users — who have the most
permissions and complain the loudest.
Put roles in the token, not permissions. Resolve permissions server-side.
---
## 9. Authority prefixes: `ROLE_` vs `SCOPE_`
`JwtGrantedAuthoritiesConverter` defaults to reading the `scope` (or `scp`) claim and
prefixing each value with `SCOPE_`. Meanwhile `hasRole("ADMIN")` looks for `ROLE_ADMIN`
and `hasAuthority("ADMIN")` looks for exactly `ADMIN`. Three conventions, easily crossed:
```java
.requestMatchers("/api/admin/**").hasRole("ADMIN") // needs ROLE_ADMIN
.requestMatchers("/api/reports").hasAuthority("SCOPE_admin:read")
```
This repository carries both families and maps them separately —
`scope``SCOPE_x`, `roles``ROLE_x` — with
`DelegatingJwtGrantedAuthoritiesConverter` in the resource-server profile. Spring Boot 4.1
also added `spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions`,
a SpEL-based extractor for claims nested deeper than the top level (mutually exclusive
with `authorities-claim-name`).
---
## 10. `@PreAuthorize` on a non-public method silently does nothing
Method security is proxy-based. A `@PreAuthorize` on a `private`, `final`, or
package-private method, or on a method called from **within the same class**, is not
intercepted. There is no warning. The endpoint is simply unprotected.
Keep authorization on public methods invoked through the proxy, and prefer
`authorizeHttpRequests` for coarse URL rules.
---
## 11. `permitAll()` does not mean "no authentication"
It means "authorization always grants". If a token *is* present, it is still decoded, and
a **bad** token on a `permitAll()` endpoint still fails — the filter rejects it before
authorization runs. This is correct: a caller sending a broken token deserves to be told,
not silently downgraded to anonymous.
Where it surprises people: health checks that pass through an expired token from a
sidecar start failing on an endpoint that is supposedly public.
---
## 12. Ordering inside `authorizeHttpRequests` is first-match
```java
.anyRequest().authenticated()
.requestMatchers("/api/public/**").permitAll() // unreachable
```
Rules are evaluated top to bottom and the first match wins. `anyRequest()` must be last.
Spring Security 7 throws at startup for an unreachable matcher in many cases, but not all
— put the specific rules first regardless.
---
## 13. The `Authorization` header can be stripped in transit
Some proxies, load balancers and CDN configurations drop or rewrite `Authorization`.
Symptom: works locally, 401 everywhere else, and the application log shows no token at
all. Check the edge before the application. `DefaultBearerTokenResolver` also supports a
query parameter, but do **not** enable it:
```java
resolver.setAllowUriQueryParameter(true); // don't
```
URLs land in access logs, browser history, and `Referer` headers.
---
## 14. Two tokens in one request is an error, not a preference
`DefaultBearerTokenResolver` throws `OAuth2AuthenticationException` when a token appears
in both the header and a parameter, rather than picking one. Correct — but it means a
client that "helpfully" adds both gets a 401 with `invalid_request` and no obvious cause.
---
## 15. `WWW-Authenticate` needs a `BearerTokenError` to say anything
Wrap a `JwtException` in a plain `AuthenticationServiceException` and the 401 carries a
bare `WWW-Authenticate: Bearer realm="…"`. Wrap it in `InvalidBearerTokenException` and it
carries `error="invalid_token"` with a description. Same status code, very different
debuggability. Compare steps 11 and 13 of the
[transcript](output/curl-transcript-hs256.txt).
---
## 16. `error_description` leaks
The flip side: `"Jwt expired at 2026-08-22T06:01:43Z"` tells a caller exactly when the
token expired, and issuer/audience mismatches name your internal URLs. Useful in
development, informative to an attacker in production. Consider a production
`AuthenticationEntryPoint` that logs the detail and returns a generic body.
---
## 17. The `SecurityContext` does not cross threads
Covered in [doc 06](06-securitycontext-and-statelessness.md#the-thread-boundary), listed
here because it is the edge case that most often reaches production: it only manifests
under `@Async`, `CompletableFuture`, or a `parallelStream()`, none of which are on the
happy path. `GET /api/async-demo` demonstrates it live. {#async}
---
## 18. `FACTOR_BEARER` appears in your authorities
New in Spring Security 7: authenticating with a bearer token adds a `FACTOR_BEARER`
authority alongside your own. Visible in every `/api/me` response in the
[transcript](output/curl-transcript-hs256.txt):
```json
"authorities": ["FACTOR_BEARER", "ROLE_USER", "SCOPE_profile:read"]
```
It exists to support the new multi-factor authorization support
(`AuthorizationManagerFactories.multiFactor()`, `@EnableMultiFactorAuthentication`). It
is harmless — until a test asserts on the exact authority set, or code assumes every
authority starts with `ROLE_` or `SCOPE_`. See [doc 11](11-spring-security-7-changes.md).

132
docs/08-testing.md Normal file
View File

@@ -0,0 +1,132 @@
# 08 — Testing
[← edge cases](07-edge-cases.md) · [next: manual filter vs resource server →](09-manual-filter-vs-resource-server.md)
## The Boot 4 test-slice split
On Spring Boot 3, `spring-boot-starter-test` alone gave you `@AutoConfigureMockMvc`. On
Boot 4 it does not — the test slices were moved into their own modules:
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
```
The package moved with it:
```java
// Boot 3
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
// Boot 4
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
```
The compiler error is `package org.springframework.boot.test.autoconfigure.web.servlet
does not exist`, which reads like a corrupt dependency rather than a relocation.
## What is worth pinning
13 tests, all passing — [`test-run.txt`](output/test-run.txt). The valuable ones assert
things that are easy to break without noticing.
**The status-code contract.** Not "it works" but *which* failure code:
```java
@Test
void missingTokenIs401NotA403() throws Exception {
this.mvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate", containsString("Bearer")));
}
@Test
void validTokenWithoutTheRoleIs403NotA401() throws Exception {
String token = login("alice", "alice-password").get("accessToken");
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
.andExpect(status().isForbidden())
.andExpect(header().string("WWW-Authenticate", containsString("insufficient_scope")));
}
```
**Non-disclosure.** A byte comparison, because a helpful message is a regression:
```java
@Test
void lockedAccountIsIndistinguishableFromABadPassword() throws Exception {
// ... both requests ...
assertThat(locked.getResponse().getContentAsString())
.isEqualTo(wrong.getResponse().getContentAsString());
}
```
**Filter order.** Ordering is configuration, and configuration drifts:
```java
@Test
void csrfFilterRunsLongBeforeAuthorizationFilter() {
List<String> filters = this.filterChainProxy.getFilterChains().getFirst()
.getFilters().stream().map(f -> f.getClass().getSimpleName()).toList();
assertThat(filters.indexOf("AuthorizationFilter")).isEqualTo(filters.size() - 1);
assertThat(filters.indexOf("CsrfFilter")).isLessThan(filters.indexOf("AuthorizationFilter"));
}
```
**Revocation and replay**, because both are easy to regress into no-ops.
## The `.with(csrf())` trap
`spring-security-test` provides a post-processor that attaches a valid CSRF token:
```java
this.mvc.perform(post("/api/auth/login").with(csrf()) ... )
```
Convenient, and it will make a test pass against a configuration that 403s in production.
[`CsrfBreaksPermitAllTests`](../src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java)
deliberately has both tests: one asserting the 403 **without** `csrf()`, one asserting the
200 with it. If you only ever write the second, you have tested your test.
## `@WithMockUser` tests authorization, not authentication
```java
@Test
@WithMockUser(roles = "ADMIN")
void adminCanSeeStats() { ... }
```
This installs an `Authentication` directly into the context and **bypasses the entire
filter chain** — decoder, validators, `token_type` check, denylist. It is the right tool
for testing `@PreAuthorize` rules and the wrong tool for testing that your JWT pipeline
works. Every test in `AuthenticationFlowTests` goes through a real `POST /api/auth/login`
and a real `Authorization` header for that reason.
`spring-security-test` also offers `SecurityMockMvcRequestPostProcessors.jwt()`, which
constructs a `Jwt` without signing it. Same caveat: good for authorization rules, blind
to decoder configuration.
## Testing expiry
A token with a 2-second TTL is **not** expired 5 seconds later — `JwtTimestampValidator`
allows 60 seconds of clock skew ([doc 07 §3](07-edge-cases.md)). A test that sleeps past
`exp` and asserts 401 either sleeps 61 seconds or is flaky.
Two better options: build the `JwtDecoder` under test with a small skew
(`new JwtTimestampValidator(Duration.ZERO)`), or inject a fixed `Clock` and issue a token
already in the past.
## Integration testing against the real server
`scripts/curl-transcript.sh` is the integration test that MockMvc cannot be — it exercises
a real Tomcat, a real HTTP client, real header parsing, and real base64url. Several
findings in these docs (the `resource_metadata` parameter, the `FACTOR_BEARER` authority,
the bare `WWW-Authenticate` on a wrapped `JwtException`) came from that script, not from
the test suite.

View File

@@ -0,0 +1,123 @@
# 09 — Manual filter vs built-in resource server
[← testing](08-testing.md) · [next: production checklist →](10-production-checklist.md)
Both are in this repository, behind profiles, secured identically. Run them side by side:
```bash
./scripts/run.sh hs256 # hand-written OncePerRequestFilter
./scripts/run.sh hs256,resourceserver # oauth2ResourceServer().jwt()
```
## The configuration, side by side
**Manual**
[`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java):
```java
.addFilterBefore(new JwtAuthenticationFilter(jwtDecoder, revokedTokens, entryPoint),
UsernamePasswordAuthenticationFilter.class);
```
plus ~120 lines of filter, plus the entry point and access-denied handler wired by hand.
**Built-in**
[`ResourceServerSecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java):
```java
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
```
One line. `BearerTokenAuthenticationFilter` is inserted in the right slot,
`BearerTokenAuthenticationEntryPoint` and `BearerTokenAccessDeniedHandler` are wired,
the `JwtDecoder` bean is picked up automatically, and the RFC 6750 headers are correct
on both 401 and 403.
## The chains it produces
Manual ([transcript](output/curl-transcript-hs256.txt) step 19):
```
… HeaderWriterFilter, JwtAuthenticationFilter, RequestCacheAwareFilter, …
```
Built-in ([transcript](output/resource-server-loose.txt) step 1):
```
… HeaderWriterFilter, LogoutFilter, OAuth2ProtectedResourceMetadataFilter,
BearerTokenAuthenticationFilter, RequestCacheAwareFilter, …
```
Same slot. The extra `OAuth2ProtectedResourceMetadataFilter` is Spring Security 7's
RFC 9728 support — see [doc 11](11-spring-security-7-changes.md).
## What moves when you switch
| concern | manual filter | resource server |
|---|---|---|
| resolve the header | `DefaultBearerTokenResolver` (you call it) | built in |
| decode + verify | `jwtDecoder.decode(token)` (you call it) | built in |
| `exp`/`nbf`/`iss` | `JwtValidators` on the decoder | same decoder, same validators |
| `aud` | your validator | your validator |
| `token_type` | an `if` in the filter | an `OAuth2TokenValidator` |
| denylist / `jti` | an `if` in the filter | an `OAuth2TokenValidator` |
| authority mapping | your converter | `JwtAuthenticationConverter` |
| 401 shape | you wire the entry point | built in |
| 403 shape | you wire the handler | built in |
The two custom checks do not disappear — they move onto the decoder. That is the trap in
the next section.
## The trap: the built-in path has no opinion about your claims
Run the same request against both resource-server profiles:
```
# 4. REFRESH token presented as an access token.
HTTP 200 hs256,resourceserver (loose)
HTTP 401 hs256,resourceserver,strict (validator wired)
```
Files: [loose](output/resource-server-loose.txt) · [strict](output/resource-server-strict.txt).
Switching from a hand-written filter to `oauth2ResourceServer()` **silently drops** every
custom check that lived in the filter, because the framework has never heard of your
`token_type` claim or your denylist. The build still passes. The tests still pass, if
they only test the happy path. Move them explicitly:
```java
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
AudienceValidator.forAudience(audience),
new AccessTokenTypeValidator()));
```
`JwtValidatorFactory` in this repository composes exactly that, and the extras are
supplied by an `ObjectProvider` so both profiles get them.
Note also that a denylist check does not belong in an `OAuth2TokenValidator` on purity
grounds — validators are supposed to be pure functions of the token — but it is where it
has to go if you want it on the built-in path. The alternative is an
`AuthenticationSuccessHandler` or a small filter *after*
`BearerTokenAuthenticationFilter`, which puts you halfway back to the manual approach.
## Which to pick
**Use the built-in resource server** if your tokens are ordinary OAuth 2.0 / OIDC access
tokens, especially from a real authorization server. It is less code, it is maintained,
and it gets RFC compliance right in places you would not think to (the `charset` in
`WWW-Authenticate`, RFC 9728 metadata, `insufficient_scope` on 403).
**Write the filter** if you need behaviour the framework has no hook for — custom header
schemes, a token bound to a device fingerprint, per-request key selection across
tenants — or if you are teaching, because the filter is where the flow becomes legible.
**What this repository actually recommends:** start with the built-in one. If you find
yourself adding `OAuth2TokenValidator`s for things that are not claims, that is the
signal to switch.
A useful third option for a real system: run
[Spring Authorization Server](https://spring.io/projects/spring-authorization-server) as
the issuer and consume its tokens with `oauth2ResourceServer()`. Then neither half of
this repository is your code.

View File

@@ -0,0 +1,66 @@
# 10 — Production checklist
[← manual vs resource server](09-manual-filter-vs-resource-server.md) · [next: Spring Security 7 changes →](11-spring-security-7-changes.md)
Run this list before shipping. Each item links to the section that explains it.
## Keys and algorithms
- [ ] Signing key comes from a secret manager or KMS, **never** from `application.yaml`, and never from an environment variable baked into an image. The values in this repository are public demo values.
- [ ] HMAC secret is ≥ 32 bytes of **random** data (`openssl rand -base64 48`), not a passphrase that happens to be long enough. [→ 05](05-hs256-vs-rs256.md)
- [ ] The algorithm is pinned on the decoder (`.macAlgorithm(..)` / `.signatureAlgorithm(..)`), not left to the token's `alg` header. [→ 05](05-hs256-vs-rs256.md)
- [ ] If more than one service verifies tokens, the algorithm is asymmetric (RS256 / ES256). With HS256 every verifier can mint admin tokens. [→ 05](05-hs256-vs-rs256.md)
- [ ] Every key has a `kid`, and a rotation procedure exists and has been rehearsed. [→ 05](05-hs256-vs-rs256.md)
- [ ] A published JWKS contains `n` and `e` only — grep it for `"d"`, `"p"`, `"q"` before exposing it.
## Claims and validation
- [ ] `aud` is validated. It is **not** validated by default. [→ 07 §1](07-edge-cases.md#audience)
- [ ] `iss` is validated (`JwtValidators.createDefaultWithIssuer`).
- [ ] Access and refresh tokens are distinguishable, and the distinction is enforced on every request. [→ 07 §2](07-edge-cases.md#refresh-token-as-access-token)
- [ ] Clock skew is a deliberate number, not an accepted default of 60s. [→ 07 §3](07-edge-cases.md)
- [ ] No PII in claims. A JWT is signed, not encrypted. [→ 07 §7](07-edge-cases.md)
- [ ] Token size measured against your proxy's header limit, with the most privileged user's token. [→ 07 §8](07-edge-cases.md)
## Lifetimes and revocation
- [ ] Access-token TTL is minutes, not hours or days.
- [ ] Refresh tokens rotate on use, and a replay invalidates the family. [→ 07 §5](07-edge-cases.md)
- [ ] Every token carries a `jti`, and a denylist exists for logout, password change, and compromise. [→ 07 §4](07-edge-cases.md#logout-and-revocation)
- [ ] The denylist is shared across instances (Redis, not a `ConcurrentHashMap`) and entries expire.
- [ ] "Log out everywhere" is possible — usually a per-user `tokensValidAfter` timestamp compared against `iat`.
## Chain configuration
- [ ] CSRF decision is deliberate and matches where the token lives: disabled **only** if no credential is ambient. [→ 04](04-csrf-permitall-403.md)
- [ ] `SessionCreationPolicy.STATELESS` **and** `NullSecurityContextRepository`. Verify no `Set-Cookie` appears in a response. [→ 06](06-securitycontext-and-statelessness.md)
- [ ] `formLogin`, `httpBasic` and `logout` are explicitly disabled if unused — otherwise a browser-shaped fallback exists on your API.
- [ ] Custom filter is `addFilterBefore(..., UsernamePasswordAuthenticationFilter.class)`, extends `OncePerRequestFilter`, and is **not** also registered as a servlet filter. [→ 02](02-filter-chain-and-ordering.md)
- [ ] `anyRequest()` is the last rule. [→ 07 §12](07-edge-cases.md)
- [ ] The filter clears the `SecurityContext` on every failure path. [→ 02](02-filter-chain-and-ordering.md)
- [ ] `AuthenticationEntryPoint` and `AccessDeniedHandler` are both configured, and login failures have a `@RestControllerAdvice`. [→ 03](03-401-vs-403.md)
- [ ] The filter-chain diagnostic endpoint (`/api/public/filters` here) is **removed**.
## Responses
- [ ] Login failures are indistinguishable across bad-password, unknown-user, locked and disabled. [→ 03](03-401-vs-403.md)
- [ ] `error_description` does not leak expiry timestamps or internal URLs in production. [→ 07 §16](07-edge-cases.md)
- [ ] 401 carries `WWW-Authenticate` with a real RFC 6750 error code, not a bare realm. [→ 07 §15](07-edge-cases.md)
- [ ] Rate limiting on `/login` and `/refresh`. Nothing in Spring Security does this for you, and an unthrottled login endpoint with bcrypt is also a CPU denial-of-service.
## Transport and operations
- [ ] HTTPS enforced; HSTS on.
- [ ] Tokens never in URLs, and `allowUriQueryParameter` is off. [→ 07 §13](07-edge-cases.md)
- [ ] Access logs do not record the `Authorization` header.
- [ ] Authentication failures are logged with enough context to alert on, and a spike in `invalid_token` is alertable.
- [ ] `@Async`/executor boundaries wrap the `SecurityContext`. [→ 06](06-securitycontext-and-statelessness.md)
- [ ] Dependency scanning covers `nimbus-jose-jwt` — it is where JOSE CVEs land.
## Before you build any of this
Ask whether you should. If you need sessions and have one server-rendered application,
a session cookie is simpler, revocable by design, and has no key management. If you need
federated identity, an authorization server (Keycloak, Auth0, Okta, Spring Authorization
Server) already implements every item on this list. A hand-rolled JWT layer is the right
answer for a stateless API you own end to end — and a lot of work everywhere else.

View File

@@ -0,0 +1,132 @@
# 11 — What changed in Spring Security 7
[← production checklist](10-production-checklist.md) · [README](../README.md)
Everything below was hit while building this repository against Spring Security **7.1.1**
on Spring Boot **4.1.1**, JDK **25**. Verified by compiling or by reading real responses,
not from release notes alone.
## `FACTOR_BEARER` in your authorities
Every bearer-token authentication now carries an extra authority:
```json
"authorities": ["FACTOR_BEARER", "ROLE_USER", "SCOPE_profile:read"]
```
It backs the new multi-factor authorization support —
`AuthorizationManagerFactories.multiFactor()`, `@EnableMultiFactorAuthentication`, and in
7.1 the `when` / `withWhen` conditions and `MultiFactorCondition.WEBAUTHN_REGISTERED`.
Harmless until a test asserts an exact authority set, or code assumes every authority
starts with `ROLE_` or `SCOPE_`.
## `resource_metadata` in every `WWW-Authenticate`
```
WWW-Authenticate: Bearer realm="jwt-auth-demo",
resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
```
Spring Security 7 adds `OAuth2ProtectedResourceMetadataFilter` to the resource-server
chain — RFC 9728, OAuth 2.0 Protected Resource Metadata. Visible in the
[resource-server chain](output/resource-server-loose.txt) and in the entry point's output
even on the manual profile, because `BearerTokenAuthenticationEntryPoint` emits it.
7.1 additionally includes `charset` in `WWW-Authenticate` ([gh-18755]).
## `NimbusJwtEncoder` builders (7.0+) and their method names
```java
NimbusJwtEncoder.withSecretKey(secretKey).algorithm(MacAlgorithm.HS256).build();
NimbusJwtEncoder.withKeyPair(rsaPublic, rsaPrivate).algorithm(SignatureAlgorithm.RS256).build();
NimbusJwtEncoder.withKeyPair(ecPublic, ecPrivate).build();
```
Two traps:
- the builder method is **`algorithm(..)`**, not `jwsAlgorithm(..)` — while the *decoder*
builders use `macAlgorithm(..)` and `signatureAlgorithm(..)`;
- there is **no `keyId(..)`**. Set `kid` through `jwkPostProcessor(jwk -> jwk.keyID(..))`.
The pre-7.0 form still compiles:
```java
new NimbusJwtEncoder(new ImmutableSecret<>(secretKey));
```
`setJwkSelector(List::getFirst)` (6.5+) resolves the "multiple matching JWKs" exception.
## Three sibling classes, three packages
```java
org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint
org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler
org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter
```
Auto-import will confidently pick the wrong one. This cost a compile cycle here.
## Jackson 3
Spring Security 7 moves to Jackson 3 (`tools.jackson.*`). `SecurityJackson2Modules` is
replaced by `SecurityJacksonModules` with `JsonMapper.Builder`. Boot 4.1.1 resolves
`tools.jackson.core:jackson-databind:3.1.5`. If you serialise a `SecurityContext` — into
a session store, a cache, a Redis-backed denylist — that code changes. See the
[Jackson 2 to 3 migration guide](https://ankurm.com/jackson-3-migration-guide/).
## Boot 4 test slices moved
`@AutoConfigureMockMvc` is now `org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc`,
in `spring-boot-starter-webmvc-test`. Security test support is in
`spring-boot-starter-security-test`. `spring-boot-starter-test` alone no longer suffices.
[→ doc 08](08-testing.md)
## Boot 4.1: SpEL authority extraction
```yaml
spring:
security:
oauth2:
resourceserver:
jwt:
authorities-claim-expressions: "['realm_access']['roles']"
authority-prefix: "ROLE_"
```
Mutually exclusive with `authorities-claim-name` / `authorities-claim-delimiter`. This is
the property-only answer to Keycloak-style nested role claims, which previously needed a
custom converter.
## `csrf.spa()` is new in 7.0
```java
.csrf(csrf -> csrf.spa())
```
One call for `CookieCsrfTokenRepository` + `XorCsrfTokenRequestAttributeHandler` +
deferred token loading. Checked against the jars: absent from `spring-security-config`
6.4.7 and 6.5.1, present in 7.0.0. Several guides describe it as a 6.x feature.
## Other 7.1 additions worth knowing
- `RestClientOpaqueTokenIntrospector` ([gh-18745]) — the `RestClient`-based replacement for the `RestTemplate` introspector, for opaque rather than JWT tokens.
- `ConditionalAuthorizationManager` and `AllRequiredFactorsAuthorizationManager.anyOf` ([gh-18960]).
- `PreFlightRequestFilter` CORS support ([gh-18926]).
- `InetAddressMatcher` ([gh-18634]).
- WebAuthn now publishes authentication events ([gh-18113]).
## Migrating from 6.x
Spring Security's own advice: go to **6.5** first, use its opt-in switches to adopt the
7.0 behaviours one at a time, then upgrade. The 6.5 preparation steps exist precisely so
that the 7.0 jump is a version bump rather than a rewrite.
ankurm.com has a dedicated
[Spring Security 5 → 6 → 7 migration guide](https://ankurm.com/spring-security-5-to-6-to-7-migration-guide/).
[gh-18755]: https://github.com/spring-projects/spring-security/issues/18755
[gh-18745]: https://github.com/spring-projects/spring-security/issues/18745
[gh-18960]: https://github.com/spring-projects/spring-security/issues/18960
[gh-18926]: https://github.com/spring-projects/spring-security/issues/18926
[gh-18634]: https://github.com/spring-projects/spring-security/pull/18634
[gh-18113]: https://github.com/spring-projects/spring-security/issues/18113

View File

@@ -0,0 +1,19 @@
==========================================================================
Spring Security TRACE log: why a permitAll() endpoint answers 403
profiles: hs256,csrfon,trace request: POST /api/auth/login
==========================================================================
DEBUG [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Securing POST /api/auth/login
TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/12)
TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/12)
TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/12)
TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/12)
TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/12)
TRACE [nio-8080-exec-2] s.s.w.c.CsrfTokenRequestAttributeHandler : Wrote a CSRF token to the following request attributes: [_csrf, org.springframework.security.web.csrf.CsrfToken]
TRACE [nio-8080-exec-2] o.s.s.web.csrf.CsrfTokenRequestHandler : Did not find a CSRF token in the [X-XSRF-TOKEN] request header
TRACE [nio-8080-exec-2] o.s.s.web.csrf.CsrfTokenRequestHandler : Did not find a CSRF token in the [_csrf] request parameter
DEBUG [nio-8080-exec-2] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/auth/login
TRACE [nio-8080-exec-2] o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure]
The chain stops at filter 5 of 12. AuthorizationFilter - the only filter that
has ever heard of permitAll() - is number 12. It is never invoked.

View File

@@ -0,0 +1,54 @@
==========================================================================
jwt-auth-demo - CSRF vs permitAll()
app started with: --spring.profiles.active=hs256,csrfon
==========================================================================
--------------------------------------------------------------------------
# A. The login endpoint is permitAll(). POST it anyway.
# 403 - and nothing in the authorization rules explains why.
HTTP 403
WWW-Authenticate: Bearer
--------------------------------------------------------------------------
# B. GET on the same permitAll() path family works. Only unsafe methods break.
HTTP 200
Content-Type: application/json
{
"authenticationRequired": false,
"status": "up"
}
--------------------------------------------------------------------------
# C. The filter chain, with CsrfFilter present. Count the positions:
# CsrfFilter is 5th, AuthorizationFilter is last. The request never
# reaches the filter that knows about permitAll().
HTTP 200
Content-Type: application/json
[
{
"matchesThisRequest": true,
"filters": [
"DisableEncodeUrlFilter",
"WebAsyncManagerIntegrationFilter",
"SecurityContextHolderFilter",
"HeaderWriterFilter",
"CsrfFilter",
"JwtAuthenticationFilter",
"RequestCacheAwareFilter",
"SecurityContextHolderAwareRequestFilter",
"AnonymousAuthenticationFilter",
"SessionManagementFilter",
"ExceptionTranslationFilter",
"AuthorizationFilter"
],
"chain": "DefaultSecurityFilterChain defined as 'apiFilterChain' in [class path resource [com/ankurm/jwtauth/config/SecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, JwtAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, SessionManagement, ExceptionTranslation, Authorization]"
}
]
--------------------------------------------------------------------------
# end

View File

@@ -0,0 +1,253 @@
==========================================================================
jwt-auth-demo - curl transcript
target : http://localhost:8080
==========================================================================
--------------------------------------------------------------------------
# 1. Public endpoint, no token. permitAll() means the filter chain lets it through.
HTTP 200
Content-Type: application/json
{
"status": "up",
"authenticationRequired": false
}
--------------------------------------------------------------------------
# 2. Protected endpoint, no token. 401 - we do not know who you are.
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
# 3. Wrong password. Still 401, and the body says nothing about which half was wrong.
HTTP 401
Content-Type: application/problem+json
{
"detail": "Invalid username or password",
"instance": "/api/auth/login",
"status": 401,
"title": "Authentication failed",
"type": "https://ankurm.com/problems/invalid-credentials"
}
--------------------------------------------------------------------------
# 4. Locked account. 401 with the identical body - no account-state oracle.
HTTP 401
Content-Type: application/problem+json
{
"detail": "Invalid username or password",
"instance": "/api/auth/login",
"status": 401,
"title": "Authentication failed",
"type": "https://ankurm.com/problems/invalid-credentials"
}
--------------------------------------------------------------------------
# 5. Login as alice (ROLE_USER, SCOPE_profile:read).
HTTP 200
Content-Type: application/json
{
"accessToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJhbGljZSIsImF1ZCI6Imp3dC1hdXRoLWRlbW8tYXBpIiwibmJmIjoxNzg3Mzc5MzgxLCJzY29wZSI6InByb2ZpbGU6cmVhZCIsInJvbGVzIjpbIlVTRVIiXSwiaXNzIjoiaHR0cHM6Ly9qd3QtYXV0aC1kZW1vLmFua3VybS5jb20iLCJleHAiOjE3ODczODAyODEsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3ODczNzkzODEsImp0aSI6IjkzMGNiNWQ3LWZiNzctNDIxZC05MmViLWU3NzYxMWZiODQxOCJ9.vIbXeXd7_VKM7qYmoUTaYwePWX1x0yGbeuzPmrCvC00",
"refreshToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJhbGljZSIsImF1ZCI6Imp3dC1hdXRoLWRlbW8tYXBpIiwibmJmIjoxNzg3Mzc5MzgxLCJpc3MiOiJodHRwczovL2p3dC1hdXRoLWRlbW8uYW5rdXJtLmNvbSIsImV4cCI6MTc4NzQwODE4MSwidG9rZW5fdHlwZSI6InJlZnJlc2giLCJpYXQiOjE3ODczNzkzODEsImp0aSI6ImM5ODQzZGYyLWM2YTMtNDA4Ni05NDlmLTg0OTBkYTE1ZjI3NCJ9.om1nFZhD-5psTyKXLtW88gfaKdqmDDy5_n3PvBn2spI",
"tokenType": "Bearer",
"expiresIn": 900
}
--------------------------------------------------------------------------
# 6. What is actually inside that token (base64url decode - no signature check).
--- JOSE header ---
{
"kid": "103Yd2rZ9LBHvqA0I09if9EeA1wu6nbz0kVPQY4x3Xo",
"typ": "JWT",
"alg": "HS256"
}
--- claims ---
{
"sub": "alice",
"aud": "jwt-auth-demo-api",
"nbf": 1787379381,
"scope": "profile:read",
"roles": [
"USER"
],
"iss": "https://jwt-auth-demo.ankurm.com",
"exp": 1787380281,
"token_type": "access",
"iat": 1787379381,
"jti": "930cb5d7-fb77-421d-92eb-e77611fb8418"
}
--------------------------------------------------------------------------
# 7. The same protected endpoint, now with the token. 200.
HTTP 200
Content-Type: application/json
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"SCOPE_profile:read"
],
"authenticationType": "JwtAuthenticationToken",
"jti": "930cb5d7-fb77-421d-92eb-e77611fb8418",
"issuer": "https://jwt-auth-demo.ankurm.com",
"audience": [
"jwt-auth-demo-api"
],
"issuedAt": "2026-08-22T06:16:21Z",
"expiresAt": "2026-08-22T06:31:21Z",
"algorithm": "HS256",
"keyId": "103Yd2rZ9LBHvqA0I09if9EeA1wu6nbz0kVPQY4x3Xo"
}
--------------------------------------------------------------------------
# 8. alice hits an ADMIN endpoint. 403, not 401 - we know who she is, she may not.
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
# 9. Same request with a scope-based rule (@PreAuthorize). Also 403.
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
# 10. Login as root and repeat. 200.
HTTP 200
Content-Type: application/json
{
"accessToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJyb290IiwiYXVkIjoiand0LWF1dGgtZGVtby1hcGkiLCJuYmYiOjE3ODczNzkzODIsInNjb3BlIjoiYWRtaW46cmVhZCBwcm9maWxlOnJlYWQiLCJyb2xlcyI6WyJBRE1JTiIsIlVTRVIiXSwiaXNzIjoiaHR0cHM6Ly9qd3QtYXV0aC1kZW1vLmFua3VybS5jb20iLCJleHAiOjE3ODczODAyODIsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3ODczNzkzODIsImp0aSI6ImI0NzRlOGU4LWI1NzUtNDBlOS04MDMyLTEwZjk3ODMwNWE3NiJ9.8bE2iAn5XecooZMSD8AGHl3PqTzw_KAOUugTi-_VgqA",
"refreshToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJyb290IiwiYXVkIjoiand0LWF1dGgtZGVtby1hcGkiLCJuYmYiOjE3ODczNzkzODIsImlzcyI6Imh0dHBzOi8vand0LWF1dGgtZGVtby5hbmt1cm0uY29tIiwiZXhwIjoxNzg3NDA4MTgyLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImlhdCI6MTc4NzM3OTM4MiwianRpIjoiOWM3NTRjMmEtMDU0Zi00N2ZmLTkzZjktNTAxNTY3ZDI5NjFiIn0.FSR9SUvWqBZZSQMcKJwnUSgF9-B57wmptF-msvpgR8M",
"tokenType": "Bearer",
"expiresIn": 900
}
HTTP 200
Content-Type: application/json
{
"requiredRole": "ROLE_ADMIN",
"activeUsers": 3
}
--------------------------------------------------------------------------
# 11. Tampered payload, original signature. 401 invalid_token.
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
# 12. Garbage where a token should be. 401, and note it is NOT 400.
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
# 13. Authorization header with no Bearer scheme. The resolver sees no token at all,\n# so this is an authorization failure, not a token failure - note the bare realm.
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
# 14. A refresh token presented as an access token. 401 - the token_type claim.
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="This endpoint accepts access tokens only", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
# 15. Refresh with rotation. New access token, new refresh token.
HTTP 200
Content-Type: application/json
{
"accessToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJhbGljZSIsImF1ZCI6Imp3dC1hdXRoLWRlbW8tYXBpIiwibmJmIjoxNzg3Mzc5MzgyLCJzY29wZSI6InByb2ZpbGU6cmVhZCIsInJvbGVzIjpbIlVTRVIiXSwiaXNzIjoiaHR0cHM6Ly9qd3QtYXV0aC1kZW1vLmFua3VybS5jb20iLCJleHAiOjE3ODczODAyODIsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3ODczNzkzODIsImp0aSI6IjkyZjU3ZWMzLTk5OGYtNDkzNC05NjgxLTVkMmM2MWM3OThhOCJ9.pw4kOzw-ZcpcP9VyjgvKG6s5TtSTMrBwU255vG3BJ6g",
"refreshToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJhbGljZSIsImF1ZCI6Imp3dC1hdXRoLWRlbW8tYXBpIiwibmJmIjoxNzg3Mzc5MzgyLCJpc3MiOiJodHRwczovL2p3dC1hdXRoLWRlbW8uYW5rdXJtLmNvbSIsImV4cCI6MTc4NzQwODE4MiwidG9rZW5fdHlwZSI6InJlZnJlc2giLCJpYXQiOjE3ODczNzkzODIsImp0aSI6IjBmNWRjOGVkLWQ0MjItNDUwMS04OTJiLTRlY2Q2MWMxNDA3NyJ9.K4gemuQ8g3DJUmgb61sCWlTKsg3ImSljet4ogd6PJaM",
"tokenType": "Bearer",
"expiresIn": 900
}
--------------------------------------------------------------------------
# 16. Replay the spent refresh token. 401 - rotation makes replay detectable.
HTTP 401
Content-Type: application/problem+json
{
"detail": "Invalid username or password",
"instance": "/api/auth/refresh",
"status": 401,
"title": "Authentication failed",
"type": "https://ankurm.com/problems/invalid-credentials"
}
--------------------------------------------------------------------------
# 17. Logout revokes the presented access token by its jti.
HTTP 204
HTTP 200
Content-Type: application/json
{
"revokedTokens": 2
}
--------------------------------------------------------------------------
# 18. The revoked token, still cryptographically valid, is now refused.
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="Token has been revoked", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
# 19. The real filter order, read from FilterChainProxy at runtime.
HTTP 200
Content-Type: application/json
[
{
"filters": [
"DisableEncodeUrlFilter",
"WebAsyncManagerIntegrationFilter",
"SecurityContextHolderFilter",
"HeaderWriterFilter",
"JwtAuthenticationFilter",
"RequestCacheAwareFilter",
"SecurityContextHolderAwareRequestFilter",
"AnonymousAuthenticationFilter",
"SessionManagementFilter",
"ExceptionTranslationFilter",
"AuthorizationFilter"
],
"matchesThisRequest": true,
"chain": "DefaultSecurityFilterChain defined as 'apiFilterChain' in [class path resource [com/ankurm/jwtauth/config/SecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, JwtAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, SessionManagement, ExceptionTranslation, Authorization]"
}
]
--------------------------------------------------------------------------
# 20. SecurityContext across a thread boundary.
HTTP 200
Content-Type: application/json
{
"onRequestThread": "root",
"onPlainExecutor": "null (context did not cross the thread)",
"onDelegatingExecutor": "root"
}
--------------------------------------------------------------------------
# end of transcript

View File

@@ -0,0 +1,40 @@
==========================================================================
jwt-auth-demo - token expiry and the 60-second clock skew
profiles: hs256,shortlived (access-token-ttl = 2s)
==========================================================================
--------------------------------------------------------------------------
# T+0s - fresh token
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"SCOPE_profile:read"
--------------------------------------------------------------------------
# T+5s - exp has passed, but JwtTimestampValidator allows 60s of clock skew
# by default, so the token is STILL accepted. This surprises people
# who write a test that sleeps past exp and expects a 401.
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"SCOPE_profile:read"
--------------------------------------------------------------------------
# T+65s - past exp + the 60s skew window. Now it is refused.
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-08-22T06:16:50Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
# end

View File

@@ -0,0 +1,63 @@
==========================================================================
jwt-auth-demo - built-in resource server
profiles: hs256,resourceserver
==========================================================================
--------------------------------------------------------------------------
# 1. The filter chain. Note BearerTokenAuthenticationFilter in place of our
# hand-written JwtAuthenticationFilter - same slot, framework-owned.
HTTP 200
[
{
"matchesThisRequest": true,
"filters": [
"DisableEncodeUrlFilter",
"WebAsyncManagerIntegrationFilter",
"SecurityContextHolderFilter",
"HeaderWriterFilter",
"LogoutFilter",
"OAuth2ProtectedResourceMetadataFilter",
--------------------------------------------------------------------------
# 2. Access token -> 200.
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"SCOPE_profile:read"
],
"authenticationType": "JwtAuthenticationToken",
"jti": "5dc2e769-9413-44e4-9bcb-6ab9fe1f6b2e",
"issuer": "https://jwt-auth-demo.ankurm.com",
--------------------------------------------------------------------------
# 3. Non-admin on an admin route -> 403 insufficient_scope.
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
# 4. REFRESH token presented as an access token.
# This is the line to watch when comparing the two runs.
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER"
],
"authenticationType": "JwtAuthenticationToken",
"jti": "8e069702-70a3-4029-89e8-d03c8b3e01ce",
"issuer": "https://jwt-auth-demo.ankurm.com",
"audience": [
"jwt-auth-demo-api"
--------------------------------------------------------------------------
# end

View File

@@ -0,0 +1,53 @@
==========================================================================
jwt-auth-demo - built-in resource server
profiles: hs256,resourceserver,strict
==========================================================================
--------------------------------------------------------------------------
# 1. The filter chain. Note BearerTokenAuthenticationFilter in place of our
# hand-written JwtAuthenticationFilter - same slot, framework-owned.
HTTP 200
[
{
"chain": "DefaultSecurityFilterChain defined as 'apiFilterChain' in [class path resource [com/ankurm/jwtauth/config/ResourceServerSecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Logout, OAuth2ProtectedResourceMetadata, BearerTokenAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, SessionManagement, ExceptionTranslation, Authorization]",
"filters": [
"DisableEncodeUrlFilter",
"WebAsyncManagerIntegrationFilter",
"SecurityContextHolderFilter",
"HeaderWriterFilter",
"LogoutFilter",
"OAuth2ProtectedResourceMetadataFilter",
--------------------------------------------------------------------------
# 2. Access token -> 200.
HTTP 200
{
"name": "alice",
"authorities": [
"FACTOR_BEARER",
"ROLE_USER",
"SCOPE_profile:read"
],
"authenticationType": "JwtAuthenticationToken",
"jti": "c140fc47-8b48-41e9-bd01-071d398b6c8b",
"issuer": "https://jwt-auth-demo.ankurm.com",
--------------------------------------------------------------------------
# 3. Non-admin on an admin route -> 403 insufficient_scope.
HTTP 403
WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
--------------------------------------------------------------------------
# 4. REFRESH token presented as an access token.
# This is the line to watch when comparing the two runs.
HTTP 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Expected a token with token_type=access", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
# end

View File

@@ -0,0 +1,83 @@
==========================================================================
jwt-auth-demo - RS256 variant
profiles: rs256 (private key signs, public key / JWKS verifies)
==========================================================================
--------------------------------------------------------------------------
# 1. The public half, published as a JWK Set. No private material here -
# n and e only. Any number of resource servers can poll this.
HTTP 200
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "demo-rsa-2026-08",
"n": "5NEDQPQW0Gz6iR5-UNl7J7660_Psd5q1f5VamK9KTS9f6YhPPIG8mfi6zWe8XmxxdR_Bd2yaX-v_Wz6MgeFLbBVkRFfve_zVnq4-kgjhn8UaRK1iU0C1j-7SahD73hHqGaOjAlFNro5ygjGAcVL8RGVMxRMy4aaTAm3KB4EdG2hJFxyfCBqtkwsHTM_DXcoFTLTZ2bI-hPhN6uBxk7ykFaCnQ47yrKSM6kn0ul0dp22AK_4mP1SRDnnr3Da5GFhMKtBqy_GgXcJ9WTpxIYhlr8B5modtb5S34900VPScpoXiJdUhghxEpXbb1W_PlpVIElISHzldnwWOogdPncO0zQ"
}
]
}
--------------------------------------------------------------------------
# 2. Login. Same endpoint, same request, different signature algorithm.
HTTP 200
{
"accessToken": "eyJraWQiOiJkZW1vLXJzYS0yMDI2LTA4IiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJyb290IiwiYXVkIjoiand0LWF1dGgtZGVtby1hcGkiLCJuYmYiOjE3ODczNzkzOTEsInNjb3BlIjoiYWRtaW46cmVhZCBwcm9maWxlOnJlYWQiLCJyb2xlcyI6WyJBRE1JTiIsIlVTRVIiXSwiaXNzIjoiaHR0cHM6Ly9qd3QtYXV0aC1kZW1vLmFua3VybS5jb20iLCJleHAiOjE3ODczODAyOTEsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3ODczNzkzOTEsImp0aSI6IjIwZWZiMmRhLTkwYWEtNGZjYy04ZTU1LWI0NjhkNDAxMWUwYSJ9.0sPi-ArszI-wKbMuZes2bUCQbi3b68hyNngUPohzrKhWgYlHThu_JIq6gIFcYhq6qK1UYrL2c2lI6uxflSHjdtl5vRhvOUHlDc63eDtzQFIMnC9-kitwwi_x5pV09IxYBVRo38K5WkD9uiIYnNoAenNLhoAfAV424464oi2X_XtsPlXdrhUjCFl8nLghAZSsDaVvTFW9PKHtWfdJUZht2vcZI54TaNFFQKIjAOjTObAwFg9kkzXzcoNiH3wBYKXe4hy3IRDxi62Zl0-eVA67a-ODGqbsPn3BVMbaaLN8iX3OKZtBAUTpYNQQ9StkBfPds5PrxbL_i9EI-j2GpXzA8Q",
"refreshToken": "eyJraWQiOiJkZW1vLXJzYS0yMDI2LTA4IiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJyb290IiwiYXVkIjoiand0LWF1dGgtZGVtby1hcGkiLCJuYmYiOjE3ODczNzkzOTEsImlzcyI6Imh0dHBzOi8vand0LWF1dGgtZGVtby5hbmt1cm0uY29tIiwiZXhwIjoxNzg3NDA4MTkxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImlhdCI6MTc4NzM3OTM5MSwianRpIjoiMjdiOGVhZmMtMGY1NS00YTQ3LWJlYjMtNjJlZDlkOTFmYzgzIn0.pnT-xsAMa-4a1VdUA3_98dGYD-6A7wYKOGUCdmnWbBL44JT2D159w4LASgAAr41Vd7onzlbCgK-cIXlscLmsDSm3Qo0xTO9VjeLYkmFByfSHM7YnG1ecZCuv6PXcqGvpTY_VT35oou0J5qpAl3pBIbkI9mtlI-mBj4ecnlZAEewki_gatloopkDUhg_5xitCeYZfZhB1nvEyCHcv42hpvGHUDBVtebNo6m7rVSpgr7BfVSyAi057qZR0m0Ddm-oJk252vYIQWu9ZH3QE15bMbP7wKOPHfeX1VfuaaUh_o9EgIEblaW1rD9MhupjokUhd2G5OfEbBA_4EDEIB3SpHWg",
"tokenType": "Bearer",
"expiresIn": 900
}
--------------------------------------------------------------------------
# 3. The JOSE header now carries alg=RS256 and the kid that selects the key.
{
"kid": "demo-rsa-2026-08",
"typ": "JWT",
"alg": "RS256"
}
--------------------------------------------------------------------------
# 4. Token length. RS256 signatures are 256 bytes; HS256 signatures are 32.
RS256 access token: 758 characters
signature segment : 343 characters
--------------------------------------------------------------------------
# 5. It works exactly the same from the caller's side.
HTTP 200
{
"name": "root",
"authorities": [
"FACTOR_BEARER",
"ROLE_ADMIN",
"ROLE_USER",
"SCOPE_admin:read",
"SCOPE_profile:read"
],
"authenticationType": "JwtAuthenticationToken",
"jti": "20efb2da-90aa-4fcc-8e55-b468d4011e0a",
"issuer": "https://jwt-auth-demo.ankurm.com",
"audience": [
"jwt-auth-demo-api"
],
"issuedAt": "2026-08-22T06:16:31Z",
"expiresAt": "2026-08-22T06:31:31Z",
"algorithm": "RS256",
"keyId": "demo-rsa-2026-08"
}
--------------------------------------------------------------------------
# 6. Tampered payload, original signature -> 401, same as HS256.
HTTP 401
WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource"
--------------------------------------------------------------------------
# end

14
docs/output/test-run.txt Normal file
View File

@@ -0,0 +1,14 @@
==========================================================================
jwt-auth-demo - test run
==========================================================================
Running com.ankurm.jwtauth.AuthenticationFlowTests
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.134 s -- in com.ankurm.jwtauth.AuthenticationFlowTests
Running com.ankurm.jwtauth.CsrfBreaksPermitAllTests
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.014 s -- in com.ankurm.jwtauth.CsrfBreaksPermitAllTests
Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
JDK : openjdk version "25.0.4.1" 2026-08-18 LTS (Temurin 25.0.4.1+1)
Boot : 4.1.1
Security : 7.1.1