Spring Security JWT Authentication: Stateless Auth Done Right
Implement JWT authentication in Spring Boot 3. Token generation, validation, refresh tokens, and security best practices.
Moshiour Rahman
Advertisement
The Problem: Sessions Don’t Scale
Your Spring app uses sessions. Then:
- Load balancer sends requests to different servers → session lost
- Mobile apps can’t use cookies easily
- Microservices need to share authentication
- Scaling horizontally requires sticky sessions or shared session store
JWT (JSON Web Token) solves this. Stateless, self-contained, works everywhere.
Quick Answer (TL;DR)
// Generate token
String token = Jwts.builder()
.subject(user.getUsername())
.claim("roles", user.getRoles())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 3600000)) // 1 hour
.signWith(secretKey)
.compact();
// Validate on every request
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, ...) {
String token = extractToken(request);
if (token != null && jwtService.isValid(token)) {
var auth = jwtService.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
How JWT Works

Step-by-Step Implementation
Dependencies
<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.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>
JWT Service
@Service
public class JwtService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expiration:3600000}") // 1 hour
private long accessTokenExpiration;
@Value("${jwt.refresh-token-expiration:604800000}") // 7 days
private long refreshTokenExpiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
public String generateAccessToken(UserDetails user) {
return generateToken(user, accessTokenExpiration);
}
public String generateRefreshToken(UserDetails user) {
return generateToken(user, refreshTokenExpiration);
}
private String generateToken(UserDetails user, long expiration) {
return Jwts.builder()
.subject(user.getUsername())
.claim("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
public boolean isValid(String token, UserDetails user) {
String username = extractUsername(token);
return username.equals(user.getUsername()) && !isExpired(token);
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> resolver) {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return resolver.apply(claims);
}
}
JWT Authentication Filter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
String username = jwtService.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isValid(token, user)) {
var auth = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities()
);
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
} catch (JwtException e) {
// Invalid token - continue without authentication
log.debug("Invalid JWT: {}", e.getMessage());
}
filterChain.doFilter(request, response);
}
}
Security Configuration
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Stateless = no CSRF needed
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
var provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}
Auth Controller
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authManager;
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@PostMapping("/login")
public AuthResponse login(@Valid @RequestBody LoginRequest request) {
authManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password())
);
var user = userDetailsService.loadUserByUsername(request.username());
return new AuthResponse(
jwtService.generateAccessToken(user),
jwtService.generateRefreshToken(user)
);
}
@PostMapping("/refresh")
public AuthResponse refresh(@RequestBody RefreshRequest request) {
String refreshToken = request.refreshToken();
if (jwtService.isExpired(refreshToken)) {
throw new AuthException("Refresh token expired");
}
String username = jwtService.extractUsername(refreshToken);
var user = userDetailsService.loadUserByUsername(username);
return new AuthResponse(
jwtService.generateAccessToken(user),
refreshToken // Reuse refresh token
);
}
@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public AuthResponse register(@Valid @RequestBody RegisterRequest request) {
// Create user, return tokens
var user = userService.create(request);
return new AuthResponse(
jwtService.generateAccessToken(user),
jwtService.generateRefreshToken(user)
);
}
}
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {}
public record AuthResponse(
String accessToken,
String refreshToken
) {}
Configuration
# application.yml
jwt:
# Generate with: openssl rand -base64 64
secret: ${JWT_SECRET:your-256-bit-secret-key-here-must-be-long-enough}
access-token-expiration: 3600000 # 1 hour
refresh-token-expiration: 604800000 # 7 days
Security Best Practices
1. Use Strong Secrets
# Generate a secure secret
openssl rand -base64 64
2. Short Access Token Lifetime
// Access token: 15 minutes to 1 hour
// Refresh token: 7 days to 30 days
// Shorter = more secure, needs refresh more often
3. Store Refresh Tokens Securely
@Entity
public class RefreshToken {
@Id
private String token;
private String username;
private Instant expiresAt;
private boolean revoked;
}
// Revoke on logout
@PostMapping("/logout")
public void logout(@RequestBody RefreshRequest request) {
refreshTokenRepository.revokeByToken(request.refreshToken());
}
4. Handle Token Theft
// Rotate refresh tokens on use
@PostMapping("/refresh")
public AuthResponse refresh(@RequestBody RefreshRequest request) {
var oldToken = refreshTokenRepository.findByToken(request.refreshToken())
.orElseThrow(() -> new AuthException("Invalid refresh token"));
if (oldToken.isRevoked()) {
// Possible theft! Revoke all user tokens
refreshTokenRepository.revokeAllByUsername(oldToken.getUsername());
throw new AuthException("Token reuse detected");
}
// Revoke old, issue new
oldToken.setRevoked(true);
refreshTokenRepository.save(oldToken);
return new AuthResponse(
jwtService.generateAccessToken(user),
createNewRefreshToken(user) // New refresh token
);
}
5. Include Essential Claims Only
// Good - minimal claims
Jwts.builder()
.subject(user.getUsername())
.claim("roles", user.getRoles())
.build();
// Bad - sensitive data in token
Jwts.builder()
.claim("email", user.getEmail())
.claim("address", user.getAddress()) // Don't!
.claim("ssn", user.getSsn()) // Never!
.build();
Common Pitfalls
| Pitfall | Risk | Fix |
|---|---|---|
| Weak secret | Token forgery | Use 256+ bit key |
| Long expiration | Token theft window | Short-lived + refresh |
| Storing in localStorage | XSS exposure | HttpOnly cookie for refresh |
| No token revocation | Can’t logout | Store refresh tokens in DB |
| Sensitive data in token | Data exposure | Only include ID, roles |
Testing JWT Auth
@WebMvcTest(UserController.class)
class UserControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private JwtService jwtService;
@Test
void shouldRejectRequestWithoutToken() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}
@Test
void shouldAcceptValidToken() throws Exception {
when(jwtService.extractUsername(anyString())).thenReturn("user1");
when(jwtService.isValid(anyString(), any())).thenReturn(true);
mockMvc.perform(get("/api/users")
.header("Authorization", "Bearer valid-token"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "ADMIN")
void shouldAllowAdminAccess() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpect(status().isOk());
}
}
Code Repository
Complete JWT implementation:
GitHub: techyowls/techyowls-io-blog-public/spring-security-jwt
Further Reading
- REST API Best Practices - Secure your endpoints
- Spring Boot Testing - Test security
- Spring Security Docs
Ship secure, stateless authentication. Follow TechyOwls for more practical guides.
Advertisement
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
Spring Boot JWT Authentication: Complete Security Guide
Implement JWT authentication in Spring Boot 3. Learn Spring Security 6, token generation, validation, refresh tokens, and role-based access control.
Spring BootSpring Security Database Authentication: Custom UserDetailsService Guide
Implement database authentication in Spring Security. Learn UserDetailsService, password encoding, account locking, and multi-tenant authentication.
Spring BootSpring Security Architecture: Complete Guide to How Security Works
Master Spring Security internals. Learn FilterChain, SecurityContext, Authentication flow, and how Spring Security protects your application.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.