advanced~2h

JWT: Token-Based Authentication

Session cookies don't scale cleanly across load-balanced servers or mobile clients. This chapter is JWT — a self-contained, signed token that carries identity without server-side session state.

Learning objectives

  • Beginner: Explain the difference between an opaque token and a self-contained (JWT) token.
  • Intermediate: Generate and validate a JWT in a custom Spring Security filter.
  • Advanced: Implement refresh token handling and expiration correctly, including what to do when a refresh token is reused after rotation.

🌱 BEGINNER STORY

Imagine a music festival where, instead of the entry gate keeping a giant logbook of every ticket-holder's name to check every time you enter a different stage area (that's a SERVER-SIDE SESSION — the gatekeeper has to look YOU up every time), they instead give you a special wristband stamped with your name, your ticket tier (VIP/General), and a unique hologram seal that's mathematically impossible to forge without the festival's private stamping machine. Now, ANY gate at ANY stage can instantly verify your wristband just by checking the hologram — no need to call back to a central logbook. If you try to scratch off 'General' and write 'VIP', the hologram seal no longer matches, and it's instantly rejected. That tamper-evident, self-contained, independently-verifiable wristband is exactly what a JWT (JSON Web Token) is.

Before diving into JWT specifically, it's worth understanding the two broad categories of access tokens:

  • Opaque tokens: a random, meaningless string (like a database primary key) that the resource server must look up (a network call to the Authorization Server's introspection endpoint) to find out what it means — simple to revoke instantly, but adds a network round-trip to every request.

  • Self-contained tokens (JWT): the token itself CONTAINS all the claims (user ID, roles, expiry) needed, cryptographically signed — a resource server can verify it LOCALLY (checking the signature) with zero network calls, but is harder to revoke instantly before its natural expiry.

Figure: A JWT has three dot-separated Base64URL-encoded parts: Header, Payload, and Signature.

Header: metadata about the token itself — which signing algorithm was used (e.g., HS256, RS256) and the token type (JWT).

Payload (Claims): the actual data — 'sub' (subject/username), 'roles', 'iat' (issued-at), 'exp' (expiry), and any custom claims you add. IMPORTANT: this is only Base64-ENCODED, not encrypted — anyone can decode and read it (e.g., on jwt.io) without any secret. Never put sensitive data (passwords, SSNs) in a JWT payload.

Signature: a cryptographic signature over the header+payload, computed using a secret key (HMAC, symmetric) or a private key (RSA/EC, asymmetric). This is what makes the token TAMPER-EVIDENT: modifying the payload even slightly produces a completely different, invalid signature.

Figure: Login once to get a signed JWT, then present it on every subsequent request — no server-side session needed.

Figure: Stateful session-cookie auth vs stateless JWT-bearer-token auth.

🌱 BEGINNER STORY

So far, our airport analogy has assumed the standard checkpoint (UsernamePasswordAuthenticationFilter) does the ID-checking automatically. But for a JWT-issuing login endpoint, you usually write your OWN dedicated /api/login controller — it needs to verify the submitted username/password itself and THEN mint a token, rather than relying on Spring Security's built-in form-login filter (which is designed to redirect/set cookies, not return a JSON token). To do this, your controller must directly ask the very same AuthenticationManager that the filters use internally — 'is this username/password combination valid?' — and only generate a JWT if the answer is yes.

Figure: A custom login controller manually invokes the same AuthenticationManager the filter chain would otherwise call automatically.

First, you must explicitly EXPOSE the AuthenticationManager as a bean — by default it's an internal implementation detail not available for injection:

@Bean

public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); }

Then your custom login controller injects it and calls authenticate() directly, exactly like UsernamePasswordAuthenticationFilter would internally:

@RestController

public class LoginController { private final AuthenticationManager authenticationManager; private final JwtTokenGenerator jwtTokenGenerator; public LoginController(AuthenticationManager authenticationManager, JwtTokenGenerator jwtTokenGenerator) { this.authenticationManager = authenticationManager; this.jwtTokenGenerator = jwtTokenGenerator; } @PostMapping("/api/login") public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest request) { Authentication authRequest = new UsernamePasswordAuthenticationToken( request.getEmail(), request.getPassword()); // This is the manual equivalent of what UsernamePasswordAuthenticationFilter // does automatically -- it runs the EXACT SAME internal flow (Chapter 2): // AuthenticationManager -> AuthenticationProvider -> UserDetailsService + PasswordEncoder. Authentication authResult = authenticationManager.authenticate(authRequest); String jwt = jwtTokenGenerator.generateToken(authResult); return ResponseEntity.ok(Map.of("accessToken", jwt)); } }

Notice authenticate() will throw BadCredentialsException (or DisabledException, LockedException, etc.) automatically if verification fails — exactly the same exceptions covered in Chapter 5 — so your controller doesn't need to reimplement any of that logic; it gets the full benefit of whatever AuthenticationProvider(s) and UserDetailsService are already configured, just triggered manually instead of by a filter.

⚠ Why this matters for interviews

A very common interview/real-project question is 'how does your JWT login endpoint actually check the password?' — many beginners write their OWN password-comparison logic inside the controller (bypassing AuthenticationManager entirely), which throws away all the benefits of the framework: account-lockout checks, event publishing (Chapter 5's audit listeners), and consistent exception handling. Always route through AuthenticationManager.authenticate(), even in a fully custom, token-issuing login endpoint.

@Component

public class JwtTokenGenerator { @Value("${jwt.secret}") private String secretKey; public String generateToken(Authentication authentication) { String roles = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.joining(",")); SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); return Jwts.builder() .issuer("eazybank") .subject(authentication.getName()) .claim("roles", roles) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) // 30 min .signWith(key) .compact(); } }

public class JwtValidationFilter extends OncePerRequestFilter {

@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String jwt = authHeader.substring(7); try { SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); Claims claims = Jwts.parser().verifyWith(key).build() .parseSignedClaims(jwt).getPayload(); String username = claims.getSubject(); String roles = claims.get("roles", String.class); List authorities = AuthorityUtils .commaSeparatedStringToAuthorityList(roles); Authentication auth = new UsernamePasswordAuthenticationToken( username, null, authorities); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException ex) { throw new BadCredentialsException("Invalid or expired JWT token", ex); } } filterChain.doFilter(request, response); } } // Registration: http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .addFilterBefore(new JwtValidationFilter(), UsernamePasswordAuthenticationFilter.class);

  • Statelessness — no server-side session storage needed, so any server instance can validate any request independently (great for horizontal scaling and microservices).

  • Cross-domain friendly — works naturally for mobile apps and SPAs calling APIs on different domains, without cookie-related CORS/CSRF complications.

  • Self-contained — the resource server doesn't need to call back to an auth server for every single request (unlike opaque tokens).

  • Standardized — JWTs are a widely adopted, interoperable standard (RFC 7519), integrating naturally with OAuth2/OIDC.

Access tokens are deliberately short-lived (minutes, not days) to limit the damage if one is stolen. To avoid forcing users to re-enter their password every few minutes, a separate, longer-lived REFRESH TOKEN is issued alongside the access token. When the access token expires, the client silently exchanges the refresh token for a new access token (without re-prompting for credentials) — but if the refresh token itself is ever compromised or revoked (e.g., on logout, or detected suspicious activity), the whole chain of trust is broken and the user must log in again.

⚠ What breaks in real production systems

JWTs cannot be easily 'revoked' before their natural expiry (unlike server-side sessions, which can be instantly invalidated) — a stolen JWT remains valid until it expires. Mitigations: keep access-token lifetimes short, maintain a server-side blocklist/deny-list for genuinely compromised tokens, or use a hybrid opaque-token approach for high-security operations. Storing a JWT in browser localStorage is convenient but exposes it to theft via XSS (any injected script can read localStorage) — storing it in an HttpOnly, Secure, SameSite cookie is generally safer, though that reintroduces CSRF considerations that must be handled. Putting sensitive data (even something as 'harmless-seeming' as an email or internal user ID) into JWT claims means that data is visible to ANYONE who intercepts or even just glances at the token, since the payload is only Base64-encoded, not encrypted — never assume JWT payload confidentiality. Clock skew between servers (if using distributed authorization/resource servers) can cause valid tokens to be rejected as 'not yet valid' or already 'expired' — most JWT libraries allow a small configurable clock-skew tolerance. Using a weak or hard-coded HMAC secret (e.g., checked into source control) completely undermines JWT security — anyone with the secret can forge arbitrary tokens with any claims/roles they want.

Want a visual for this concept?

Generate a diagram tailored to “JWT: Token-Based Authentication” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Method-Level Security← Back to all Spring Security chapters