Back to Blog
spring-bootjwtsecurityauthenticationjavaspring-security

JWT Authentication in Spring Boot: A Complete Implementation Guide (2026)

JWT is stateless, scalable, and works across microservices. But implementing it correctly — with token refresh, revocation, and proper security — requires more than copying a tutorial.

J

JOptimize Team

May 30, 2026· 10 min read

JWT (JSON Web Token) authentication is stateless — the server doesn't need to store session state, every token is self-contained, and any instance of your service can validate tokens independently. This makes it ideal for stateless microservices and horizontal scaling.

But most JWT tutorials stop at the happy path: generate a token, validate a token, done. Production JWT implementation requires refresh tokens, revocation, secure storage guidance for clients, and correct Spring Security integration. This guide covers all of it.


How JWT Authentication Works

Before writing code, it's worth being clear about the flow:

  1. User sends credentials (username + password) to /auth/login
  2. Server validates credentials, generates an access token (short-lived, 15 min) and a refresh token (long-lived, 7 days)
  3. Client stores the tokens (access token in memory, refresh token in an HttpOnly cookie)
  4. Client sends the access token in the Authorization: Bearer <token> header on every request
  5. Server validates the token signature and expiry on every request — no database lookup needed
  6. When the access token expires, the client sends the refresh token to /auth/refresh to get a new access token
  7. On logout, the refresh token is invalidated

Dependencies

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency>

JWT Token Service

@Service public class JwtTokenService { @Value("${jwt.secret}") private String secret; private SecretKey signingKey() { return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); } public String generateAccessToken(UserDetails user) { return Jwts.builder() .subject(user.getUsername()) .claim("roles", user.getAuthorities().stream() .map(GrantedAuthority::getAuthority).toList()) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + 15 * 60 * 1000)) // 15 min .signWith(signingKey()) .compact(); } public String generateRefreshToken(String username) { return Jwts.builder() .subject(username) .claim("type", "refresh") .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000L)) // 7 days .signWith(signingKey()) .compact(); } public Claims validateAndParse(String token) { return Jwts.parser() .verifyWith(signingKey()) .build() .parseSignedClaims(token) .getPayload(); // Throws JwtException if expired, tampered, or invalid } public String extractUsername(String token) { return validateAndParse(token).getSubject(); } public boolean isAccessToken(String token) { Claims claims = validateAndParse(token); return !"refresh".equals(claims.get("type")); } }

JWT Security Filter

The filter extracts and validates the JWT on every request:

@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenService tokenService; private final UserDetailsService userDetailsService; private final TokenRevocationService revocationService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { chain.doFilter(request, response); return; } String token = authHeader.substring(7); try { if (!tokenService.isAccessToken(token)) { // Refuse refresh tokens used as access tokens response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid token type"); return; } if (revocationService.isRevoked(token)) { response.sendError(HttpStatus.UNAUTHORIZED.value(), "Token revoked"); return; } String username = tokenService.extractUsername(token); UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException e) { // Token expired, tampered, or invalid signature response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid or expired token"); return; } chain.doFilter(request, response); } }

Spring Security Configuration

@Configuration @EnableWebSecurity @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) // JWT is stateless — CSRF not needed .sessionManagement(s -> s .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/actuator/health").permitAll() .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); // Cost factor 12 — balance of security and speed } }

Token Refresh and Revocation

Access tokens expire in 15 minutes. The refresh flow issues new access tokens without re-entering credentials:

@Service @RequiredArgsConstructor public class TokenRevocationService { private final RedisTemplate<String, String> redis; private static final String PREFIX = "revoked:"; // Store the token JTI (JWT ID) in Redis until expiry public void revoke(String token) { Claims claims = jwtTokenService.validateAndParse(token); long ttl = claims.getExpiration().getTime() - System.currentTimeMillis(); if (ttl > 0) { redis.opsForValue().set( PREFIX + token, "revoked", Duration.ofMillis(ttl) ); } } public boolean isRevoked(String token) { return Boolean.TRUE.equals(redis.hasKey(PREFIX + token)); } } @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthController { private final JwtTokenService tokenService; private final TokenRevocationService revocationService; private final AuthenticationManager authManager; @PostMapping("/login") public AuthResponse login(@RequestBody @Valid LoginRequest req, HttpServletResponse response) { Authentication auth = authManager.authenticate( new UsernamePasswordAuthenticationToken(req.username(), req.password())); UserDetails user = (UserDetails) auth.getPrincipal(); String accessToken = tokenService.generateAccessToken(user); String refreshToken = tokenService.generateRefreshToken(user.getUsername()); // Refresh token in HttpOnly cookie — not accessible to JavaScript ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) .secure(true) .path("/api/v1/auth/refresh") .maxAge(Duration.ofDays(7)) .sameSite("Strict") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); return new AuthResponse(accessToken); } @PostMapping("/refresh") public AuthResponse refresh(@CookieValue("refreshToken") String refreshToken) { if (revocationService.isRevoked(refreshToken)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Token revoked"); } String username = tokenService.extractUsername(refreshToken); UserDetails user = userDetailsService.loadUserByUsername(username); return new AuthResponse(tokenService.generateAccessToken(user)); } @PostMapping("/logout") public void logout(@CookieValue("refreshToken") String refreshToken, @RequestHeader("Authorization") String authHeader) { revocationService.revoke(authHeader.substring(7)); // Revoke access token revocationService.revoke(refreshToken); // Revoke refresh token } }

Security Best Practices

  • Secret key: minimum 256 bits, stored in environment variables or a secrets manager — never in source code or application.yml committed to git
  • Access token lifetime: 15 minutes maximum; shorter is more secure at the cost of more refresh calls
  • Refresh token storage: HttpOnly cookie — JavaScript can't read it, protecting against XSS attacks
  • Access token storage: JavaScript memory (a variable, not localStorage) — localStorage is readable by any script on the page
  • Algorithm: Use HS256 for symmetric (single service) or RS256 for asymmetric (multiple services validating tokens issued by one auth service)

Common Mistakes to Avoid

  • Long-lived access tokens — a 24-hour access token that gets stolen gives the attacker 24 hours of access; keep them short
  • Storing JWT in localStorage — XSS attacks can steal localStorage contents; use HttpOnly cookies for refresh tokens and memory for access tokens
  • No token revocation — JWT is stateless by design, but you still need the ability to invalidate compromised tokens; Redis revocation list is the standard solution
  • Weak secret keys — a short or guessable secret allows offline brute-force attacks to forge tokens; generate a cryptographically random 256-bit key

Summary

JWT authentication in Spring Boot requires four components: a token service for generation and validation, a security filter for per-request validation, a Spring Security configuration for authorization rules, and a revocation mechanism (Redis) for logout and compromised token handling. Store access tokens in JavaScript memory, refresh tokens in HttpOnly cookies, and rotate secrets regularly.


Secure and Performant APIs with JOptimize

JWT validation adds overhead on every request. JOptimize identifies security anti-patterns and performance issues in your Spring Security configuration.

Authenticate correctly. Perform correctly.

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.