Spring Boot 6 min read

Spring Security JWT Authentication: Stateless Auth Done Right

Implement JWT authentication in Spring Boot 3. Complete guide covering token generation, validation, refresh tokens, and production security best practices.

MR

Moshiour Rahman

· Updated May 9, 2026

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

JWT Authentication Flow


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

PitfallRiskFix
Weak secretToken forgeryUse 256+ bit key
Long expirationToken theft windowShort-lived + refresh
Storing in localStorageXSS exposureHttpOnly cookie for refresh
No token revocationCan’t logoutStore refresh tokens in DB
Sensitive data in tokenData exposureOnly 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


Ship secure, stateless authentication. Follow TechyOwls for more practical guides.

Is your AI API down right now? Check live status for OpenAI, Claude, Gemini, Groq, and more.
Check status →

Frequently Asked Questions

What is the difference between JWT and session-based authentication?
Sessions store auth state on the server and require sticky sessions or a shared store to scale. JWTs are self-contained tokens verified without server state, making them ideal for microservices and mobile apps.
Where should I store JWT tokens on the client?
Store access tokens in memory (JavaScript variable) and refresh tokens in HttpOnly secure cookies. Never store JWTs in localStorage — it is vulnerable to XSS attacks.
How do I handle JWT token expiration?
Use short-lived access tokens (15-30 minutes) paired with a longer-lived refresh token. When the access token expires, the client uses the refresh token to get a new one without re-login.
Can I invalidate a JWT before it expires?
Not directly, since JWTs are stateless. Use a token blacklist in Redis, short expiration times, or a token version field in the database that invalidates all previous tokens on logout.
MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

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

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.