JWT Authentication Deep Dive
JWTs done safely: structure and claims, why RS256 beats HS256, key rotation with JWKS, the alg=none and key-confusion attacks, and how to revoke a stateless token.
On this page
A JWT is a signed statement that some party made a claim. It is genuinely useful and routinely misused, because the failure modes are silent — a token validated incorrectly still looks like a token that was validated.
Key Takeaways
- A JWT is signed, not encrypted. Anyone can read the payload; never put secrets in it.
- Use RS256, not HS256, whenever more than one service verifies tokens.
- Always pin the expected algorithm. Accepting whatever the header claims is the
alg=noneattack. - Validate issuer, audience and expiry — a signature check alone is not enough.
- Stateless means unrevocable. Short expiry plus a denylist is the practical compromise.
Structure
Three base64url segments separated by dots: header, payload, signature.
{ "alg": "RS256", "typ": "JWT", "kid": "2026-07-key-1" }{
"iss": "https://auth.acme.com",
"sub": "user_8Fj3kQ",
"aud": "https://api.acme.com",
"exp": 1785500000,
"iat": 1785499100,
"jti": "9f1c2b7e-3d4a-4f2b-8c1d-0a5e7b9f3c21",
"scope": "orders.read orders.write",
"roles": ["CUSTOMER"]
}The registered claims each do a specific security job. iss names who issued it, so you can reject
tokens from an issuer you do not trust. aud names who it is for — without checking it, a token
issued for a low-value service is accepted by a high-value one. exp bounds the damage from a
leaked token. And jti gives it a unique id, which is what makes revocation possible at all.
Because the payload is only base64-encoded, it is readable by anyone holding the token. Email addresses, internal user ids and role names all travel in clear text to the browser and into any proxy log that captures the header.
RS256 over HS256
That distinction decides the blast radius of a compromise. With HS256 in a microservices estate, an attacker who reads the secret from any one service's configuration can mint a token claiming to be anyone with any role. With RS256 they get a public key, which lets them verify tokens they could already read.
The attacks
alg=none. The original JWT specification included a "none" algorithm for unsigned tokens. An
attacker edits the header to {"alg":"none"}, edits the payload to grant themselves admin, and
removes the signature. A naive library that reads the algorithm from the token accepts it.
Key confusion. The server expects RS256. The attacker changes the header to HS256 and signs the token using the server's public key as the HMAC secret. A library that picks the verification method from the header will HMAC-verify with the public key, which the attacker also has, and it matches.
Both attacks share one root cause: trusting the token to say how it should be verified. The defence is to decide the algorithm in configuration and reject anything else:
@Configuration
public class JwtDecoderConfig {
@Bean
JwtDecoder jwtDecoder(@Value("${app.auth.issuer}") String issuer,
@Value("${app.auth.audience}") String audience,
@Value("${app.auth.jwks-uri}") String jwksUri) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwksUri)
// Pinned. A token claiming HS256 or none is rejected outright.
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
// Checks exp and nbf, with 60s clock skew tolerance.
new JwtTimestampValidator(Duration.ofSeconds(60)),
new JwtIssuerValidator(issuer),
// Without this, a token minted for another service is accepted here.
new JwtClaimValidator<List<String>>("aud",
aud -> aud != null && aud.contains(audience))));
return decoder;
}
}Key rotation with JWKS
The authorisation server publishes its public keys at
/.well-known/jwks.json, and each token's header carries a kid naming which key signed it.
Verifiers fetch and cache the key set, looking up by kid.
This is what makes rotation possible without downtime. Publish the new key alongside the old, start signing with the new one, wait for every token signed by the old key to expire, then remove it. Verifiers pick up the change on their next cache refresh with no coordinated deploy.
Two practical notes. Cache the JWKS response — refetching per request turns your auth server into a
hot dependency on every API call. And handle an unknown kid by refetching once before rejecting,
otherwise a rotation that happens between cache refreshes causes a burst of spurious 401s.
Access and refresh tokens
The split exists because the two tokens have different jobs. The access token is sent to every resource server on every request, so it is exposed constantly — hence the short life. The refresh token goes only to the auth server, rarely, so it can live longer.
Rotate refresh tokens. Each use issues a new one and invalidates the old. If an old token is presented again, either it was stolen and replayed or the legitimate client raced — either way, the correct response is to invalidate the whole token family and force re-authentication. This is the only reliable way to detect refresh-token theft.
Storage matters as much as expiry. The refresh token belongs in an HttpOnly; Secure; SameSite=Strict
cookie scoped to the token endpoint path, where JavaScript cannot read it. The access token lives in
a JavaScript variable — never localStorage, where one XSS reads it.
Revocation
Statelessness and revocation are in direct tension: the whole point of a JWT is that verification needs no lookup, and revocation needs exactly that lookup.
@Service
public class TokenDenylist {
private final StringRedisTemplate redis;
/** Revoke by jti, with a TTL matching the token's remaining life. */
public void revoke(String jti, Instant expiresAt) {
Duration remaining = Duration.between(Instant.now(), expiresAt);
if (remaining.isPositive()) {
// Expiring the entry with the token means the denylist stays small:
// it only ever holds tokens that are still otherwise valid.
redis.opsForValue().set("revoked:" + jti, "1", remaining);
}
}
public boolean isRevoked(String jti) {
return Boolean.TRUE.equals(redis.hasKey("revoked:" + jti));
}
}
@Component
public class DenylistValidator implements OAuth2TokenValidator<Jwt> {
private final TokenDenylist denylist;
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
return denylist.isRevoked(jwt.getId())
? OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "Token has been revoked", null))
: OAuth2TokenValidatorResult.success();
}
}Be honest about what this costs: a Redis lookup per request, and a hard dependency on Redis for authentication. Decide the failure mode deliberately — failing open keeps you serving during a Redis outage but honours revoked tokens; failing closed is safer and takes your API down with Redis.
For many systems the better answer is to skip the denylist and set the access token expiry low enough that the exposure window is acceptable. Revocation then applies to the refresh token, which is already stateful.
Extracting authorities
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("ROLE_"); // so hasRole('ADMIN') matches
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
converter.setPrincipalClaimName("sub");
return converter;
}By default Spring reads the scope claim and prefixes SCOPE_. If your tokens carry roles under a
different claim, the conversion above is what makes hasRole work — and its absence is the usual
explanation for an authenticated request being denied by a rule that looks correct.
What to take away
Sign with RS256, pin the algorithm in configuration, and validate issuer, audience and expiry — not
just the signature. Rotate keys through JWKS. Keep access tokens short and out of localStorage,
rotate refresh tokens to detect theft, and treat revocation as a deliberate trade of statelessness
rather than something you get for free.
Frequently Asked Questions
Should a JWT be stored in localStorage?
How do I revoke a JWT before it expires?
Is HS256 ever acceptable?
Related tutorials
- In-Memory & JDBC AuthenticationWhere user credentials live: in-memory users for tests, JdbcUserDetailsManager and its schema, writing a custom UserDetailsService, and seeding an initial administrator safely.
- OAuth 2.0 — The Complete GuideOAuth 2.0 without the confusion: the four actors, the grants that still matter, why PKCE is mandatory, refresh token rotation, and what OAuth 2.1 removed.
- HTTP Basic & Form-Based AuthenticationThe two classic authentication mechanisms: when Basic is appropriate, configuring form login properly, custom success and failure handlers, logout, and account lockout that is not a DoS.
- Spring Authorization ServerRunning your own OAuth 2.1 and OIDC provider: registering clients, persisting authorizations, JWK sources and key rotation, custom claims, and the consent page.