Databases, Spring and microservices · 4. Spring Security, lesson 6 of 8

JWT for stateless APIs: structure, signing, expiry and refresh tokens

Advanced3 min read@since 17Code runs on your Java 25
Explain it forThe essentials plus production detail and pitfalls.

A JSON Web Token has three Base64URL parts, header.payload.signature:

  • Header: the algorithm, such as HS256 or RS256.
  • Payload: claims such as sub (who), iss (issuer), aud (audience), exp (expiry), iat (issued at), and roles or scopes. It is encoded, not encrypted: anyone can read it.
  • Signature: proves the token was issued by someone holding the key and hasn't been changed.

Signing: HS256 uses one shared secret (sign and verify with the same key), simple for a single API. RS256/ES256 sign with a private key and verify with a public key, published as a JWKS, so many services can verify tokens without being able to create them.

Stateless: the API validates signature, issuer, audience and expiry locally, with no session and no database lookup. The flip side: a token can't easily be revoked before it expires. So:

  • keep access tokens short-lived (5 to 15 minutes);
  • use a refresh token (long-lived, stored server-side so it can be revoked, rotated on every use) to get new access tokens.

In Spring: oauth2ResourceServer(o -> o.jwt(...)) validates incoming tokens; JwtEncoder issues them.

Diagram
JWT lab

Where should the browser keep tokens?

  • localStorage / sessionStorage: simple, but any JavaScript on the page can read them, so one XSS bug leaks every token.
  • Memory only (a variable): XSS can still use the token while the page is open, but can't steal it permanently; lost on reload.
  • HttpOnly, Secure, SameSite cookie: JavaScript can't read it, and the browser sends it automatically, which brings CSRF back into play (keep CSRF protection on).

A common robust setup: the access token in memory, the refresh token in an HttpOnly cookie restricted to the refresh endpoint. Or a backend-for-frontend that keeps all tokens on the server.

Logging out and revoking tokens

A JWT stays valid until it expires, even after logout. Options, from simplest: keep access tokens short-lived and revoke the refresh token on logout; store a per-user token version in the database and put it in the token, so bumping it invalidates every existing token ("sign out everywhere"); or keep a denylist of token IDs (jti) until they expire. Checking the database on every request trades away some statelessness for control, which is often worth it for admin accounts.

Example

Java
@Configuration
class JwtConfig {
    @Bean
    JwtDecoder jwtDecoder(@Value("${jwt.secret}") String secret) {        // 32+ random bytes, from an env variable
        SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        return NimbusJwtDecoder.withSecretKey(key).build();
    }

    @Bean
    JwtEncoder jwtEncoder(@Value("${jwt.secret}") String secret) {
        return new NimbusJwtEncoder(new ImmutableSecret<>(secret.getBytes(StandardCharsets.UTF_8)));
    }
}

@Service
class TokenService {
    private final JwtEncoder encoder;
    TokenService(JwtEncoder encoder) { this.encoder = encoder; }

    String issueFor(Authentication auth) {
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("https://api.javaatlas.com")
                .subject(auth.getName())
                .issuedAt(now)
                .expiresAt(now.plus(15, ChronoUnit.MINUTES))              // short-lived
                .claim("roles", auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList())
                .build();
        return encoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
    }
}
Turning the roles claim into authorities
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
    roles.setAuthoritiesClaimName("roles");     // read authorities from "roles" instead of "scope"
    roles.setAuthorityPrefix("");               // the claim already contains ROLE_USER, ROLE_ADMIN
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(roles);
    return converter;
}

Common mistake

Putting personal or secret data in the payload (it's only Base64URL-encoded), or issuing access tokens valid for days.

Under the hood

Where the browser keeps tokens matters: localStorage is readable by any script on the page, so one XSS bug leaks every token. An HttpOnly, Secure, SameSite cookie can't be read by JavaScript (but then you need CSRF protection again). A common, robust pattern is a short-lived access token in memory plus a refresh token in an HttpOnly cookie, or a backend-for-frontend that keeps tokens server-side entirely.

Check yourself

Can anyone read the claims inside a signed JWT?

How this connects

Part of Spring Core and Spring Security in depth.

Was this lesson helpful?

Finished reading? Mark it complete to track your progress.