JWT Authentication in Spring Boot, End to End
Nearly every backend I have worked on ended up issuing JWTs, and nearly every one of them got at least one thing wrong on the first pass. Usually the same thing: treating the token as if it were secret. It is not. A JWT is signed, not encrypted — anyone holding one can read every claim inside it.
This is the walkthrough I wish I had when I first wired Spring Security, JWT and Keycloak together at CureBay: what the format really is, what the server must verify, and where the design genuinely hurts.
Three segments, two dots
A JWT is three base64url-encoded segments joined by dots: header.payload.signature. Split one apart and it stops being mysterious.
{
"alg": "RS256",
"typ": "JWT",
"kid": "a3f1c9e2-2b77-4f0a-9d31-6c0e5b2a11de"
}{
"iss": "https://auth.example.com/realms/connect",
"sub": "8c2b1f04-5a9d-4e77-b0c3-91f2ad6e4b18",
"aud": "connect-api",
"exp": 1781452800,
"iat": 1781451900,
"jti": "0f4a7c9e-11d2-4d6b-8e30-7a5c9b3f2d41",
"scope": "order:read order:write",
"roles": ["PHARMACY_ADMIN"]
}Both are plain JSON, base64url-encoded. Base64 is an encoding, not a cipher — paste any token into a decoder and the payload comes straight out. The third segment is the only thing standing between that payload and an attacker rewriting "roles": ["PHARMACY_ADMIN"] into something more interesting.
The signature is the whole security model
The signature is computed over base64url(header) + "." + base64url(payload). Change one byte of either and verification fails. Which algorithm you sign with decides who is able to mint tokens.
| Family | Key | Use when |
|---|---|---|
| HS256 | One shared secret, signs and verifies | A single service issues and consumes its own tokens |
| RS256 / ES256 | Private key signs, public key verifies | An auth server issues; many services verify |
The distinction matters more than it looks. With HS256, every service that can verify a token can also forge one — they hold the same secret. The moment you have more than one consumer, that is an unacceptable blast radius. Go asymmetric: the auth server keeps the private key, everyone else fetches public keys from a JWKS endpoint and can only check signatures.
"alg": "none" from the header, or let an attacker swap RS256 for HS256 and sign with the public key as if it were an HMAC secret. Never let the token tell you how to verify it. Pin the expected algorithm server-side.Validating tokens in Spring Security 6
Here is the part people over-engineer. If another system issues your tokens — Keycloak, Auth0, Cognito — you do not write a filter. Spring Security ships a resource server that does key discovery, caching, rotation and claim validation for you.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/connect
audiences: connect-apiThat issuer-uri is doing real work. On startup Spring fetches /.well-known/openid-configuration, finds the JWKS URL, and caches the public keys — keyed by the kid in each token header, so key rotation on the auth server just works without a redeploy.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
// stateless API: no session to fixate, no CSRF token to leak
.csrf(CsrfConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/api/v1/public/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth
.jwt(jwt -> jwt.jwtAuthenticationConverter(converter())))
.build();
}
/** Map the realm's role claim onto Spring authorities. */
private JwtAuthenticationConverter converter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
scopes.setAuthorityPrefix("SCOPE_");
JwtAuthenticationConverter c = new JwtAuthenticationConverter();
c.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> out = new ArrayList<>(scopes.convert(jwt));
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.forEach(out::add);
}
return out;
});
return c;
}
}anyRequest().authenticated() as the last rule is deliberate. Default-deny means a new controller is protected the moment it exists — the alternative fails open, and that failure is silent.
Verify the audience, not just the signature
A valid signature only proves the token came from your issuer. It does not prove the token was meant for this service. Without an audience check, a token minted for the low-privilege reporting API is accepted by the payments API.
@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
String issuer = props.getJwt().getIssuerUri();
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer), // exp, nbf, iss
new JwtClaimValidator<List<String>>(
"aud", aud -> aud != null && aud.contains("connect-api"))));
return decoder;
}Issuing your own tokens
When you are the auth server, sign with a private key and expose the public one. Spring Security's JwtEncoder handles the encoding.
@Service
public class TokenService {
private static final Duration ACCESS_TTL = Duration.ofMinutes(10);
private final JwtEncoder encoder;
public String accessToken(UserPrincipal user) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("https://auth.example.com")
.audience(List.of("connect-api"))
.subject(user.id().toString())
.issuedAt(now)
.expiresAt(now.plus(ACCESS_TTL))
.id(UUID.randomUUID().toString()) // jti, for revocation lists
.claim("scope", String.join(" ", user.scopes()))
.claim("roles", user.roles())
.build();
JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
}Ten minutes is not arbitrary. Short expiry is the only revocation mechanism a stateless token has — which brings us to the trade-off nobody advertises.
The revocation problem
Sessions are revocable because the server holds the state: delete the row, the session is gone. A JWT is the opposite. It is valid because it says so and the maths agrees. Ban a user at 10:02 and their token still opens every door until exp.
There are three honest answers, and one dishonest one:
- Short-lived access tokens. Cap the damage at a few minutes. Simple, and right for most systems.
- A denylist of
jtivalues in Redis, expiring at the token's ownexp. Bounded in size, gives instant revocation. - A token version per user. Put
verin the claims, compare it to the user record, bump it to invalidate everything they hold. - The dishonest one: checking the database on every request to confirm the user is still active. That works — and you have rebuilt sessions with extra steps and worse ergonomics. If you need that, use sessions.
Refresh tokens, and rotating them properly
Short access tokens mean re-authenticating constantly unless you pair them with a refresh token: long-lived, opaque, stored server-side, and used only against the token endpoint.
Naive refresh handling hands back the same refresh token forever. If it is ever stolen, the attacker has permanent access and you will never know. Rotation with reuse detection fixes that: every refresh issues a new token and invalidates the old one, so a replayed token is proof of compromise.
@Transactional
public TokenPair refresh(String presented) {
RefreshToken token = repo.findByHash(sha256(presented))
.orElseThrow(() -> new BadCredentialsException("unknown refresh token"));
// Already rotated away => someone replayed an old token.
// Assume the family is compromised and revoke all of it.
if (token.isRotated()) {
repo.revokeFamily(token.familyId());
log.warn("refresh token reuse detected for family {}", token.familyId());
throw new BadCredentialsException("token reuse detected");
}
if (token.expiresAt().isBefore(Instant.now())) {
throw new CredentialsExpiredException("refresh token expired");
}
token.markRotated();
RefreshToken next = repo.save(RefreshToken.issue(token.userId(), token.familyId()));
return new TokenPair(tokens.accessToken(token.user()), next.plaintext());
}Store the hash of the refresh token, never the value. A leaked database dump then yields nothing usable — the same reasoning that applies to passwords.
Where to keep the token in the browser
This is where most front-end JWT advice goes wrong. localStorage is readable by any JavaScript on the page, which means one XSS — one compromised npm dependency — and the token walks out.
The safer arrangement is an HttpOnly, Secure, SameSite=Strict cookie. JavaScript cannot read it, so XSS cannot exfiltrate it. Cookies reintroduce CSRF, but SameSite plus a CSRF token on state-changing routes is a well-understood problem with a well-understood fix. XSS token theft is neither.
ResponseCookie cookie = ResponseCookie.from("refresh_token", value)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.path("/api/v1/auth/refresh") // sent nowhere else
.maxAge(Duration.ofDays(14))
.build();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie.toString())
.body(new AccessTokenResponse(accessToken));Note the path. The refresh token is attached only to the refresh endpoint, so it is not broadcast on every API call it has no business being part of.
The checklist
What I actually verify before an auth change ships:
- Algorithm is pinned server-side;
algfrom the token is never trusted. iss,audandexpare all validated, not just the signature.- Access tokens expire in minutes, not days.
- Refresh tokens rotate, are stored hashed, and reuse revokes the family.
- No secrets, no PII beyond an opaque subject, in the payload.
- Clock skew allowance is small and deliberate — 30 seconds, not five minutes.
- The last authorisation rule is
authenticated(), so new endpoints fail closed.