1
0
Files
jwt-auth-demo/README.md
asmhatre 4a8dab6739 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.
2026-08-22 06:34:43 +00:00

133 lines
5.6 KiB
Markdown

# 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.