JWT for stateless APIs: structure, signing, expiry and refresh tokens
A JSON Web Token has three Base64URL parts, header.payload.signature:
- Header: the algorithm, such as
HS256orRS256. - 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.
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
@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();
}
}@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
Was this lesson helpful?
Finished reading? Mark it complete to track your progress.