Back to Blog
spring-securityjwtspring-bootsecurityauthenticationjava

Spring Security JWT Best Practices: What Most Tutorials Get Wrong (2026)

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.

J

JOptimize Team

May 21, 2026· 10 min read

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.


What Most Tutorials Get Wrong

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:

  • Catches JwtException broadly - expired tokens return false instead of 401 Unauthorized
  • SECRET_KEY is often a short, hardcoded string - trivially brute-forceable
  • No token revocation - a stolen token is valid until expiry
  • No validation of iss, aud, or nbf claims

Step 1: Use a Strong Signing Key

Never 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(); }

Step 2: Validate All Claims Properly

@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 } }

Step 3: Short-Lived Access Tokens + Refresh Tokens

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); } }

Step 4: Token Revocation

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; }

Step 5: Secure the Refresh Endpoint

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


Common Mistakes to Avoid

  • Storing JWTs in localStorage - accessible to JavaScript; XSS attacks steal them instantly; use HttpOnly cookies instead
  • Long-lived access tokens (24h+) - if stolen, attacker has full access for 24h; keep them under 60 minutes
  • No iss / aud validation - accepting tokens from any issuer enables token substitution attacks
  • Logging the full token - tokens in logs are credentials; log only the subject or a hash
  • Symmetric keys shared across services - any service with the key can forge tokens; use asymmetric keys for multi-service architectures

Summary

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


Detect JWT Security Issues in Your Codebase

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.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.