Most Spring Boot JWT tutorials teach patterns that create security vulnerabilities. Learn the correct way to implement JWT authentication - token validation, refresh tokens, and common pitfalls.
JOptimize Team
JWT authentication is everywhere in Spring Boot applications, and so are the same security mistakes. Most tutorials show you how to make it work - almost none show you how to make it secure. In this guide, we cover what those tutorials skip.
Here's the typical tutorial pattern:
// What 90% of JWT tutorials teach public boolean validateToken(String token) { try { Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token); return true; } catch (JwtException e) { return false; // Swallows ALL errors - expiry, tampering, everything } }
This pattern has several problems:
JwtException broadly - expired tokens return false instead of 401 UnauthorizedSECRET_KEY is often a short, hardcoded string - trivially brute-forceableiss, aud, or nbf claimsNever use a short string as your HMAC secret. For HS256, you need at least 256 bits of entropy:
// BAD - short, predictable secret private static final String SECRET = "mySecret123"; // GOOD - generate once and store in environment variable // Generate: openssl rand -base64 64 @Value("${jwt.secret}") private String jwtSecret; @Bean public SecretKey signingKey() { byte[] keyBytes = Base64.getDecoder().decode(jwtSecret); return Keys.hmacShaKeyFor(keyBytes); // Validates key length }
For production, prefer RS256 (asymmetric) - the private key signs, the public key verifies. Compromising a verification server doesn't expose your signing key:
@Bean public JwtDecoder jwtDecoder() throws Exception { // Load RSA public key from file or environment return NimbusJwtDecoder.withPublicKey(rsaPublicKey()).build(); }
@Component public class JwtTokenValidator { private final SecretKey signingKey; public Claims validateAndExtract(String token) { try { return Jwts.parserBuilder() .setSigningKey(signingKey) .requireIssuer("https://yourapp.com") // Validate issuer .requireAudience("api") // Validate audience .setAllowedClockSkewSeconds(30) // Allow 30s clock skew .build() .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { throw new TokenExpiredException("Token has expired"); } catch (MalformedJwtException | SignatureException e) { throw new InvalidTokenException("Token is invalid"); } catch (UnsupportedJwtException e) { throw new InvalidTokenException("Token algorithm not supported"); } // Don't catch JwtException broadly - handle each case explicitly } }
Access tokens should expire in 15-60 minutes. Use refresh tokens for session continuity:
@Service public class TokenService { // Access token: 15 minutes public String generateAccessToken(UserDetails user) { return Jwts.builder() .setSubject(user.getUsername()) .setIssuer("https://yourapp.com") .setAudience("api") .setIssuedAt(new Date()) .setExpiration(Date.from(Instant.now().plus(15, ChronoUnit.MINUTES))) .claim("roles", user.getAuthorities()) .signWith(signingKey, SignatureAlgorithm.HS256) .compact(); } // Refresh token: 7 days, stored in DB for revocation public RefreshToken generateRefreshToken(String username) { RefreshToken token = RefreshToken.builder() .token(UUID.randomUUID().toString()) .username(username) .expiresAt(Instant.now().plus(7, ChronoUnit.DAYS)) .build(); return refreshTokenRepository.save(token); } }
JWTs are stateless - once issued, they're valid until expiry. For revocation, maintain a denylist:
@Service public class TokenRevocationService { // Use Redis for fast lookups with TTL auto-cleanup private final RedisTemplate<String, String> redisTemplate; public void revokeToken(String token) { Claims claims = jwtValidator.validateAndExtract(token); long ttl = claims.getExpiration().getTime() - System.currentTimeMillis(); if (ttl > 0) { redisTemplate.opsForValue() .set("revoked:" + token, "1", Duration.ofMillis(ttl)); } } public boolean isRevoked(String token) { return Boolean.TRUE.equals( redisTemplate.hasKey("revoked:" + token) ); } } // In your filter: if (tokenRevocationService.isRevoked(token)) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token revoked"); return; }
@RestController @RequestMapping("/auth") public class AuthController { @PostMapping("/refresh") @RateLimited(requests = 10, per = Duration.ofMinutes(1)) // Rate limit! public ResponseEntity<TokenResponse> refresh( @CookieValue("refreshToken") String refreshToken) { // HttpOnly cookie, not header RefreshToken stored = refreshTokenRepository .findByToken(refreshToken) .orElseThrow(() -> new InvalidTokenException("Unknown refresh token")); if (stored.getExpiresAt().isBefore(Instant.now())) { refreshTokenRepository.delete(stored); throw new TokenExpiredException("Refresh token expired"); } String newAccessToken = tokenService.generateAccessToken( userService.loadUserByUsername(stored.getUsername()) ); return ResponseEntity.ok(new TokenResponse(newAccessToken)); } }
Key: the refresh token is in an HttpOnly cookie, not a JavaScript-accessible header. This prevents XSS from stealing it.
localStorage - accessible to JavaScript; XSS attacks steal them instantly; use HttpOnly cookies insteadiss / aud validation - accepting tokens from any issuer enables token substitution attacksSecure JWT in Spring Boot requires: a strong signing key (256-bit minimum, RS256 for multi-service), explicit claim validation (iss, aud, exp), short-lived access tokens with refresh tokens stored in HttpOnly cookies, and a Redis-backed revocation list for logout. Each of these is skipped by most tutorials - implement all of them.
JOptimize scans your Spring Boot project for weak JWT signing keys, missing claim validation, tokens stored in localStorage patterns, and missing refresh token logic.
Find JWT vulnerabilities before an attacker does - free scan, no configuration required.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.