Java & Spring Boot6 min read·By Liyabona Saki··

Spring Security + JWT — Stateless Auth Done Right

A practical, modern Spring Security 6 setup: JWT validation, stateless sessions, and method-level authorization.

Stop putting roles in the JWT — and other things I learned the hard way

The first JWT-based auth system I shipped put the user's role inside the access token. Two weeks later I needed to revoke a user's admin permission and discovered I had no way to do it for the next 60 minutes — the token said "admin" and the resource server believed it.

JWTs are not the problem. The way most tutorials teach them is. This article is the configuration I actually use in production, why each piece exists, and the five things to *not* do that the popular tutorials get wrong.

What goes in the token

A useful access token contains the minimum needed for authorization decisions on the resource server without an extra database round trip:

  • sub — the user id (UUID, not email)
  • iat, exp — issued/expiry. 15 minutes for access, 7–30 days for refresh.
  • scope — coarse-grained scopes like orders.read, not roles.

What does not go in: user email, display name, mutable role assignments, anything you would be embarrassed to find leaked in a log line. The token will end up in proxy logs, browser localStorage, and your own access log. Treat it accordingly.

The Spring Security 6 config

```java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {

@Bean SecurityFilterChain api(HttpSecurity http, JwtDecoder decoder) throws Exception { return http .csrf(csrf -> csrf.disable()) .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) .authorizeHttpRequests(a -> a .requestMatchers("/v1/auth/", "/actuator/health").permitAll() .requestMatchers("/v1/admin/").hasAuthority("SCOPE_admin") .anyRequest().authenticated()) .oauth2ResourceServer(o -> o.jwt(j -> j.decoder(decoder))) .build(); }

@Bean JwtDecoder jwtDecoder(@Value("${auth.jwks-uri}") String jwks) { return NimbusJwtDecoder.withJwkSetUri(jwks).build(); } } ```

Two things to point at:

  • Stateless session. No JSESSIONID cookie, no server-side session storage. The token is the credential.
  • JWKS endpoint, not a hardcoded secret. The decoder fetches the signing public key from the auth server. Rotating the signing key becomes "deploy the auth server with a new key" — zero changes to resource servers.

The revocation problem and how to actually solve it

The core JWT design assumption is that tokens are *short-lived* enough that revocation does not matter. If your access token lives 15 minutes, "I revoked your access" means "your token will stop working in at most 15 minutes". For most apps that is acceptable.

For the cases where it is not (compromised account, dismissed employee), you need a revocation list. The cheap version:

```java
@Component
class RevocationCheck implements OAuth2TokenValidator<Jwt> {
  private final StringRedisTemplate redis;

public OAuth2TokenValidatorResult validate(Jwt jwt) { var jti = jwt.getId(); if (jti != null && redis.hasKey("revoked:" + jti)) { return OAuth2TokenValidatorResult.failure( new OAuth2Error("invalid_token", "Token revoked", null)); } return OAuth2TokenValidatorResult.success(); } } ```

You set the Redis key with a TTL matching the token's remaining lifetime. The set is bounded in size, lookup is sub-millisecond, and the revocation list cleans itself up.

Refresh tokens: rotation is non-optional

The refresh token is the long-lived credential. If it is stolen, the attacker has 30 days of access. The mitigation is *rotation*: every refresh issues a new refresh token and invalidates the old one. If the same refresh token is presented twice, you have detected token reuse — invalidate the entire session and force re-login.

This is a five-line change in your token endpoint and it converts "stolen refresh token" from "30-day breach" to "detected within seconds of the legitimate user's next request".

The five things tutorials get wrong

1. Symmetric (HS256) signing in production. With HS256 the resource server holds the signing secret, which means a compromise of any resource server compromises token issuance. Use RS256 (or ES256) so resource servers only hold the public key. 2. Storing the access token in localStorage. It is readable by any script on the page, which means any XSS is a token theft. Use an HttpOnly cookie for the refresh token and keep the access token in memory only. 3. Sending the JWT to the browser without HTTPS. This is obvious and still gets shipped. Enforce HSTS. 4. Trusting iss without checking it. The JWT spec says you must validate the issuer. NimbusJwtDecoder does it if you configure the issuer — many tutorials skip that step. 5. Putting roles, not scopes, in the token. Roles change. Scopes describe what the token can do. Bake scopes; look up roles fresh from the database when you need them.

Where authorization checks belong

Authentication says "who". Authorization says "can they". Spring Security's URL-level matchers are fine for coarse rules ("/admin/** needs admin"). Per-object rules ("can user X edit document Y?") belong in the service layer:

java
@PreAuthorize("@docs.canEdit(authentication.name, #id)")
public Document update(UUID id, EditCommand cmd) { ... }

Putting these checks in controllers is a category error — you will eventually call the service from a background job that has no controller, and the check will vanish.

Logging — what to capture and what to never log

Capture: token jti, sub, iss, the path, the result. That is enough to investigate any auth incident.

Never log: the raw token, the refresh token, the signing key. If you find a "let me just log the token for debugging" line in code review, treat it as a P1 secret leak — once it lands in your log aggregator it is exposed to anyone with log access.

The honest summary

JWTs are not magic. They are a signed, base64-encoded blob with an expiry. Get the rotation, signing algorithm, and storage right, and they are a perfectly good auth primitive. Get them wrong, and you have built a worse session cookie that you cannot revoke. The configuration above is the version I would ship today — opinionated, boring, and rooted in incidents I would rather not repeat.

Go deeper

Further reading

Source Code

Get the full project on GitHub

View repo →
#Spring Security#JWT#OAuth2#Auth

Stay in the Loop

Get the next tutorial in your inbox

Related tutorials