← All posts

JWT Authentication in Spring Boot, End to End

14 Jun 202611 min read
JWTSpring SecurityAuth

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.

header — decoded
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "a3f1c9e2-2b77-4f0a-9d31-6c0e5b2a11de"
}
payload — decoded
{
  "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.

Do notPut anything in a JWT you would not print in a log line. No email addresses you are not happy leaking, no internal IDs that grant access on their own, never a password or an API key. The payload is public by construction.

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.

FamilyKeyUse when
HS256One shared secret, signs and verifiesA single service issues and consumes its own tokens
RS256 / ES256Private key signs, public key verifiesAn 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.

The classic exploitOlder libraries honoured "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.

pom.xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/connect
          audiences: connect-api

That 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.

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

Audience validation
@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.

TokenService.java
@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 jti values in Redis, expiring at the token's own exp. Bounded in size, gives instant revocation.
  • A token version per user. Put ver in 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.

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

Setting the refresh cookie
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; alg from the token is never trusted.
  • iss, aud and exp are 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.
The short versionA JWT is a signed claim you chose to believe without asking the database. That is exactly why it scales, and exactly why revoking one is hard. Keep them short-lived, verify every claim you depend on, and never confuse signed with secret.