Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)
A three-part guide to JWT authentication on Spring Security 7.1 and Spring Boot 4.1, written to be useful whether you have never issued a token or already run one in production. Part 1 starts from zero: what a token is, what is inside it, the two paths through the application, and the smallest configuration that verifies one. Part 2 opens the filter chain — writing the OncePerRequestFilter by hand, where it goes, what really decides 401 versus 403, why a permitAll() login endpoint still returns 403 with CSRF on, and how to choose a signing algorithm. Part 3 is everything the defaults do not check.
Every Spring Boot API that hands out tokens contains the same four moving parts: a login endpoint that trades a password for a signed string, an encoder that signs it, a filter that verifies it on every subsequent request, and a SecurityContext that carries the result to your controller. Written out, it is perhaps two hundred lines.
The trouble is that three of the four fail quietly. A misplaced filter produces a 401 that looks like a bad token. A valid token on the wrong endpoint produces a 403 that looks like a broken filter. And a permitAll() login endpoint can return 403 to a perfectly good request for a reason that has nothing to do with authentication at all.
So this article is built in three parts, and it genuinely does start from zero. If you have never issued a JWT, Part 1 gives you the mental model and a working application. If you have shipped one and something is behaving strangely, Part 2 is where the explanations live. If you already run this in production, Part 3 is the list of things the framework does not check for you.
Part
Covers
You come away with
1. Beginner
What a token is, what is inside it, the two paths through the application, issuing a token, and the smallest configuration that verifies one
A working, secured API and an accurate mental model
2. Intermediate
The filter chain, writing the filter by hand, where it goes, what really decides 401 vs 403, the CSRF trap, and choosing a signing algorithm
The ability to debug it when it misbehaves
3. Advanced
The validators nobody adds, what Spring Security 7 changed underneath you, and eighteen edge cases with reproductions
Something you would be comfortable running in production
Everything below was compiled and executed; every response shown is copied from a real run, and the companion repository reproduces each one.
Versions (August 2026): Verified against 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 and JDK 25.0.4.1 (current LTS). Spring Boot 4.1.0 went GA on 10 June 2026 and ships Spring Security 7.1. Jackson is 3.1.5 under the tools.jackson coordinates.
Part 1 — The beginner’s mental model
What problem a token actually solves
The traditional way to remember a logged-in user is a session. The server keeps a map of session id to user, sets a cookie, and looks the user up on every request. It works, it is well understood, and logging someone out is a single map.remove(id).
The cost is that map. It lives on one server, so a second server needs to share it — sticky sessions, or a replicated session store, or Redis. Every request pays a lookup. And your API is now stateful in a way that makes scaling and deployment more annoying than it needs to be.
A token inverts the arrangement. Instead of the server remembering who you are, the client carries a signed statement of who it is, and the server verifies that statement mathematically on each request. No shared map, no lookup, no stickiness. Any server holding the right key can check any token.
That inversion is the whole idea, and it buys you exactly one thing — statelessness — in exchange for a specific, permanent cost: you can no longer un-say something you have said. A session can be deleted. A signed statement, once handed out, is valid until it expires, everywhere, for anyone holding it. Almost every difficulty later in this article traces back to that single trade.
What is actually inside a JWT
A JWT is one long string with three segments separated by dots. Nothing more mysterious than that.
You do not need a library to look inside one. Take a real token from the companion repository and decode it with a shell built-in:
A JWT is signed, not encrypted. That command took no key and there is no version of it that requires one. Anything you put in a claims set is readable by the holder, by any proxy that logs the header, and by anything the string is ever pasted into. No email addresses, no phone numbers, no internal identifiers you would not publish, no description of your permission model. If a payload genuinely must be confidential you want JWE, not JWS — but the better answer is almost always an opaque identifier plus a server-side lookup.
This is the single most common beginner misconception, and it is worth fixing before you write any code, because everything you choose to put in a claims set is a decision you are making in public.
The two paths through the application
Now, who makes these strings and who checks them? There are two paths through a JWT application, and most confusion comes from treating them as one. The login path runs once and is the only place a password is ever read. The request path runs on every call afterwards and sees nothing but a string.
Do not worry about the numbered filters on the right yet — Part 2 is entirely about them. For now, hold two ideas: the login path is stateful only in the sense that it sees a password, and the request path never sees one again.
Issuing the token
Spring Security has shipped a JWT encoder since 5.6, so there is no reason to reach for a third-party JWT library. NimbusJwtEncoder takes a claims set and returns a signed compact serialization:
Most of those claims are the registered ones from the JWT specification, and the builder method names map onto them directly. Two, however, are load-bearing rather than decorative, and both exist to solve problems you will meet later in this article.
jti is a unique id for this specific token. It is what a denylist keys on, and remember the trade from the start of this part — a signed statement cannot be un-said. A JWT without a jti cannot be revoked at all, so leaving it out is a decision you make once and cannot reverse later without reissuing every token.
token_type distinguishes an access token from a refresh token. That sounds like bookkeeping. It is not: both are signed by the same key and both pass every default check, so without this claim a refresh token works perfectly well as an access token. We will watch that happen in Part 3.
The smallest thing that verifies a token
Something now has to check those strings on the way back in. You can write that yourself — and Part 2 does, because understanding it is the point of this article — but it is worth knowing that the framework already ships it, because for most applications this is genuinely the right answer:
One line. Your JwtDecoder bean is picked up automatically, a BearerTokenAuthenticationFilter is inserted at the correct point in the chain, and the 401 and 403 handlers are wired with correct RFC 6750 headers. Point it at an issuer and you do not even need the decoder:
At this point you have a secured API. If that is all you needed, you could stop here and skip to Part 3, which is the list of things this configuration does not check. But the reason so many teams hand-roll a filter instead is that this one line is opaque: when it returns 401 and you believe it should not, there is nothing obvious to read. So let us open it.
Part 2 — Opening the box
The filter chain, read from the running application
Spring Security is a chain of servlet filters. A request enters at the top, each filter gets a turn, and any of them can stop the request dead. Almost every confusing behaviour in this article is really a question about which filter answered first.
You do not have to guess at the order. The companion repository exposes an endpoint that reads FilterChainProxy.getFilterChains() at runtime and prints it:
Three of those eleven matter for everything that follows. SecurityContextHolderFilter near the top sets up the per-request storage that holds “who you are”. AuthorizationFilter at the very bottom is the only filter that has ever heard of permitAll() or hasRole(). And ExceptionTranslationFilter, immediately above it, is what converts a refusal into a status code.
Our filter sits at position five, between them. That position is not arbitrary, and getting it wrong is the subject of two sections’ time. Reading this list from a running application whenever a chain surprises you is a habit worth forming now.
Writing the filter yourself
This is the part everyone writes and almost everyone writes slightly wrong. The skeleton is short:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final SecurityContextHolderStrategy contextHolderStrategy =
SecurityContextHolder.getContextHolderStrategy();
private final SecurityContextRepository contextRepository =
new RequestAttributeSecurityContextRepository();
private final BearerTokenResolver bearerTokenResolver = new DefaultBearerTokenResolver();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = this.bearerTokenResolver.resolve(request);
if (token == null) {
filterChain.doFilter(request, response); // (1) no token is NOT an error
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); // (2) via the strategy
this.contextRepository.saveContext(context, request, response); // (3) explicit save
}
catch (JwtException | OAuth2AuthenticationException ex) {
this.contextHolderStrategy.clearContext(); // (4) never leak a principal
this.entryPoint.commence(request, response, asAuthenticationException(ex));
return; // (5) stop the chain
}
filterChain.doFilter(request, response);
}
}
Read past the boilerplate and the shape is simple: get the token, verify it, put the result somewhere, carry on. The five numbered lines are where the bugs live, and each is worth a moment.
(1) No token is not an error. The instinct is to reject a request with no Authorization header, but that is not this filter’s job. Continue the chain and let AuthorizationFilter decide — that is precisely what keeps permitAll() endpoints such as your login route reachable without credentials. A filter that rejects here makes login impossible, which is a memorable first bug.
(2) Use SecurityContextHolderStrategy, not the static setters.SecurityContextHolder‘s static methods delegate to whatever strategy is installed. Capturing the strategy once is what the framework’s own filters do, and it is the form that keeps working when an application swaps in a delegating strategy — usually for observability or tenant propagation.
(3) Save explicitly. Spring Security 6 replaced SecurityContextPersistenceFilter with SecurityContextHolderFilter, and the difference is exactly one behaviour: the new one loads the context but never saves it. Omitting saveContext appears to work until an ERROR dispatch or a FORWARD clears the ThreadLocal and the principal vanishes on /error.
(4) Clear the context on failure. Servlet containers pool threads. A populated ThreadLocal left behind is a cross-request principal leak — the worst class of security bug, because it is intermittent and load-dependent, so it survives testing and appears in production under traffic.
(5) Stop the chain on a bad token. The tempting alternative — catch, log, continue anonymously — means a forged token yields 403 on a protected endpoint and a silent 200 on a public one. Neither response says “your token is invalid”, so the client retries forever with the same bad token.
The landmark class need not be present in your chain — ordering is by position in a registry, not by the presence of a neighbour, so this works fine with formLogin disabled. That surprises people, so it is worth stating plainly: you are naming a slot, not a neighbour.
Four ways to place it wrong, and the symptom of each.After AuthorizationFilter — authorization has already answered, so every protected endpoint 401s regardless of the token. Before SecurityContextHolderFilter — the context holder is not initialised yet, giving intermittent wrong-principal bugs under load. Registered twice — a filter that is also a @Component gets picked up by Boot’s servlet auto-registration and inserted into the security chain, so it runs on requests no SecurityFilterChain matches; suppress it with a disabled FilterRegistrationBean, or do what the companion repository does and never make it a bean. Extending GenericFilterBean — without OncePerRequestFilter‘s re-entry guard, a FORWARD to an error page or an async dispatch authenticates twice.
Notice that all four symptoms are misleading in the same direction: they look like token problems. That is the recurring lesson of this part — when Spring Security refuses a request, the status code tells you which filter answered, not what was wrong with your credentials.
401 vs 403: the decision is not where you think
Here is the rule, and everything else follows from it:
401 — I do not know who you are. 403 — I know who you are, and you may not do this. If you remember nothing else from this article, remember that a 403 is a statement about an identity the server already accepted.
Both statuses come from a filter your token never reaches. ExceptionTranslationFilter wraps everything after it and catches exactly two exception types:
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, because it explains the whole thing. AuthorizationFilter throws AccessDeniedException for both “no credentials” and “wrong credentials”. The split is decided by authenticated, which is false when the current Authentication is anonymous or null.
You sent
Context holds
Result
nothing
AnonymousAuthenticationToken
401
a valid token, insufficient authority
JwtAuthenticationToken
403
an invalid token
nothing — the filter cleared it and stopped
401
The third row is detail (5) from the filter above, and it is the one that gets skipped. If an invalid token falls through to anonymous, a forged token on an admin endpoint returns 403 — which tells the caller “your token is fine, your role is not”. It is not fine.
On the wire, from the captured transcript. No token at all — a bare challenge, because nothing is wrong with a token that was never presented:
A tampered payload with the original signature attached:
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"
And a perfectly valid token belonging to a user without ROLE_ADMIN — 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."
Getting those shapes right takes three beans and one piece of trivia:
.exceptionHandling(ex -> ex
.authenticationEntryPoint(bearerTokenEntryPoint()) // 401
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())); // 403
Three siblings, three packages.BearerTokenAuthenticationEntryPoint lives in org.springframework.security.oauth2.server.resource.<strong>web</strong>, BearerTokenAccessDeniedHandler one level deeper in …resource.web.<strong>access</strong>, and BearerTokenAuthenticationFilter in …resource.web.<strong>authentication</strong>. Auto-import will confidently pick the wrong one; this cost a compile cycle while writing the companion repository.
There is a second, subtler trap in the same area. BearerTokenAuthenticationEntryPoint only writes error="invalid_token" into the header when the exception carries a BearerTokenError. Wrap a JwtException in a plain AuthenticationServiceException and your clients get a bare WWW-Authenticate: Bearer realm="…" with no reason at all. Wrap it in InvalidBearerTokenException instead — same status code, vastly better debuggability.
There is a third path that neither bean covers, and it catches nearly everyone. POST /api/auth/login calls AuthenticationManagerfrom a controller, so a BadCredentialsException is an ordinary MVC exception by the time anything security-shaped could intercept it. The entry point is never invoked, and without a @RestControllerAdvice the default answer to a wrong password is a 500 or a 403:
@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 returns the same body. LockedException producing a distinct message is a user-enumeration oracle — “account locked” confirms the username exists, which is exactly the fact an attacker wants before starting a password spray. The repository pins this with a test that byte-compares the two responses, because it is the kind of helpfulness that creeps back in during a refactor.
Why permitAll() still returns 403
Now the most reported “Spring Security is broken” bug, which is not a bug. Everything in the previous section is true, and this failure still contradicts it — which is the clue. The setup looks unimpeachable:
Empty body. A header mentioning Bearer, which sends people hunting for a token problem. There is no token problem. The cause is one number:
Here is the application’s own TRACE log for that exact request, unedited:
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
Five of twelve. CsrfFilter throws an AccessDeniedException subtype — MissingCsrfTokenException here, since this client had no stored token at all — and hands it to an AccessDeniedHandler directly: it does not travel to ExceptionTranslationFilter, which sits at position 11 in this chain — downstream of the filter that threw. And because CsrfConfigurer reuses whatever handler you configured under exceptionHandling(), a CSRF rejection gets rendered by your bearer-token handler. Hence the misleading header.
Turning on TRACE for org.springframework.security and reading which filter number the request reached is the fastest debugging move in this whole article, and it works for every symptom in Part 2, not just this one.
A bare WWW-Authenticate: Bearer on a 403, with an empty body, on POST only, is the fingerprint of a CSRF rejection — not a scope problem. If GET works on the same path family and POST does not, stop reading your authorization rules.
Correct for a bearer-token API — but understand why, because the reasoning has a condition and the condition is routinely violated. 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 header; your JavaScript has to read the token out of memory and set it, and the same-origin policy stops another site’s script from doing that. No ambient credential, nothing to forge.
Both halves of the condition matter: the token is never in a cookie, and no cookie- or session-based authentication remains on any chain. Store the JWT in a cookie “for convenience” and it is an ambient credential — you have re-created CSRF exactly, and csrf.disable() is now a real vulnerability rather than a correct simplification. For a mixed application, csrf.ignoringRequestMatchers("/api/**") or, better, two separate SecurityFilterChain beans split by securityMatcher(). For a session-backed SPA, Spring Security 7 adds csrf.spa(), which bundles cookie storage, BREACH protection and deferred token loading in one call — on 6.x you wire those three pieces yourself.
One more trap in this area: spring-security-test‘s .with(csrf()) post-processor makes the failing test pass against a configuration that 403s in production. The repository deliberately keeps both tests — one asserting the 403 without it, one asserting the 200 with it. If you only ever write the second, you have tested your test.
Choosing a signing algorithm
One thing has been taken for granted since Part 1: the key. It is the last intermediate decision, and unlike the others it is genuinely hard to change later.
The performance table is not the interesting part. 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, because verifying requires the signing secret — so every verifier is also an issuer, and one compromised read-only reporting service can mint an admin token. If that is unacceptable, you need RS256 or ES256, and no amount of secret rotation substitutes.
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)
Distribution
copy the secret everywhere
publish a JWKS URL
Rotation
flag day, or dual-secret window
overlap two kids, no downtime
// HS256 - note algorithm(..), NOT jwsAlgorithm(..)
NimbusJwtEncoder.withSecretKey(secretKey)
.algorithm(MacAlgorithm.HS256)
.build();
// RS256 - and there is no keyId(..) method; the kid goes through the JWK builder
NimbusJwtEncoder.withKeyPair(publicKey, privateKey)
.algorithm(SignatureAlgorithm.RS256)
.jwkPostProcessor(jwk -> jwk.keyID("demo-rsa-2026-08"))
.build();
// Decoders - which DO use macAlgorithm(..) / signatureAlgorithm(..)
NimbusJwtDecoder.withSecretKey(secretKey).macAlgorithm(MacAlgorithm.HS256).build();
NimbusJwtDecoder.withPublicKey(publicKey).signatureAlgorithm(SignatureAlgorithm.RS256).build();
Those builders arrived in Spring Security 7.0 and their naming is genuinely inconsistent with the decoder side — the encoder says algorithm, the decoder says macAlgorithm and signatureAlgorithm. Pin the algorithm on the decoder in particular: it is the half that decides how an incoming token is verified, and letting the token’s own alg header choose is the shape of the classic RS256-to-HS256 confusion attack. Look back at the anatomy diagram in Part 1 — the header is written by whoever produced the token, which may not be you.
Under the rs256 profile the repository publishes the public half at /.well-known/jwks.json:
n and e only — modulus and exponent. A private key would additionally carry d, p and q; grep any JWKS you expose for those letters before shipping it. A downstream resource server then needs no key material at all, just NimbusJwtDecoder.withJwkSetUri(...) or the issuer-uri property from Part 1 — which is why the one-line configuration back there was able to work without you ever handling a key.
An HMAC secret is not a passphrase. HS256 requires a key of at least 256 bits and Nimbus throws KeyLengthException for anything shorter. But "changeit-changeit-changeit-change" is 32 bytes and passes that check while carrying perhaps 40 bits of entropy — and an attacker cracking an HMAC has the plaintext, the signature, and unlimited offline attempts. Generate it: openssl rand -base64 48.
Part 3 — What the defaults do not do
Everything up to here assumes that once a token verifies, you are finished. You are not. This part is the list of things Spring Security will happily let through, and it is the part that separates a demo from something you would put in front of real users.
The validators nobody adds
Go back to Part 1’s one-line resource server. It is genuinely the right choice for most applications — but here is what it does not know about your tokens. Same API, same request, two profiles of the companion repository:
# The same request against the same API, two profiles:
# GET /api/me with a REFRESH token in the Authorization header
HTTP 200 hs256,resourceserver
HTTP 401 hs256,resourceserver,strict
A refresh token is signed by the same key, has a valid exp, iss and aud, and passes every default validator. Nothing in the framework has heard of your token_type claim — the one we added in Part 1 and never used. That is why it was worth adding: the claim is inert until something checks it.
This also means that moving from a hand-written filter to oauth2ResourceServer() silently drops every custom check that lived inside the filter, and the build still passes. The checks have to move explicitly, onto the decoder:
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer), // exp, nbf, iss
AudienceValidator.forAudience(audience), // aud - NOT included by default
new AccessTokenTypeValidator())); // your token_type check
Note the middle line. JwtValidators.createDefaultWithIssuer validates exp, nbf and iss — it does not validate aud. In an estate where every service trusts the same issuer, that means a token minted for the reporting API is accepted by the payments API without complaint. It is a confused-deputy vulnerability that arrives by default, and it is the single most valuable line in this article.
Three things Spring Security 7 changed under you
All three showed up while building the companion repository, none of them in a place I was looking. If you are upgrading rather than starting fresh, these are the ones that will not appear in your compiler output.
1. There is a new authority in your token. Every bearer-token authentication now carries FACTOR_BEARER alongside your own:
It backs the new multi-factor authorization support (AuthorizationManagerFactories.multiFactor(), @EnableMultiFactorAuthentication, and in 7.1 the when/withWhen conditions). Harmless — until a test asserts an exact authority set, or code assumes every authority starts with ROLE_ or SCOPE_.
2. Every 401 now advertises resource metadata. That resource_metadata="…/.well-known/oauth-protected-resource" parameter you saw in the Part 2 responses is RFC 9728 support, added by a new OAuth2ProtectedResourceMetadataFilter in the resource-server chain. Spring Security 7.1 also adds charset to WWW-Authenticate. If you have clients that parse that header strictly, they will see fields they did not before.
3. The Boot 4 test slices moved. Not Spring Security’s doing, but you will hit it in the same afternoon:
spring-boot-starter-test alone no longer provides it — you need spring-boot-starter-webmvc-test and spring-boot-starter-security-test. The compiler error reads like a corrupt dependency rather than a relocation.
Worth knowing alongside those: Spring Security 7 moves to Jackson 3 (SecurityJackson2Modules becomes SecurityJacksonModules), which matters the moment you serialise a SecurityContext into a cache or session store — see the Jackson 3 migration guide. And Boot 4.1 adds spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions, a SpEL-based authority extractor that finally handles Keycloak-style nested role claims without a custom converter.
The rest of the edge cases
These are the ones that reach production. Each has a section in the repository documentation with the reproduction and the fix — one line each here, so you can recognise the symptom when it appears.
Sixty seconds of clock skew — JwtTimestampValidator accepts a token a full minute past exp by default, which is why a test that sleeps past expiry and asserts 401 fails.
A JWT cannot be revoked — client-side “logout” deletes nothing; you need a jti and a denylist, and that reintroduces the shared state JWTs were meant to avoid.
Refresh tokens must rotate — a reusable refresh token makes theft undetectable; rotating one turns a replay into an alertable signal.
Token size is a real cost — headers travel on every request, and a permissions array can exceed proxy header limits for exactly your most privileged users.
Three authority conventions — SCOPE_ from the default converter, ROLE_ from hasRole, and bare strings from hasAuthority, crossed constantly.
That last one has a companion: allowUriQueryParameter exists on DefaultBearerTokenResolver and should stay off, because URLs land in access logs, browser history and Referer headers.
Running it
git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git
cd jwt-auth-demo
./scripts/run.sh hs256 # then, in another shell:
./scripts/curl-transcript.sh
Twenty steps end to end — login, decode, 401, 403, tamper, refresh with rotation, replay, revoke — plus rs256, csrfon, shortlived and resourceserver profiles for the individual failures, and 13 tests pinning the status-code contract. Everything under docs/output/ is captured output, regenerated wholesale by ./scripts/run-all.sh.
The eleven documentation chapters go considerably deeper than this article does — in particular the production checklist, which is the part to actually run against your own code before shipping.
Before you build any of this, ask whether you should. If you have one server-rendered application and need sessions, a session cookie is simpler, revocable by design, and has no key management. If you need federated identity, an authorization server — Keycloak, Auth0, Okta, or Spring Authorization Server — already implements every item on that checklist, correctly, and consuming its tokens with oauth2ResourceServer() means neither half of this article is your code. A hand-rolled JWT layer is the right answer for a stateless API you own end to end, and a considerable amount of work everywhere else.
That is the honest closing note, and it is the same trade the article opened with. A token buys statelessness. Everything in Part 3 is the invoice.
Further reading
jwt-auth-demo — the companion repository: both configurations, both algorithms, 11 documentation chapters and captured output for every claim above
RFC 6750 — where invalid_token and insufficient_scope are defined
Related
8 Replies to “Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)”
Rajesh IyerAug 22nd at 12:00 pm
The CSRF section is the one I wish I’d had two years ago. We lost most of a sprint on exactly this after a Boot 2 to Boot 3 upgrade — login worked in Postman and 403’d from the React app, which sent everyone chasing CORS instead.
The reason it worked in Postman, for anyone hitting the same thing: our Postman collection had a pre-request script that pulled a CSRF cookie from an earlier GET. So the tool we were debugging with was the only client sending a valid token. Once we tested with plain curl the 403 was instant and reproducible.
Your “bare WWW-Authenticate: Bearer on a 403 with an empty body, POST only” fingerprint is a better diagnostic than anything I found at the time. Adding it to our runbook.
The point about aud not being validated by default deserves to be higher up — I think a lot of people assume createDefaultWithIssuer means “all the standard claims”.
Two follow-ups from our Keycloak setup. First, Keycloak puts roles at realm_access.roles, which JwtGrantedAuthoritiesConverter cannot reach because it only reads top-level claims. We wrote a custom converter for it. Since Boot 4.1 the property you mention does it without code:
Worth noting it is mutually exclusive with authorities-claim-name — setting both fails at startup rather than silently preferring one, which I was glad about.
Second question: Keycloak often issues tokens with aud as an array containing “account” plus the client. Does JwtClaimValidator with a List handle that, or do you need the explicit contains check?
@Marta — the JwtClaimValidator<List<String>> form works fine with a multi-value aud, because you are supplying the predicate yourself and aud.contains(“your-client”) is true regardless of what else is in the array. The thing to be careful about is the opposite direction: contains() means “at least one of the audiences is me”, not “the token was minted only for me”. If you need the stricter reading you have to assert on size as well, and Keycloak’s habit of appending “account” makes that painful.
Separately — FACTOR_BEARER cost me an afternoon this week. We had assertThat(authorities).containsExactly(“ROLE_USER”, “SCOPE_read”) in about forty tests and every one of them broke on the 7.0 upgrade. We switched to containsAll and moved on, but I would be interested to know whether the framework guarantees anything about which FACTOR_* authorities appear, or whether that set can grow again in a patch release.
Good article, but I want to push back gently on the denylist as the default answer to revocation.
A per-jti denylist means a Redis round trip on every authenticated request, and you have to size it for peak concurrent tokens rather than peak users. We ran that for a year and the operational cost was real — a Redis blip became a total auth outage, because “cannot reach the denylist” has no safe default. Fail open and revocation is theatre; fail closed and Redis is now a hard dependency of every request.
What we moved to: a single tokensValidAfter timestamp per user, cached aggressively, and a rule that any token with iat before it is refused. That covers the cases that actually matter — password change, logout-everywhere, account compromise — with one entry per user instead of one per token, and it is cheap to cache because it changes rarely. Individual-token revocation we simply gave up on, and paired that with a five-minute access TTL.
Not saying the article is wrong. Saying the jti denylist is the more expensive of the two designs and people should choose it deliberately.
@Tobias — that is a fair trade and we landed in the same place, though we kept a jti denylist purely for the “this specific token was leaked in a log” incident case. It is empty 99% of the time, so the Redis lookup is a miss and cheap, and we fail closed only for that path.
On the clock skew section: the sixty seconds bit us in the other direction. We had two nodes in a cluster where chrony had drifted about forty seconds, and the symptom was tokens that worked on three pods and failed on one, seemingly at random, with nbf rather than exp as the cause. The default skew was masking the drift on most requests and it only surfaced under load balancing. Two things I would add to the checklist: monitor clock drift as a first-class metric, and remember the skew cuts both ways — it forgives a not-yet-valid token just as happily as an expired one.
Also confirming the stripped Authorization header note. Ours was an ALB rule doing a redirect that dropped the header on the second hop. Nothing in the application log, because there was genuinely no token by the time it arrived.
The resource_metadata parameter is not as harmless as it looks. We have an old Android client with a hand-written WWW-Authenticate parser that assumed at most three parameters and gave up on the fourth. After the Spring Security 7 upgrade every 401 came back to the client as a generic network error instead of “please re-authenticate”, so the app never triggered its refresh flow and users got silently logged out.
Nothing wrong with the framework here — RFC 7235 has always allowed arbitrary auth-param lists and our parser was wrong. But if you have native clients you did not write recently, that header is worth diffing before and after the upgrade. It took us longer to find than it should have because the server-side logs looked completely normal: correct 401, correct error code, no exceptions anywhere.
Question: is there a supported way to suppress that parameter, or is removing OAuth2ProtectedResourceMetadataFilter from the chain the only lever?
Question about refresh rotation, because the article stops just short of the part I always get wrong.
You revoke the presented refresh token as it is spent, so a replay is a 401. Good. But the article also says a replay should invalidate the whole token family, and that is where I get stuck: at the moment the replay arrives you cannot tell whether you are talking to the attacker or to a legitimate client whose response got lost on a flaky mobile network. Killing the family logs out a real user; not killing it lets the attacker keep the token they stole.
What we do now is put a family id claim on the refresh token, carry it through every rotation, and on a replay kill the family but return a distinguishable error so the client shows “please sign in again” rather than a generic failure. It is still hostile to users on bad connections. Has anyone found something better than a short grace window where the previous token is still accepted once?
Also a small thing that cost me an hour: if you generate the new refresh token before revoking the old one and something throws in between, you have issued a live token whose predecessor is still valid. Order matters.
The @PreAuthorize self-invocation item in your list is understated. In our case it was not a private method — it was a public method on the same class called from another public method, which is the version people do not think of because the annotation is right there on a public method and looks like it must be doing something.
We only found it because a penetration tester did. The endpoint had a correct @PreAuthorize(“hasRole(‘ADMIN’)”), and it was genuinely enforced when called through the proxy from a controller. But an internal batch path called it directly from a sibling method in the same bean, so the check never ran, and that path was reachable from an unauthenticated webhook. Nine months in production.
Two things that would have caught it: an ArchUnit rule that no method annotated with @PreAuthorize is called from within its own class, and treating “authorization only at the URL layer” as the primary control with method security as defence in depth rather than the other way round.
Very good article overall. The distinction between what the filter chain enforces and what the proxy enforces is the thing most JWT tutorials never mention at all.
The CSRF section is the one I wish I’d had two years ago. We lost most of a sprint on exactly this after a Boot 2 to Boot 3 upgrade — login worked in Postman and 403’d from the React app, which sent everyone chasing CORS instead.
The reason it worked in Postman, for anyone hitting the same thing: our Postman collection had a pre-request script that pulled a CSRF cookie from an earlier GET. So the tool we were debugging with was the only client sending a valid token. Once we tested with plain curl the 403 was instant and reproducible.
Your “bare WWW-Authenticate: Bearer on a 403 with an empty body, POST only” fingerprint is a better diagnostic than anything I found at the time. Adding it to our runbook.
The point about aud not being validated by default deserves to be higher up — I think a lot of people assume createDefaultWithIssuer means “all the standard claims”.
Two follow-ups from our Keycloak setup. First, Keycloak puts roles at realm_access.roles, which JwtGrantedAuthoritiesConverter cannot reach because it only reads top-level claims. We wrote a custom converter for it. Since Boot 4.1 the property you mention does it without code:
spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions: “[‘realm_access’][‘roles’]”
spring.security.oauth2.resourceserver.jwt.authority-prefix: “ROLE_”
Worth noting it is mutually exclusive with authorities-claim-name — setting both fails at startup rather than silently preferring one, which I was glad about.
Second question: Keycloak often issues tokens with aud as an array containing “account” plus the client. Does JwtClaimValidator with a List handle that, or do you need the explicit contains check?
@Marta — the JwtClaimValidator<List<String>> form works fine with a multi-value aud, because you are supplying the predicate yourself and aud.contains(“your-client”) is true regardless of what else is in the array. The thing to be careful about is the opposite direction: contains() means “at least one of the audiences is me”, not “the token was minted only for me”. If you need the stricter reading you have to assert on size as well, and Keycloak’s habit of appending “account” makes that painful.
Separately — FACTOR_BEARER cost me an afternoon this week. We had assertThat(authorities).containsExactly(“ROLE_USER”, “SCOPE_read”) in about forty tests and every one of them broke on the 7.0 upgrade. We switched to containsAll and moved on, but I would be interested to know whether the framework guarantees anything about which FACTOR_* authorities appear, or whether that set can grow again in a patch release.
Good article, but I want to push back gently on the denylist as the default answer to revocation.
A per-jti denylist means a Redis round trip on every authenticated request, and you have to size it for peak concurrent tokens rather than peak users. We ran that for a year and the operational cost was real — a Redis blip became a total auth outage, because “cannot reach the denylist” has no safe default. Fail open and revocation is theatre; fail closed and Redis is now a hard dependency of every request.
What we moved to: a single tokensValidAfter timestamp per user, cached aggressively, and a rule that any token with iat before it is refused. That covers the cases that actually matter — password change, logout-everywhere, account compromise — with one entry per user instead of one per token, and it is cheap to cache because it changes rarely. Individual-token revocation we simply gave up on, and paired that with a five-minute access TTL.
Not saying the article is wrong. Saying the jti denylist is the more expensive of the two designs and people should choose it deliberately.
@Tobias — that is a fair trade and we landed in the same place, though we kept a jti denylist purely for the “this specific token was leaked in a log” incident case. It is empty 99% of the time, so the Redis lookup is a miss and cheap, and we fail closed only for that path.
On the clock skew section: the sixty seconds bit us in the other direction. We had two nodes in a cluster where chrony had drifted about forty seconds, and the symptom was tokens that worked on three pods and failed on one, seemingly at random, with nbf rather than exp as the cause. The default skew was masking the drift on most requests and it only surfaced under load balancing. Two things I would add to the checklist: monitor clock drift as a first-class metric, and remember the skew cuts both ways — it forgives a not-yet-valid token just as happily as an expired one.
Also confirming the stripped Authorization header note. Ours was an ALB rule doing a redirect that dropped the header on the second hop. Nothing in the application log, because there was genuinely no token by the time it arrived.
The resource_metadata parameter is not as harmless as it looks. We have an old Android client with a hand-written WWW-Authenticate parser that assumed at most three parameters and gave up on the fourth. After the Spring Security 7 upgrade every 401 came back to the client as a generic network error instead of “please re-authenticate”, so the app never triggered its refresh flow and users got silently logged out.
Nothing wrong with the framework here — RFC 7235 has always allowed arbitrary auth-param lists and our parser was wrong. But if you have native clients you did not write recently, that header is worth diffing before and after the upgrade. It took us longer to find than it should have because the server-side logs looked completely normal: correct 401, correct error code, no exceptions anywhere.
Question: is there a supported way to suppress that parameter, or is removing OAuth2ProtectedResourceMetadataFilter from the chain the only lever?
Question about refresh rotation, because the article stops just short of the part I always get wrong.
You revoke the presented refresh token as it is spent, so a replay is a 401. Good. But the article also says a replay should invalidate the whole token family, and that is where I get stuck: at the moment the replay arrives you cannot tell whether you are talking to the attacker or to a legitimate client whose response got lost on a flaky mobile network. Killing the family logs out a real user; not killing it lets the attacker keep the token they stole.
What we do now is put a family id claim on the refresh token, carry it through every rotation, and on a replay kill the family but return a distinguishable error so the client shows “please sign in again” rather than a generic failure. It is still hostile to users on bad connections. Has anyone found something better than a short grace window where the previous token is still accepted once?
Also a small thing that cost me an hour: if you generate the new refresh token before revoking the old one and something throws in between, you have issued a live token whose predecessor is still valid. Order matters.
The @PreAuthorize self-invocation item in your list is understated. In our case it was not a private method — it was a public method on the same class called from another public method, which is the version people do not think of because the annotation is right there on a public method and looks like it must be doing something.
We only found it because a penetration tester did. The endpoint had a correct @PreAuthorize(“hasRole(‘ADMIN’)”), and it was genuinely enforced when called through the proxy from a controller. But an internal batch path called it directly from a sibling method in the same bean, so the check never ran, and that path was reachable from an unauthenticated webhook. Nine months in production.
Two things that would have caught it: an ArchUnit rule that no method annotated with @PreAuthorize is called from within its own class, and treating “authorization only at the URL layer” as the primary control with method security as defence in depth rather than the other way round.
Very good article overall. The distinction between what the filter chain enforces and what the proxy enforces is the thing most JWT tutorials never mention at all.