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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
target/
.mvn/
!.mvn/wrapper/maven-wrapper.properties
*.iml
.idea/
.vscode/
.classpath
.project
.settings/
*.log

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

132
README.md Normal file
View File

@@ -0,0 +1,132 @@
# jwt-auth-demo
Runnable companion code for **[Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/)** on ankurm.com.
Everything here was compiled and executed. Every file under [`docs/output/`](docs/output)
is real program output, regenerated by [`scripts/run-all.sh`](scripts/run-all.sh) — not
transcribed by hand.
| | |
|---|---|
| JDK | Temurin **25.0.4.1+1** (current LTS) |
| Spring Boot | **4.1.1** |
| Spring Framework | **7.0.9** |
| Spring Security | **7.1.1** |
| Nimbus JOSE+JWT | **10.9.1** |
| Tomcat | **11.0.24** |
| Jackson | **3.1.5** (`tools.jackson`) |
---
## Quickstart
```bash
git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git
cd jwt-auth-demo
./scripts/run.sh hs256 # or: mvn spring-boot:run -Dspring-boot.run.profiles=hs256
# in another shell
./scripts/curl-transcript.sh # the whole flow, end to end
```
Three demo users:
| username | password | authorities |
|---|---|---|
| `alice` | `alice-password` | `ROLE_USER`, `SCOPE_profile:read` |
| `root` | `root-password` | `ROLE_USER`, `ROLE_ADMIN`, `SCOPE_profile:read`, `SCOPE_admin:read` |
| `locked` | `locked-password` | locked account — always fails login |
---
## Profiles
The same application demonstrates four axes. Combine them freely.
| profile | what it changes |
|---|---|
| `hs256` *(default)* | Symmetric HMAC signing. One secret signs and verifies. |
| `rs256` | RSA signing, plus a real `/.well-known/jwks.json` endpoint. |
| *(none)* | Validation by a hand-written `OncePerRequestFilter`. |
| `resourceserver` | Validation by Spring Security's built-in `oauth2ResourceServer().jwt()`. |
| `strict` | Adds the `token_type` validator to the resource-server chain. |
| `csrfon` | Turns CSRF on, reproducing the "permitAll() returns 403" failure. |
| `shortlived` | 2-second access tokens, for observing expiry and clock skew. |
| `trace` | `TRACE` logging for `org.springframework.security`. |
```bash
./scripts/run.sh rs256
./scripts/run.sh hs256,resourceserver,strict
./scripts/run.sh hs256,csrfon,trace
```
---
## Endpoints
| method | path | rule | why it exists |
|---|---|---|---|
| `POST` | `/api/auth/login` | `permitAll()` | issues an access + refresh token pair |
| `POST` | `/api/auth/refresh` | `permitAll()` | rotates the refresh token |
| `POST` | `/api/auth/logout` | authenticated | revokes the presented token by `jti` |
| `GET` | `/api/public/ping` | `permitAll()` | reachable with no token at all |
| `GET` | `/api/me` | authenticated | **401** without a token |
| `GET` | `/api/admin/stats` | `hasRole('ADMIN')` | **403** with a valid non-admin token |
| `GET` | `/api/reports` | `@PreAuthorize` scope | the method-security twin of the above |
| `GET` | `/api/public/filters` | `permitAll()` | prints the live filter chain |
| `GET` | `/api/async-demo` | authenticated | `SecurityContext` across a thread boundary |
| `GET` | `/.well-known/jwks.json` | `permitAll()` | `rs256` profile only |
---
## Documentation
Start with [`docs/01-architecture.md`](docs/01-architecture.md) and follow the trail.
| doc | covers |
|---|---|
| [01 — Architecture](docs/01-architecture.md) | the whole request path, drawn |
| [02 — Filter chain and ordering](docs/02-filter-chain-and-ordering.md) | where a custom filter goes, and the four ways to place it wrong |
| [03 — 401 vs 403](docs/03-401-vs-403.md) | `ExceptionTranslationFilter`'s actual decision, and RFC 6750 headers |
| [04 — CSRF vs permitAll](docs/04-csrf-permitall-403.md) | why `permitAll()` still returns 403, and when to disable CSRF |
| [05 — HS256 vs RS256](docs/05-hs256-vs-rs256.md) | key handling, JWKS, rotation, algorithm confusion |
| [06 — SecurityContext and statelessness](docs/06-securitycontext-and-statelessness.md) | explicit save, repositories, thread boundaries |
| [07 — Edge cases](docs/07-edge-cases.md) | 18 things that bite, each with the fix |
| [08 — Testing](docs/08-testing.md) | what to pin, and the Boot 4 test-slice split |
| [09 — Manual filter vs resource server](docs/09-manual-filter-vs-resource-server.md) | a side-by-side, and which to pick |
| [10 — Production checklist](docs/10-production-checklist.md) | the list to run before you ship |
| [11 — What changed in Spring Security 7](docs/11-spring-security-7-changes.md) | the 7.x-specific surprises this repo hit |
---
## Captured output
| file | what it shows |
|---|---|
| [`curl-transcript-hs256.txt`](docs/output/curl-transcript-hs256.txt) | 20 steps: login → token → 401 → 403 → tamper → refresh → revoke |
| [`rs256-demo.txt`](docs/output/rs256-demo.txt) | JWKS, `alg=RS256`, signature sizes, tamper rejection |
| [`csrf-vs-permitall.txt`](docs/output/csrf-vs-permitall.txt) | the 403 on a `permitAll()` endpoint |
| [`csrf-trace.txt`](docs/output/csrf-trace.txt) | the TRACE log proving the chain stops at filter 5 of 12 |
| [`expiry-and-clock-skew.txt`](docs/output/expiry-and-clock-skew.txt) | a token still accepted 5s after `exp` |
| [`resource-server-loose.txt`](docs/output/resource-server-loose.txt) | a refresh token accepted as an access token |
| [`resource-server-strict.txt`](docs/output/resource-server-strict.txt) | the same request, refused |
| [`test-run.txt`](docs/output/test-run.txt) | 13 passing tests |
Regenerate all of it:
```bash
./scripts/run-all.sh
```
---
## Security note
The keys in `src/main/resources/` and the HMAC secret in `application.yaml` are
**demo values committed on purpose** so the repository runs with no setup. They are
public. Never point them at anything you care about — see
[docs/10-production-checklist.md](docs/10-production-checklist.md).
## License
MIT.

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

74
pom.xml Normal file
View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>jwt-auth-demo</artifactId>
<version>1.0.0</version>
<name>jwt-auth-demo</name>
<description>Spring Security 7.1 JWT authentication on Spring Boot 4.1 - runnable companion for ankurm.com</description>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--
Spring Boot 4 split the test slices into their own modules. @AutoConfigureMockMvc
and @WebMvcTest now live in spring-boot-starter-webmvc-test, and the Spring
Security test support arrives via spring-boot-starter-security-test. On Boot 3
spring-boot-starter-test alone was enough; on Boot 4 it is not.
-->
<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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

36
scripts/csrf-demo.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Reproduces the "permitAll() endpoint still returns 403" failure.
# Requires: --spring.profiles.active=hs256,csrfon
set -u
BASE="${1:-http://localhost:8080}"
show() {
local out
out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^(www-authenticate|content-type):' /tmp/.h | sed 's/\r$//'
[ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null || cat /tmp/.b; echo; }
}
hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; }
echo "=========================================================================="
echo " jwt-auth-demo - CSRF vs permitAll()"
echo " app started with: --spring.profiles.active=hs256,csrfon"
echo "=========================================================================="
hr; echo "# A. The login endpoint is permitAll(). POST it anyway."
echo "# 403 - and nothing in the authorization rules explains why."
echo
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice-password"}'
hr; echo "# B. GET on the same permitAll() path family works. Only unsafe methods break."
echo
show "$BASE/api/public/ping"
hr; echo "# C. The filter chain, with CsrfFilter present. Count the positions:"
echo "# CsrfFilter is 5th, AuthorizationFilter is last. The request never"
echo "# reaches the filter that knows about permitAll()."
echo
show "$BASE/api/public/filters"
hr; echo "# end"

112
scripts/curl-transcript.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Regenerates docs/output/curl-transcript.txt against a running instance.
# Usage: ./scripts/curl-transcript.sh [base-url]
set -u
BASE="${1:-http://localhost:8080}"
hr() { printf '\n%s\n' "--------------------------------------------------------------------------"; }
step(){ hr; printf '# %s\n\n' "$1"; }
# Print status line, the security-relevant headers, and the body.
show() {
local out
out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^(www-authenticate|content-type|set-cookie):' /tmp/.h | sed 's/\r$//'
if [ -s /tmp/.b ]; then
python3 -m json.tool < /tmp/.b 2>/dev/null || cat /tmp/.b
echo
fi
}
jwt_part() { # $1=token $2=0|1 -> pretty-print header or payload
echo "$1" | cut -d. -f$(( $2 + 1 )) \
| tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null \
| python3 -m json.tool 2>/dev/null
}
echo "=========================================================================="
echo " jwt-auth-demo - curl transcript"
echo " target : $BASE"
echo "=========================================================================="
step "1. Public endpoint, no token. permitAll() means the filter chain lets it through."
show "$BASE/api/public/ping"
step "2. Protected endpoint, no token. 401 - we do not know who you are."
show "$BASE/api/me"
step "3. Wrong password. Still 401, and the body says nothing about which half was wrong."
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"wrong-password"}'
step "4. Locked account. 401 with the identical body - no account-state oracle."
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"locked","password":"locked-password"}'
step "5. Login as alice (ROLE_USER, SCOPE_profile:read)."
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice-password"}'
ALICE=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])")
ALICE_REFRESH=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['refreshToken'])")
step "6. What is actually inside that token (base64url decode - no signature check)."
echo "--- JOSE header ---"
jwt_part "$ALICE" 0
echo "--- claims ---"
jwt_part "$ALICE" 1
step "7. The same protected endpoint, now with the token. 200."
show "$BASE/api/me" -H "Authorization: Bearer $ALICE"
step "8. alice hits an ADMIN endpoint. 403, not 401 - we know who she is, she may not."
show "$BASE/api/admin/stats" -H "Authorization: Bearer $ALICE"
step "9. Same request with a scope-based rule (@PreAuthorize). Also 403."
show "$BASE/api/reports" -H "Authorization: Bearer $ALICE"
step "10. Login as root and repeat. 200."
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"root","password":"root-password"}'
ROOT=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])")
show "$BASE/api/admin/stats" -H "Authorization: Bearer $ROOT"
step "11. Tampered payload, original signature. 401 invalid_token."
HDR=$(echo "$ALICE" | cut -d. -f1); SIG=$(echo "$ALICE" | cut -d. -f3)
FORGED_PAYLOAD=$(echo "$ALICE" | cut -d. -f2 | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null \
| sed 's/"roles":\["USER"\]/"roles":["ADMIN"]/' | base64 -w0 | tr '/+' '_-' | tr -d '=')
show "$BASE/api/admin/stats" -H "Authorization: Bearer $HDR.$FORGED_PAYLOAD.$SIG"
step "12. Garbage where a token should be. 401, and note it is NOT 400."
show "$BASE/api/me" -H "Authorization: Bearer not-a-jwt"
step "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."
show "$BASE/api/me" -H "Authorization: $ALICE"
step "14. A refresh token presented as an access token. 401 - the token_type claim."
show "$BASE/api/me" -H "Authorization: Bearer $ALICE_REFRESH"
step "15. Refresh with rotation. New access token, new refresh token."
show -X POST "$BASE/api/auth/refresh" -H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$ALICE_REFRESH\"}"
ALICE2=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])")
step "16. Replay the spent refresh token. 401 - rotation makes replay detectable."
show -X POST "$BASE/api/auth/refresh" -H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$ALICE_REFRESH\"}"
step "17. Logout revokes the presented access token by its jti."
show -X POST "$BASE/api/auth/logout" -H "Authorization: Bearer $ALICE2"
show "$BASE/api/auth/revocations" -H "Authorization: Bearer $ROOT"
step "18. The revoked token, still cryptographically valid, is now refused."
show "$BASE/api/me" -H "Authorization: Bearer $ALICE2"
step "19. The real filter order, read from FilterChainProxy at runtime."
show "$BASE/api/public/filters"
step "20. SecurityContext across a thread boundary."
show "$BASE/api/async-demo" -H "Authorization: Bearer $ROOT"
hr
echo "# end of transcript"

32
scripts/expiry-demo.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Requires: --spring.profiles.active=hs256,shortlived (2-second access tokens)
set -u
BASE="${1:-http://localhost:8080}"
show() {
local out; out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^www-authenticate:' /tmp/.h | sed 's/\r$//'
[ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null | head -6 || cat /tmp/.b; }
echo
}
hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; }
echo "=========================================================================="
echo " jwt-auth-demo - token expiry and the 60-second clock skew"
echo " profiles: hs256,shortlived (access-token-ttl = 2s)"
echo "=========================================================================="
TOK=$(curl -sS -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice-password"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["accessToken"])')
hr; echo "# T+0s - fresh token"; echo; show "$BASE/api/me" -H "Authorization: Bearer $TOK"
hr; echo "# T+5s - exp has passed, but JwtTimestampValidator allows 60s of clock skew"
echo "# by default, so the token is STILL accepted. This surprises people"
echo "# who write a test that sleeps past exp and expects a 401."
echo; sleep 5; show "$BASE/api/me" -H "Authorization: Bearer $TOK"
hr; echo "# T+65s - past exp + the 60s skew window. Now it is refused."
echo; sleep 60; show "$BASE/api/me" -H "Authorization: Bearer $TOK"
hr; echo "# end"

38
scripts/resource-server-demo.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Compares the built-in resource server with and without a token_type validator.
# Requires two runs; see docs/09-manual-filter-vs-resource-server.md
set -u
BASE="${1:-http://localhost:8080}"
LABEL="${2:-resourceserver}"
show() {
local out; out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^www-authenticate:' /tmp/.h | sed 's/\r$//'
[ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null | head -10 || cat /tmp/.b; echo; }
}
hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; }
echo "=========================================================================="
echo " jwt-auth-demo - built-in resource server"
echo " profiles: $LABEL"
echo "=========================================================================="
R=$(curl -sS -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice-password"}')
ACCESS=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["accessToken"])')
REFRESH=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["refreshToken"])')
hr; echo "# 1. The filter chain. Note BearerTokenAuthenticationFilter in place of our"
echo "# hand-written JwtAuthenticationFilter - same slot, framework-owned."
echo; show "$BASE/api/public/filters"
hr; echo "# 2. Access token -> 200."; echo; show "$BASE/api/me" -H "Authorization: Bearer $ACCESS"
hr; echo "# 3. Non-admin on an admin route -> 403 insufficient_scope."
echo; show "$BASE/api/admin/stats" -H "Authorization: Bearer $ACCESS"
hr; echo "# 4. REFRESH token presented as an access token."
echo "# This is the line to watch when comparing the two runs."
echo; show "$BASE/api/me" -H "Authorization: Bearer $REFRESH"
hr; echo "# end"

45
scripts/rs256-demo.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Requires: --spring.profiles.active=rs256
set -u
BASE="${1:-http://localhost:8080}"
show() {
local out; out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^www-authenticate:' /tmp/.h | sed 's/\r$//'
[ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null || cat /tmp/.b; echo; }
}
jwt_part(){ echo "$1" | cut -d. -f$(( $2 + 1 )) | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null | python3 -m json.tool 2>/dev/null; }
hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; }
echo "=========================================================================="
echo " jwt-auth-demo - RS256 variant"
echo " profiles: rs256 (private key signs, public key / JWKS verifies)"
echo "=========================================================================="
hr; echo "# 1. The public half, published as a JWK Set. No private material here -"
echo "# n and e only. Any number of resource servers can poll this."
echo; show "$BASE/.well-known/jwks.json"
hr; echo "# 2. Login. Same endpoint, same request, different signature algorithm."
echo; show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"root","password":"root-password"}'
TOK=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])")
hr; echo "# 3. The JOSE header now carries alg=RS256 and the kid that selects the key."
echo; jwt_part "$TOK" 0
hr; echo "# 4. Token length. RS256 signatures are 256 bytes; HS256 signatures are 32."
echo
printf ' RS256 access token: %s characters\n' "${#TOK}"
printf ' signature segment : %s characters\n' "$(echo "$TOK" | cut -d. -f3 | wc -c)"
hr; echo "# 5. It works exactly the same from the caller's side."
echo; show "$BASE/api/me" -H "Authorization: Bearer $TOK"
hr; echo "# 6. Tampered payload, original signature -> 401, same as HS256."
HDR=$(echo "$TOK" | cut -d. -f1); SIG=$(echo "$TOK" | cut -d. -f3)
FORGED=$(echo "$TOK" | cut -d. -f2 | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null \
| sed 's/"sub":"root"/"sub":"mallory"/' | base64 -w0 | tr '/+' '_-' | tr -d '=')
echo; show "$BASE/api/me" -H "Authorization: Bearer $HDR.$FORGED.$SIG"
hr; echo "# end"

42
scripts/run-all.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Regenerates every file under docs/output/ from a real run.
# Usage: ./scripts/run-all.sh
set -eu
cd "$(dirname "$0")/.."
echo "==> mvn test"
{
echo "=========================================================================="
echo " jwt-auth-demo - test run"
echo "=========================================================================="
echo
mvn -B test 2>&1 | grep -E '^\[INFO\] (Running|Tests run)|^\[ERROR\]|BUILD (SUCCESS|FAILURE)' \
| sed 's/^\[INFO\] //'
echo
echo "JDK : $(java -version 2>&1 | grep -v JAVA_TOOL | head -1)"
echo "Boot : 4.1.1"
echo "Security : 7.1.1"
} > docs/output/test-run.txt
echo "==> hs256 (manual filter) transcript"
./scripts/run.sh hs256 >/dev/null && ./scripts/curl-transcript.sh > docs/output/curl-transcript-hs256.txt 2>&1
echo "==> rs256 transcript"
./scripts/run.sh rs256 >/dev/null && ./scripts/rs256-demo.sh > docs/output/rs256-demo.txt 2>&1
echo "==> csrf vs permitAll"
./scripts/run.sh hs256,csrfon >/dev/null && ./scripts/csrf-demo.sh > docs/output/csrf-vs-permitall.txt 2>&1
echo "==> expiry and clock skew (takes ~70s)"
./scripts/run.sh hs256,shortlived >/dev/null && ./scripts/expiry-demo.sh > docs/output/expiry-and-clock-skew.txt 2>&1
echo "==> built-in resource server, without and with the token_type validator"
./scripts/run.sh hs256,resourceserver >/dev/null \
&& ./scripts/resource-server-demo.sh http://localhost:8080 "hs256,resourceserver" \
> docs/output/resource-server-loose.txt 2>&1
./scripts/run.sh hs256,resourceserver,strict >/dev/null \
&& ./scripts/resource-server-demo.sh http://localhost:8080 "hs256,resourceserver,strict" \
> docs/output/resource-server-strict.txt 2>&1
for p in $(ps -eo pid,cmd | grep '[J]wtAuthDemoApplication' | awk '{print $1}'); do kill -9 "$p" || true; done
echo "==> done. docs/output/ regenerated."

16
scripts/run.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Starts the app on a clean port 8080 with the given profiles.
# Usage: ./scripts/run.sh [profiles] e.g. ./scripts/run.sh rs256,resourceserver
set -eu
PROFILES="${1:-hs256}"
for p in $(ps -eo pid,cmd | grep '[J]wtAuthDemoApplication' | awk '{print $1}'); do kill -9 "$p" || true; done
sleep 2
setsid nohup mvn -B -o org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.profiles="$PROFILES" > "/tmp/app-${PROFILES//,/-}.log" 2>&1 < /dev/null &
for i in $(seq 1 60); do
if curl -s -o /dev/null http://localhost:8080/api/public/ping 2>/dev/null; then
echo "started with profiles: $PROFILES"; exit 0
fi
sleep 2
done
echo "failed to start; see /tmp/app-${PROFILES//,/-}.log" >&2; exit 1

View File

@@ -0,0 +1,31 @@
package com.ankurm.jwtauth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Runnable companion for the ankurm.com article
* "Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)".
*
* <p>Two signing variants are wired as Spring profiles:
* <ul>
* <li>{@code hs256} (default) - symmetric HMAC, one shared secret.</li>
* <li>{@code rs256} - asymmetric RSA, private key signs, public key (JWKS) verifies.</li>
* </ul>
*
* <p>Two validation styles are wired as Spring profiles too:
* <ul>
* <li>{@code manual} (default) - a hand-written {@code OncePerRequestFilter}.</li>
* <li>{@code resourceserver} - Spring Security's built-in
* {@code oauth2ResourceServer().jwt()} support.</li>
* </ul>
*
* @see docs/01-architecture.md
*/
@SpringBootApplication
public class JwtAuthDemoApplication {
public static void main(String[] args) {
SpringApplication.run(JwtAuthDemoApplication.class, args);
}
}

View File

@@ -0,0 +1,121 @@
package com.ankurm.jwtauth.auth;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ankurm.jwtauth.auth.AuthDtos.LoginRequest;
import com.ankurm.jwtauth.auth.AuthDtos.RefreshRequest;
import com.ankurm.jwtauth.auth.AuthDtos.TokenResponse;
/**
* The login endpoint. It is the only place that sees a password, and the only
* place that calls the {@code AuthenticationManager}.
*
* <p>Everything after this point in the system is stateless: no session is created,
* and the {@code SecurityContext} written here is deliberately NOT persisted.
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
private final JwtDecoder jwtDecoder;
private final UserDetailsService userDetailsService;
private final RevokedTokenStore revokedTokens;
public AuthController(AuthenticationManager authenticationManager,
TokenService tokenService,
JwtDecoder jwtDecoder,
UserDetailsService userDetailsService,
RevokedTokenStore revokedTokens) {
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
this.jwtDecoder = jwtDecoder;
this.userDetailsService = userDetailsService;
this.revokedTokens = revokedTokens;
}
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest request) {
// Throws BadCredentialsException / LockedException / DisabledException,
// all AuthenticationException subtypes -> 401 via the exception handler.
Authentication authentication = this.authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(
request.username(), request.password()));
TokenService.IssuedToken access = this.tokenService.issueAccessToken(authentication);
TokenService.IssuedToken refresh = this.tokenService.issueRefreshToken(authentication.getName());
return new TokenResponse(access.value(), refresh.value(), "Bearer", access.expiresInSeconds());
}
/**
* Refresh with rotation: the presented refresh token is revoked as it is spent.
* Replaying it is then a detectable event, not a silent success.
*/
@PostMapping("/refresh")
public TokenResponse refresh(@Valid @RequestBody RefreshRequest request) {
Jwt jwt;
try {
jwt = this.jwtDecoder.decode(request.refreshToken());
}
catch (JwtException ex) {
throw new BadCredentialsException("Refresh token is not valid", ex);
}
if (!TokenService.REFRESH.equals(jwt.getClaimAsString("token_type"))) {
throw new BadCredentialsException("Not a refresh token");
}
if (this.revokedTokens.isRevoked(jwt.getId())) {
throw new BadCredentialsException("Refresh token already used or revoked");
}
this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt());
UserDetails user = this.userDetailsService.loadUserByUsername(jwt.getSubject());
Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(
user, null, user.getAuthorities());
TokenService.IssuedToken access = this.tokenService.issueAccessToken(authentication);
TokenService.IssuedToken newRefresh = this.tokenService.issueRefreshToken(user.getUsername());
return new TokenResponse(access.value(), newRefresh.value(), "Bearer", access.expiresInSeconds());
}
/** Revokes the presented access token by jti. Requires a valid token to call. */
@PostMapping("/logout")
public ResponseEntity<Void> logout(Authentication authentication) {
if (authentication instanceof
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt());
}
return ResponseEntity.noContent().build();
}
@GetMapping("/revocations")
public java.util.Map<String, Integer> revocations() {
return java.util.Map.of("revokedTokens", this.revokedTokens.size());
}
static class LoginFailed extends RuntimeException {
LoginFailed(AuthenticationException cause) {
super(cause);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.ankurm.jwtauth.auth;
import jakarta.validation.constraints.NotBlank;
public final class AuthDtos {
private AuthDtos() { }
public record LoginRequest(@NotBlank String username, @NotBlank String password) { }
public record RefreshRequest(@NotBlank String refreshToken) { }
public record TokenResponse(String accessToken,
String refreshToken,
String tokenType,
long expiresIn) { }
}

View File

@@ -0,0 +1,178 @@
package com.ankurm.jwtauth.auth;
import java.io.IOException;
import java.util.List;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* The hand-written half of the flow: read the bearer token, verify it, put an
* {@code Authentication} in the {@code SecurityContext}, continue the chain.
*
* <p>Five details separate a filter that works from one that only appears to:
* <ol>
* <li>It extends {@link OncePerRequestFilter}, so a {@code FORWARD} to an error
* page or a {@code @Async} dispatch does not run authentication twice.</li>
* <li>No token present is <em>not</em> an error. The filter continues the chain and
* lets {@code AuthorizationFilter} decide - that is what makes {@code permitAll()}
* endpoints reachable without a token.</li>
* <li>A token that <em>is</em> present but bad is an error, and the chain stops. The
* alternative - continuing anonymously - turns a forged token into a 403 on a
* protected endpoint and a silent 200 on a public one.</li>
* <li>The context is written through {@link SecurityContextHolderStrategy}, not the
* static {@code SecurityContextHolder} setters, and also saved into the
* {@link SecurityContextRepository} so it survives a dispatch.</li>
* <li>The context is cleared on failure, so a pooled thread cannot leak a previous
* request's principal.</li>
* </ol>
*
* @see docs/02-filter-chain-and-ordering.md
*/
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtDecoder jwtDecoder;
private final RevokedTokenStore revokedTokens;
private final AuthenticationEntryPoint entryPoint;
private final BearerTokenResolver bearerTokenResolver = new DefaultBearerTokenResolver();
private final JwtAuthenticationConverter authenticationConverter = defaultConverter();
private final SecurityContextHolderStrategy contextHolderStrategy =
SecurityContextHolder.getContextHolderStrategy();
private final SecurityContextRepository contextRepository =
new RequestAttributeSecurityContextRepository();
public JwtAuthenticationFilter(JwtDecoder jwtDecoder,
RevokedTokenStore revokedTokens,
AuthenticationEntryPoint entryPoint) {
this.jwtDecoder = jwtDecoder;
this.revokedTokens = revokedTokens;
this.entryPoint = entryPoint;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token;
try {
token = this.bearerTokenResolver.resolve(request);
}
catch (OAuth2AuthenticationException ex) {
// Malformed Authorization header, or a token in two places at once.
this.contextHolderStrategy.clearContext();
this.entryPoint.commence(request, response, ex);
return;
}
if (token == null) {
// (2) No credentials offered. Not our business - let authorization decide.
filterChain.doFilter(request, response);
return;
}
try {
Jwt jwt = this.jwtDecoder.decode(token);
assertIsAccessToken(jwt);
assertNotRevoked(jwt);
SecurityContext context = this.contextHolderStrategy.createEmptyContext();
context.setAuthentication(this.authenticationConverter.convert(jwt));
this.contextHolderStrategy.setContext(context);
this.contextRepository.saveContext(context, request, response);
}
catch (JwtException | OAuth2AuthenticationException ex) {
// (3) and (5): stop the chain, clear the context, answer 401.
this.contextHolderStrategy.clearContext();
this.entryPoint.commence(request, response, asAuthenticationException(ex));
return;
}
filterChain.doFilter(request, response);
}
private void assertIsAccessToken(Jwt jwt) {
if (!TokenService.ACCESS.equals(jwt.getClaimAsString("token_type"))) {
throw invalidToken("This endpoint accepts access tokens only");
}
}
private void assertNotRevoked(Jwt jwt) {
if (this.revokedTokens.isRevoked(jwt.getId())) {
throw invalidToken("Token has been revoked");
}
}
private static OAuth2AuthenticationException invalidToken(String description) {
OAuth2Error error = new OAuth2Error(
BearerTokenErrorCodes.INVALID_TOKEN,
description,
"https://tools.ietf.org/html/rfc6750#section-3.1");
return new OAuth2AuthenticationException(error, description);
}
/**
* Detail that decides what the client sees. {@code BearerTokenAuthenticationEntryPoint}
* only writes {@code error="invalid_token"} into WWW-Authenticate when the exception
* carries a {@code BearerTokenError}. Wrap a {@code JwtException} in a plain
* {@code AuthenticationServiceException} and the client gets a bare
* {@code WWW-Authenticate: Bearer realm="..."} with no reason at all.
*/
private static org.springframework.security.core.AuthenticationException asAuthenticationException(
Exception ex) {
if (ex instanceof org.springframework.security.core.AuthenticationException authEx) {
return authEx;
}
return new InvalidBearerTokenException(ex.getMessage(), ex);
}
private static JwtAuthenticationConverter defaultConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(JwtAuthenticationFilter::authorities);
return converter;
}
/**
* "scope" -> SCOPE_x (Spring's default) plus "roles" -> ROLE_x, so
* {@code hasRole("ADMIN")} and {@code hasAuthority("SCOPE_admin:read")} both work.
*/
private static List<GrantedAuthority> authorities(Jwt jwt) {
List<GrantedAuthority> result = new java.util.ArrayList<>();
String scope = jwt.getClaimAsString("scope");
if (scope != null && !scope.isBlank()) {
for (String s : scope.split("\\s+")) {
result.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(
"SCOPE_" + s));
}
}
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
for (String role : roles) {
result.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(
"ROLE_" + role));
}
}
return result;
}
}

View File

@@ -0,0 +1,40 @@
package com.ankurm.jwtauth.auth;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
/**
* The smallest thing that makes a stateless token revocable: a jti denylist.
*
* <p>Entries only need to outlive the token's own expiry, so the map self-prunes.
* In production this is Redis with a TTL, not a map - but the shape is identical.
* See docs/07-edge-cases.md#logout-and-revocation.
*/
@Component
public class RevokedTokenStore {
private final Map<String, Instant> revoked = new ConcurrentHashMap<>();
public void revoke(String jti, Instant expiresAt) {
prune();
this.revoked.put(jti, expiresAt);
}
public boolean isRevoked(String jti) {
prune();
return jti != null && this.revoked.containsKey(jti);
}
public int size() {
prune();
return this.revoked.size();
}
private void prune() {
Instant now = Instant.now();
this.revoked.entrySet().removeIf(e -> e.getValue() != null && e.getValue().isBefore(now));
}
}

View File

@@ -0,0 +1,112 @@
package com.ankurm.jwtauth.auth;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
/**
* Mints the tokens. This is the "token issue" step of the flow.
*
* <p>Two claims here are not decoration:
* <ul>
* <li>{@code token_type} - separates access tokens from refresh tokens. Without it,
* a refresh token is a perfectly valid access token, because both are signed
* by the same key. See docs/07-edge-cases.md#refresh-token-as-access-token.</li>
* <li>{@code jti} - a per-token id, which is what a denylist keys on. A JWT is not
* revocable without one.</li>
* </ul>
*/
@Service
public class TokenService {
public static final String ACCESS = "access";
public static final String REFRESH = "refresh";
private final JwtEncoder encoder;
private final String issuer;
private final String audience;
private final Duration accessTtl;
private final Duration refreshTtl;
public TokenService(JwtEncoder encoder,
@Value("${demo.jwt.issuer}") String issuer,
@Value("${demo.jwt.audience}") String audience,
@Value("${demo.jwt.access-token-ttl}") Duration accessTtl,
@Value("${demo.jwt.refresh-token-ttl}") Duration refreshTtl) {
this.encoder = encoder;
this.issuer = issuer;
this.audience = audience;
this.accessTtl = accessTtl;
this.refreshTtl = refreshTtl;
}
public IssuedToken issueAccessToken(Authentication authentication) {
List<String> authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.sorted()
.toList();
// Spring Security's default JwtGrantedAuthoritiesConverter reads the "scope"
// claim and prefixes each value with SCOPE_. We keep roles in a separate
// "roles" claim so the two authority families stay distinguishable.
String scope = authorities.stream()
.filter(a -> a.startsWith("SCOPE_"))
.map(a -> a.substring("SCOPE_".length()))
.collect(Collectors.joining(" "));
List<String> roles = authorities.stream()
.filter(a -> a.startsWith("ROLE_"))
.map(a -> a.substring("ROLE_".length()))
.toList();
return encode(authentication.getName(), ACCESS, this.accessTtl, claims -> claims
.claim("scope", scope)
.claim("roles", roles));
}
public IssuedToken issueRefreshToken(String subject) {
return encode(subject, REFRESH, this.refreshTtl, claims -> { });
}
private IssuedToken encode(String subject, String tokenType, Duration ttl,
java.util.function.Consumer<JwtClaimsSet.Builder> extra) {
Instant now = Instant.now();
String jti = UUID.randomUUID().toString();
JwtClaimsSet.Builder claims = JwtClaimsSet.builder()
.issuer(this.issuer)
.audience(List.of(this.audience))
.subject(subject)
.id(jti) // -> "jti"
.issuedAt(now) // -> "iat"
.notBefore(now) // -> "nbf"
.expiresAt(now.plus(ttl)) // -> "exp"
.claim("token_type", tokenType);
extra.accept(claims);
// Passing JwsHeader explicitly is optional - the encoder derives the algorithm
// from the key - but being explicit documents intent and fails loudly on a
// key/algorithm mismatch.
Jwt jwt = this.encoder.encode(JwtEncoderParameters.from(claims.build()));
return new IssuedToken(jwt.getTokenValue(), jti, jwt.getExpiresAt(), ttl.toSeconds());
}
/** Unused overload kept to show the explicit-header form. */
@SuppressWarnings("unused")
private Jwt encodeWithExplicitHeader(JwsHeader header, JwtClaimsSet claims) {
return this.encoder.encode(JwtEncoderParameters.from(header, claims));
}
public record IssuedToken(String value, String jti, Instant expiresAt, long expiresInSeconds) { }
}

View File

@@ -0,0 +1,41 @@
package com.ankurm.jwtauth.config;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Login failures arrive here, not at the {@code AuthenticationEntryPoint} - the
* controller calls {@code AuthenticationManager} itself, so the exception is a plain
* MVC exception by the time anything security-shaped could see it.
*
* <p>Note every branch answers 401 with the same body. Telling a caller that the
* username exists but the password is wrong is a user-enumeration oracle.
*/
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler({BadCredentialsException.class, LockedException.class, DisabledException.class})
public ProblemDetail onAuthenticationFailure(AuthenticationException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
problem.setType(URI.create("https://ankurm.com/problems/invalid-credentials"));
problem.setTitle("Authentication failed");
problem.setDetail("Invalid username or password");
return problem;
}
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail onAuthentication(AuthenticationException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
problem.setTitle("Authentication failed");
problem.setDetail("Invalid username or password");
return problem;
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.jwtauth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/**
* Demo user store. In-memory on purpose: this repository is about the token
* pipeline, not about where users live.
*
* <p>Note there is deliberately NO {@code AuthenticationManager} bean exposed by
* auto-configuration once a {@code SecurityFilterChain} bean exists - we build one
* explicitly in {@link SecurityConfig} so the login endpoint can call it.
*/
@Configuration
public class AppUsers {
@Bean
public PasswordEncoder passwordEncoder() {
// DelegatingPasswordEncoder: stores {bcrypt}$2a$... so the hash format is upgradable.
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
UserDetails alice = User.withUsername("alice")
.password(encoder.encode("alice-password"))
.authorities("ROLE_USER", "SCOPE_profile:read")
.build();
UserDetails root = User.withUsername("root")
.password(encoder.encode("root-password"))
.authorities("ROLE_USER", "ROLE_ADMIN", "SCOPE_profile:read", "SCOPE_admin:read")
.build();
UserDetails locked = User.withUsername("locked")
.password(encoder.encode("locked-password"))
.authorities("ROLE_USER")
.accountLocked(true)
.build();
return new InMemoryUserDetailsManager(alice, root, locked);
}
}

View File

@@ -0,0 +1,74 @@
package com.ankurm.jwtauth.config;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
/**
* HS256 variant: one symmetric secret both signs and verifies.
*
* <p>The secret must be at least 256 bits (32 bytes) for HS256 - Nimbus enforces
* this and throws {@code KeyLengthException} otherwise. See docs/05-hs256-vs-rs256.md.
*/
@Configuration
@Profile("hs256")
public class Hs256KeyConfig {
private final SecretKey secretKey;
private final String issuer;
private final String audience;
public Hs256KeyConfig(@Value("${demo.jwt.hmac-secret}") String secret,
@Value("${demo.jwt.issuer}") String issuer,
@Value("${demo.jwt.audience}") String audience) {
this.issuer = issuer;
this.audience = audience;
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalStateException(
"demo.jwt.hmac-secret must be >= 32 bytes for HS256, got " + bytes.length);
}
this.secretKey = new SecretKeySpec(bytes, "HmacSHA256");
}
@Bean
public JwtEncoder jwtEncoder() {
// Spring Security 7.0 added the withSecretKey builder; the older
// new NimbusJwtEncoder(new ImmutableSecret<>(key)) form still works.
return NimbusJwtEncoder.withSecretKey(this.secretKey)
.algorithm(MacAlgorithm.HS256)
.build();
}
@Bean
public JwtDecoder jwtDecoder(ObjectProvider<OAuth2TokenValidator<Jwt>> extraValidators) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(this.secretKey)
.macAlgorithm(MacAlgorithm.HS256) // pin the algorithm - see docs/07-edge-cases.md
.build();
decoder.setJwtValidator(JwtValidatorFactory.compose(this.issuer, this.audience, extraValidators));
return decoder;
}
/** Kept only to show the pre-7.0 constructor still compiles. Not a bean. */
@SuppressWarnings("unused")
private JwtEncoder legacyStyleEncoder() {
ImmutableSecret<SecurityContext> jwkSource = new ImmutableSecret<>(this.secretKey);
return new NimbusJwtEncoder(jwkSource);
}
}

View File

@@ -0,0 +1,37 @@
package com.ankurm.jwtauth.config;
import java.util.ArrayList;
import java.util.List;
import com.ankurm.jwtauth.edge.AudienceValidator;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtValidators;
/**
* Builds the validator stack applied to every decoded token.
*
* <p>What {@code JwtValidators.createDefaultWithIssuer(issuer)} gives you:
* {@code exp} and {@code nbf} (with 60 seconds of clock skew) plus {@code iss}.
* What it does NOT give you: {@code aud}. That one is added here explicitly,
* because a token minted for another service in the same estate is otherwise
* accepted without complaint.
*
* @see docs/07-edge-cases.md
*/
final class JwtValidatorFactory {
private JwtValidatorFactory() { }
static OAuth2TokenValidator<Jwt> compose(String issuer,
String audience,
ObjectProvider<OAuth2TokenValidator<Jwt>> extras) {
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(JwtValidators.createDefaultWithIssuer(issuer)); // exp, nbf, iss
validators.add(AudienceValidator.forAudience(audience)); // aud - not default
extras.orderedStream().forEach(validators::add); // e.g. token_type
return new DelegatingOAuth2TokenValidator<>(validators);
}
}

View File

@@ -0,0 +1,117 @@
package com.ankurm.jwtauth.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.context.NullSecurityContextRepository;
/**
* The same API secured by Spring Security's built-in resource server instead of a
* hand-written filter. Run with {@code --spring.profiles.active=hs256,resourceserver}.
*
* <p>What you give up: the {@code token_type} check and the jti denylist have to move
* into an {@code OAuth2TokenValidator} (see
* {@link com.ankurm.jwtauth.edge.AccessTokenTypeValidator}).
* What you gain: {@code BearerTokenAuthenticationFilter},
* {@code BearerTokenAuthenticationEntryPoint} and {@code BearerTokenAccessDeniedHandler}
* are wired for you, with the correct WWW-Authenticate headers on both 401 and 403.
*
* @see docs/09-manual-filter-vs-resource-server.md
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@Profile("resourceserver")
public class ResourceServerSecurityConfig {
@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityContext(context -> context
.securityContextRepository(new NullSecurityContextRepository()))
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/login", "/api/auth/refresh").permitAll()
.requestMatchers("/.well-known/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
// One line replaces the whole custom filter. The JwtDecoder bean is picked
// up automatically; BearerTokenAuthenticationFilter is inserted in the
// right place; 401 and 403 handlers come with it.
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
/** Same authority mapping as the manual filter, expressed the framework's way. */
private Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
scopes.setAuthoritiesClaimName("scope");
scopes.setAuthorityPrefix("SCOPE_");
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles");
roles.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> all = new ArrayList<>(scopes.convert(jwt));
all.addAll(roles.convert(jwt));
return all;
});
return converter;
}
/**
* Only registered under the {@code strict} profile - on purpose. Run without it
* and a refresh token authenticates as an access token; run with it and the same
* request is a 401. The built-in resource server has no opinion about your
* private claims until you give it one.
*/
@Bean
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(
name = "demo.validate-token-type", havingValue = "true")
public org.springframework.security.oauth2.core.OAuth2TokenValidator<
org.springframework.security.oauth2.jwt.Jwt> accessTokenTypeValidator() {
return new com.ankurm.jwtauth.edge.AccessTokenTypeValidator();
}
@Bean
public AuthenticationManager authenticationManager(UserDetailsService users,
PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
/** Unused, kept as documentation of the alternative authority mapping. */
@SuppressWarnings("unused")
private GrantedAuthority example() {
return new SimpleGrantedAuthority("SCOPE_profile:read");
}
}

View File

@@ -0,0 +1,120 @@
package com.ankurm.jwtauth.config;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Map;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* RS256 variant: the private key signs, the public key verifies.
*
* <p>The public half is also published at {@code /.well-known/jwks.json} so a
* separate resource server could verify tokens without ever seeing the private key.
*/
@Configuration
@Profile("rs256")
public class Rs256KeyConfig {
private final RSAPublicKey publicKey;
private final RSAPrivateKey privateKey;
private final String keyId = "demo-rsa-2026-08";
private final String issuer;
private final String audience;
public Rs256KeyConfig(@Value("${demo.jwt.issuer}") String issuer,
@Value("${demo.jwt.audience}") String audience) throws Exception {
this.issuer = issuer;
this.audience = audience;
this.privateKey = readPrivateKey("demo-private-key.pem");
this.publicKey = readPublicKey("demo-public-key.pem");
}
@Bean
public JwtEncoder jwtEncoder() {
// Note: the builder method is algorithm(..), not jwsAlgorithm(..), and there is
// no keyId(..) - the key id is set by post-processing the Nimbus JWK builder.
return NimbusJwtEncoder.withKeyPair(this.publicKey, this.privateKey)
.algorithm(SignatureAlgorithm.RS256)
.jwkPostProcessor(jwk -> jwk.keyID(this.keyId))
.build();
}
@Bean
public JwtDecoder jwtDecoder(ObjectProvider<OAuth2TokenValidator<Jwt>> extraValidators) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(this.publicKey)
.signatureAlgorithm(SignatureAlgorithm.RS256) // pin it - see docs/07-edge-cases.md
.build();
decoder.setJwtValidator(JwtValidatorFactory.compose(this.issuer, this.audience, extraValidators));
return decoder;
}
/**
* A minimal JWKS endpoint. A real authorization server publishes this and the
* resource server points at it with {@code NimbusJwtDecoder.withJwkSetUri(...)},
* which then rotates keys automatically by {@code kid}.
*/
@RestController
@Profile("rs256")
public static class JwkSetEndpoint {
private final JWKSet jwkSet;
public JwkSetEndpoint(Rs256KeyConfig keys) {
RSAKey rsaKey = new RSAKey.Builder(keys.publicKey)
.keyID(keys.keyId)
.build();
this.jwkSet = new JWKSet(rsaKey);
}
@GetMapping("/.well-known/jwks.json")
public Map<String, Object> keys() {
return this.jwkSet.toJSONObject(); // public parameters only
}
}
private static RSAPrivateKey readPrivateKey(String path) throws Exception {
byte[] der = pemBody(path, "PRIVATE KEY");
return (RSAPrivateKey) KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(der));
}
private static RSAPublicKey readPublicKey(String path) throws Exception {
byte[] der = pemBody(path, "PUBLIC KEY");
return (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(der));
}
private static byte[] pemBody(String path, String label) throws Exception {
try (InputStream in = new ClassPathResource(path).getInputStream()) {
String pem = new String(in.readAllBytes(), StandardCharsets.UTF_8);
String base64 = pem
.replace("-----BEGIN " + label + "-----", "")
.replace("-----END " + label + "-----", "")
.replaceAll("\\s", "");
return Base64.getDecoder().decode(base64);
}
}
}

View File

@@ -0,0 +1,140 @@
package com.ankurm.jwtauth.config;
import com.ankurm.jwtauth.auth.JwtAuthenticationFilter;
import com.ankurm.jwtauth.auth.RevokedTokenStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.context.NullSecurityContextRepository;
/**
* The default chain: our own {@link JwtAuthenticationFilter} does the verifying.
*
* <p>Active unless the {@code resourceserver} profile is on. Compare with
* {@link ResourceServerSecurityConfig}, which deletes most of this file.
*
* @see docs/02-filter-chain-and-ordering.md
* @see docs/03-401-vs-403.md
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize on controller methods
@Profile("!resourceserver")
public class SecurityConfig {
/**
* CSRF is off by default here (stateless bearer-token API). Flip
* {@code demo.csrf.enabled=true} to reproduce the "permitAll() still 403s" failure
* described in docs/04-csrf-permitall-403.md.
*/
@Value("${demo.csrf.enabled:false}")
private boolean csrfEnabled;
@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http,
JwtDecoder jwtDecoder,
RevokedTokenStore revokedTokens,
AuthenticationEntryPoint entryPoint,
AccessDeniedHandler accessDeniedHandler) throws Exception {
JwtAuthenticationFilter jwtFilter =
new JwtAuthenticationFilter(jwtDecoder, revokedTokens, entryPoint);
http
// 1. No sessions, no session cookie, nothing to fix on the server.
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityContext(context -> context
.securityContextRepository(new NullSecurityContextRepository()))
// 2. CSRF. See the callout in docs/04 before you copy the disable() line.
.csrf(csrf -> {
if (this.csrfEnabled) {
// Left at defaults on purpose: this is the failure, not the fix.
// Every unsafe method now needs a CSRF token - including the
// permitAll() login endpoint. See docs/04-csrf-permitall-403.md.
csrf.csrfTokenRepository(
org.springframework.security.web.csrf.CookieCsrfTokenRepository
.withHttpOnlyFalse());
}
else {
// Correct for a bearer-token API: the browser never attaches
// credentials automatically, so there is nothing to forge.
csrf.disable();
}
})
// 3. Nothing browser-shaped: no login page, no basic auth popup.
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.logout(logout -> logout.disable())
// 4. Authorization. Evaluated by AuthorizationFilter, the LAST filter.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/login", "/api/auth/refresh").permitAll()
.requestMatchers("/.well-known/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
// 5. What an authentication failure and an authorization failure look like.
.exceptionHandling(ex -> ex
.authenticationEntryPoint(entryPoint)
.accessDeniedHandler(accessDeniedHandler))
// 6. Position matters. Before UsernamePasswordAuthenticationFilter puts us
// ahead of AnonymousAuthenticationFilter and ExceptionTranslationFilter,
// which is what we want. addFilterAfter(..., AuthorizationFilter.class)
// would run after the decision has already been made.
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* 401, with a {@code WWW-Authenticate: Bearer ...} header carrying the
* RFC 6750 error code. Reused by the filter so unauthenticated responses
* are identical whether they come from the filter or from
* {@code ExceptionTranslationFilter}.
*/
@Bean
public AuthenticationEntryPoint bearerTokenEntryPoint() {
BearerTokenAuthenticationEntryPoint entryPoint = new BearerTokenAuthenticationEntryPoint();
entryPoint.setRealmName("jwt-auth-demo");
return entryPoint;
}
/** 403, with {@code error="insufficient_scope"} in WWW-Authenticate. */
@Bean
public AccessDeniedHandler bearerTokenAccessDeniedHandler() {
return new BearerTokenAccessDeniedHandler();
}
/**
* The login endpoint needs this. Once you define a SecurityFilterChain bean,
* Boot no longer auto-configures an AuthenticationManager, so build one.
*/
@Bean
public AuthenticationManager authenticationManager(UserDetailsService users,
PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.jwtauth.diag;
import java.util.List;
import java.util.Map;
import jakarta.servlet.Filter;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints the real filter order at runtime instead of the one you remember.
*
* <p>{@code GET /api/public/filters} - deliberately under {@code /api/public} so it is
* reachable without a token. Delete this class before shipping: it tells an attacker
* exactly which filters guard the application.
*/
@RestController
public class FilterChainReport {
private final FilterChainProxy filterChainProxy;
public FilterChainReport(FilterChainProxy filterChainProxy) {
this.filterChainProxy = filterChainProxy;
}
@GetMapping("/api/public/filters")
public List<Map<String, Object>> filters(HttpServletRequest request) {
return this.filterChainProxy.getFilterChains().stream()
.map(chain -> Map.<String, Object>of(
"chain", chain.toString(),
"matchesThisRequest", matches(chain, request),
"filters", chain.getFilters().stream()
.map(f -> f.getClass().getSimpleName())
.toList()))
.toList();
}
private boolean matches(SecurityFilterChain chain, HttpServletRequest request) {
try {
return chain.matches(request);
}
catch (RuntimeException ex) {
return false;
}
}
/** Unused; shows the type the list actually holds. */
@SuppressWarnings("unused")
private Class<?> filterType(Filter filter) {
return filter.getClass();
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.jwtauth.edge;
import com.ankurm.jwtauth.auth.TokenService;
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;
/**
* Edge case: a refresh token is a valid access token unless you say otherwise.
*
* <p>Both are signed by the same key, both carry a valid {@code exp}, both pass every
* default validator. If the only difference is the TTL, a stolen refresh token is a
* long-lived access token. This validator is the resource-server equivalent of the
* {@code token_type} check inside {@code JwtAuthenticationFilter}.
*
* <pre>
* NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).build();
* decoder.setJwtValidator(new DelegatingOAuth2TokenValidator&lt;&gt;(
* JwtValidators.createDefaultWithIssuer(issuer),
* new AccessTokenTypeValidator()));
* </pre>
*
* @see docs/07-edge-cases.md
*/
public class AccessTokenTypeValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error ERROR = new OAuth2Error(
"invalid_token",
"Expected a token with token_type=access",
"https://ankurm.com/spring-security-7-1-jwt-authentication-guide/");
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
return TokenService.ACCESS.equals(jwt.getClaimAsString("token_type"))
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(ERROR);
}
}

View File

@@ -0,0 +1,50 @@
package com.ankurm.jwtauth.edge;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Edge case: the {@code SecurityContext} lives in a {@code ThreadLocal}, so it does
* not cross a thread boundary on its own.
*
* <p>{@code GET /api/async-demo} runs the same lookup on a plain executor and on a
* {@code DelegatingSecurityContextExecutorService}, and returns both answers. The
* plain one is {@code null} - and {@code null} here means an authenticated user's
* background work runs unauthenticated, which usually surfaces as a
* {@code AuthenticationCredentialsNotFoundException} far from the cause.
*
* @see docs/07-edge-cases.md#async
*/
@RestController
public class AsyncPropagationDemo {
@GetMapping("/api/async-demo")
public Map<String, Object> asyncDemo() throws Exception {
Callable<String> readPrincipal = () -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth == null ? "null (context did not cross the thread)" : auth.getName();
};
ExecutorService plain = Executors.newVirtualThreadPerTaskExecutor();
ExecutorService wrapped = new DelegatingSecurityContextExecutorService(
Executors.newVirtualThreadPerTaskExecutor());
try {
return Map.of(
"onRequestThread", readPrincipal.call(),
"onPlainExecutor", plain.submit(readPrincipal).get(),
"onDelegatingExecutor", wrapped.submit(readPrincipal).get());
}
finally {
plain.shutdown();
wrapped.shutdown();
}
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.jwtauth.edge;
import java.util.List;
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.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
/**
* Edge case: {@code aud} is NOT validated by default.
*
* <p>{@code JwtValidators.createDefaultWithIssuer(issuer)} checks {@code exp},
* {@code nbf} and {@code iss} - not {@code aud}. In a multi-service estate where every
* service trusts the same issuer, that means a token minted for the reporting API is
* accepted by the payments API. Confused-deputy, by default.
*
* @see docs/07-edge-cases.md#audience
*/
public final class AudienceValidator {
private AudienceValidator() { }
public static OAuth2TokenValidator<Jwt> forAudience(String expected) {
return new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
aud -> aud != null && aud.contains(expected));
}
/** The long-hand equivalent, for readers who prefer to see the shape. */
public static OAuth2TokenValidator<Jwt> explicit(String expected) {
OAuth2Error error = new OAuth2Error("invalid_token",
"The required audience " + expected + " is missing", null);
return jwt -> jwt.getAudience() != null && jwt.getAudience().contains(expected)
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(error);
}
}

View File

@@ -0,0 +1,62 @@
package com.ankurm.jwtauth.web;
import java.util.List;
import java.util.Map;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Four endpoints, chosen so every authorization outcome is reachable from curl:
* <ul>
* <li>{@code /api/public/ping} - permitAll, works with no token.</li>
* <li>{@code /api/me} - authenticated, 401 without a token.</li>
* <li>{@code /api/admin/stats} - hasRole('ADMIN'), 403 for a valid non-admin token.</li>
* <li>{@code /api/reports} - hasAuthority('SCOPE_admin:read'), the scope-based twin.</li>
* </ul>
*/
@RestController
public class ApiControllers {
@GetMapping("/api/public/ping")
public Map<String, Object> ping() {
return Map.of("status", "up", "authenticationRequired", false);
}
@GetMapping("/api/me")
public Map<String, Object> me(Authentication authentication) {
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("name", authentication.getName());
body.put("authorities", authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).sorted().toList());
body.put("authenticationType", authentication.getClass().getSimpleName());
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
body.put("jti", jwt.getId());
body.put("issuer", jwt.getIssuer() == null ? null : jwt.getIssuer().toString());
body.put("audience", jwt.getAudience());
body.put("issuedAt", String.valueOf(jwt.getIssuedAt()));
body.put("expiresAt", String.valueOf(jwt.getExpiresAt()));
body.put("algorithm", jwt.getHeaders().get("alg"));
body.put("keyId", jwt.getHeaders().get("kid"));
}
return body;
}
@GetMapping("/api/admin/stats")
public Map<String, Object> adminStats() {
return Map.of("activeUsers", 3, "requiredRole", "ROLE_ADMIN");
}
@GetMapping("/api/reports")
@PreAuthorize("hasAuthority('SCOPE_admin:read')")
public List<String> reports() {
return List.of("q1-revenue", "q2-revenue");
}
}

View File

@@ -0,0 +1,5 @@
# Run with --spring.profiles.active=hs256,csrfon to reproduce the failure in
# docs/04-csrf-permitall-403.md: a permitAll() login endpoint answering 403.
demo:
csrf:
enabled: true

View File

@@ -0,0 +1,6 @@
# Run with --spring.profiles.active=hs256,shortlived to observe expiry and
# clock-skew behaviour without waiting 15 minutes.
demo:
jwt:
access-token-ttl: 2s
refresh-token-ttl: 30s

View File

@@ -0,0 +1,5 @@
# Run with --spring.profiles.active=hs256,resourceserver,strict to wire the
# AccessTokenTypeValidator into the JwtDecoder. Without it, the built-in resource
# server happily accepts a refresh token as an access token - see docs/07.
demo:
validate-token-type: true

View File

@@ -0,0 +1,5 @@
# Run with --spring.profiles.active=hs256,trace to see every filter decision.
logging:
level:
org.springframework.security: TRACE
org.springframework.security.web.FilterChainProxy: TRACE

View File

@@ -0,0 +1,28 @@
spring:
application:
name: jwt-auth-demo
profiles:
# hs256 = symmetric signing, manual = hand-written OncePerRequestFilter.
# Override with: --spring.profiles.active=rs256,resourceserver
default: hs256
server:
port: 8080
error:
include-message: always
demo:
csrf:
# true reproduces the "permitAll() still 403s" failure from docs/04.
enabled: false
jwt:
issuer: https://jwt-auth-demo.ankurm.com
audience: jwt-auth-demo-api
access-token-ttl: 15m
refresh-token-ttl: 8h
# 32+ bytes. Demo value only - a real deployment reads this from a secret manager.
hmac-secret: ankurm-demo-hmac-secret-key-please-rotate-me-32b
logging:
level:
org.springframework.security: INFO

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDk0QNA9BbQbPqJ
Hn5Q2XsnvrrT8+x3mrV/lVqYr0pNL1/piE88gbyZ+LrNZ7xebHF1H8F3bJpf6/9b
PoyB4UtsFWREV+97/NWerj6SCOGfxRpErWJTQLWP7tJqEPveEeoZo6MCUU2ujnKC
MYBxUvxEZUzFEzLhppMCbcoHgR0baEkXHJ8IGq2TCwdMz8NdygVMtNnZsj6E+E3q
4HGTvKQVoKdDjvKspIzqSfS6XR2nbYAr/iY/VJEOeevcNrkYWEwq0GrL8aBdwn1Z
OnEhiGWvwHmah21vlLfj3TRU9JymheIl1SGCHESldtvVb8+WlUgSUhIfOV2fBY6i
B0+dw7TNAgMBAAECggEAHlP/0OepcHXJXUxP5Mp2suVqYPaHRLEaVm9G4070k7dw
SIVbL0No6qWXqOsTghZwkVwkqf4YlhczMPZg7EQe2ZQaRp67LN1tuQsSWwvXT/Rx
j2HF0xAUIKBAfnOC1sPcGgrg68k3+SeDUPNbuWmM60nb+5EYYOVRvfQsX4NDBuMz
B3gtuccd+L+wbCQPKmTQtu0FBtSzROKS39QJ77TmvNclQdVHu9tu805hezfbwXzG
usR64CJRzJE/mxmgoAJX+Tws+G4VRY5KAEBojVpAlN2bxpIBYMfyX6UEOOIBKp0E
WWv6bcFqlKQnSW9mWf4vt+ePtjlR4aLQcw9NWo2w5wKBgQD0N0s+X2+WX1h6Oqzd
sVFjGkrhwWrCwrdepsnLTQoAj0FuwMpgvfQDGvNrnyOSz4jf3rDe63WDRBQuxdaa
wU234jDBZ33VzUPQT4nrprKa1F3V8ZISpQdPNDRKyth56yyDeLQ8UnfhIlfN0/+b
hQ9ns9uv4ASMH53Q4/TAI5OBywKBgQDv236HXs3JEED6vM2zwE5H0SNQUhwCv4/K
m16Umd6rjHJJHkzQBhWsfYCCMUYZB+K7z9Zq9GivfJev2DqcKZkSdibpse4XDWfs
MWtMvYxtHvTt5Ghs/vE5A5x6uz8DXwaWOx/8bo13EIQvxtvw5HXtKB/WtTygBGMv
dcmT+dNwxwKBgDvm0Cr1Z75/loksmTgrhSYEzfc/5PruneG2kWqvc9OdT9Rlr345
OYAFfU2ZlDUveIhI7CNRp9pRuY2bcz80SObgsUrPIrtthMO0rsTBd6+ohXezsDuo
hPl1eZoa1Sxademtkq/1Hnh3XwgahujTo2qxYCJslVD1dFVHhMIYN9cvAoGAZO6e
beSNAADg9yIgBXX0+u+cxp3mv5lQrtd2k120f8fYB8DCXf9Re4ZMX3zQnJPe611o
QxWaP85UHmEFONWgXk5tzYVcRUMU6iVZm69fukN+meS1tLgLVgyY+mR0/bwtD2bN
7PGwgdvnZBtwTgw1O5jY3Qbi/gsamcwdCTHlsd0CgYAc+gUTmRlzZDLiUHWOg4Hi
+cllYIQPfQJYd4woiZu3er1Qo/tNYAI5hGDrUGI5cM4Qt3j9xA55eoigzy7ra+y2
ZkfOUUHAtgJrOOkbUtqBCtqbhNTW2HnjMXwt8TaozrjjoHW6lLiq3PvQ+wjwKx54
6g0Y6BKcZz2zYcpmrDXM6Q==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5NEDQPQW0Gz6iR5+UNl7
J7660/Psd5q1f5VamK9KTS9f6YhPPIG8mfi6zWe8XmxxdR/Bd2yaX+v/Wz6MgeFL
bBVkRFfve/zVnq4+kgjhn8UaRK1iU0C1j+7SahD73hHqGaOjAlFNro5ygjGAcVL8
RGVMxRMy4aaTAm3KB4EdG2hJFxyfCBqtkwsHTM/DXcoFTLTZ2bI+hPhN6uBxk7yk
FaCnQ47yrKSM6kn0ul0dp22AK/4mP1SRDnnr3Da5GFhMKtBqy/GgXcJ9WTpxIYhl
r8B5modtb5S34900VPScpoXiJdUhghxEpXbb1W/PlpVIElISHzldnwWOogdPncO0
zQIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,142 @@
package com.ankurm.jwtauth;
import com.ankurm.jwtauth.auth.RevokedTokenStore;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The 401-vs-403 contract, pinned as tests.
*
* @see docs/03-401-vs-403.md
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("hs256")
class AuthenticationFlowTests {
@Autowired MockMvc mvc;
@Autowired RevokedTokenStore revokedTokens;
private static final tools.jackson.databind.ObjectMapper JSON =
new tools.jackson.databind.ObjectMapper();
private Map<String, String> login(String user, String password) throws Exception {
MvcResult result = this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"%s\",\"password\":\"%s\"}".formatted(user, password)))
.andExpect(status().isOk())
.andReturn();
return JSON.readValue(result.getResponse().getContentAsString(),
new tools.jackson.core.type.TypeReference<Map<String, String>>() { });
}
@Test
void publicEndpointNeedsNoToken() throws Exception {
this.mvc.perform(get("/api/public/ping")).andExpect(status().isOk());
}
@Test
void missingTokenIs401NotA403() throws Exception {
this.mvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate",
org.hamcrest.Matchers.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",
org.hamcrest.Matchers.containsString("insufficient_scope")));
}
@Test
void adminTokenReachesAdminEndpoint() throws Exception {
String token = login("root", "root-password").get("accessToken");
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
@Test
void tamperedSignatureIs401WithInvalidToken() throws Exception {
String token = login("alice", "alice-password").get("accessToken");
String tampered = token.substring(0, token.length() - 4) + "AAAA";
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + tampered))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate",
org.hamcrest.Matchers.containsString("invalid_token")));
}
@Test
void refreshTokenIsNotAnAccessToken() throws Exception {
String refresh = login("alice", "alice-password").get("refreshToken");
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + refresh))
.andExpect(status().isUnauthorized());
}
@Test
void badPasswordIs401AndSaysNothingUseful() throws Exception {
this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void lockedAccountIsIndistinguishableFromABadPassword() throws Exception {
MvcResult locked = this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"locked\",\"password\":\"locked-password\"}"))
.andExpect(status().isUnauthorized()).andReturn();
MvcResult wrong = this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
.andExpect(status().isUnauthorized()).andReturn();
assertThat(locked.getResponse().getContentAsString())
.isEqualTo(wrong.getResponse().getContentAsString());
}
@Test
void revokedTokenIsRefusedEvenThoughTheSignatureIsStillValid() throws Exception {
String token = login("alice", "alice-password").get("accessToken");
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
this.mvc.perform(post("/api/auth/logout").header("Authorization", "Bearer " + token))
.andExpect(status().isNoContent());
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
.andExpect(status().isUnauthorized());
}
@Test
void spentRefreshTokenCannotBeReplayed() throws Exception {
String refresh = login("alice", "alice-password").get("refreshToken");
this.mvc.perform(post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
.andExpect(status().isOk());
this.mvc.perform(post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -0,0 +1,59 @@
package com.ankurm.jwtauth;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Pins the failure described in docs/04-csrf-permitall-403.md, and the two
* facts that explain it.
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"hs256", "csrfon"})
class CsrfBreaksPermitAllTests {
@Autowired MockMvc mvc;
@Autowired FilterChainProxy filterChainProxy;
@Test
void permitAllLoginStillReturns403WhenCsrfIsOn() throws Exception {
this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"alice-password\"}"))
.andExpect(status().isForbidden());
}
@Test
void theSameRequestWithACsrfTokenSucceeds() throws Exception {
this.mvc.perform(post("/api/auth/login").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"alice-password\"}"))
.andExpect(status().isOk());
}
@Test
void csrfFilterRunsLongBeforeAuthorizationFilter() {
List<String> filters = this.filterChainProxy.getFilterChains().getFirst()
.getFilters().stream().map(f -> f.getClass().getSimpleName()).toList();
int csrf = filters.indexOf("CsrfFilter");
int authorization = filters.indexOf("AuthorizationFilter");
assertThat(csrf).isNotNegative();
assertThat(authorization).isEqualTo(filters.size() - 1);
assertThat(csrf).isLessThan(authorization);
}
}