Spring Boot 6 min read

Spring Security JWT Authentication: Stateless Auth Done Right

Implement JWT authentication in Spring Boot 3. Token generation, validation, refresh tokens, and security best practices.

MR

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

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.

Advertisement

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.