Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate7 min readUpdated
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=none attack.
  • 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.

header
{ "alg": "RS256", "typ": "JWT", "kid": "2026-07-key-1" }
payload
{
  "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

With HS256 every verifier is also a potential forger. RS256 separates signing from verification.

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:

JwtDecoderConfig.java
@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

Short-lived access tokens bound the damage from a leak; rotating refresh tokens make theft detectable.

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.

TokenDenylist.java
@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

AuthoritiesConverter.java
@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?
No. Anything in localStorage is readable by any JavaScript on the page, so a single XSS becomes full account takeover. Keep the refresh token in an HttpOnly, Secure, SameSite cookie and hold the short-lived access token in memory only, where a page reload discards it.
How do I revoke a JWT before it expires?
You cannot invalidate the token itself — that is what stateless means. The workable answers are short expiry (5-15 minutes) so the window is small, plus a denylist of revoked token ids in Redis with a TTL matching the expiry, checked on each request. That reintroduces state, which is the honest cost of revocation.
Is HS256 ever acceptable?
Only when the same service both issues and verifies, and the secret never leaves it. The moment a second service needs to verify, it needs the signing secret — and anything that can verify can now forge. RS256 splits those capabilities: the private key signs, the public key only verifies.

Related tutorials