Skip to main content
TWYTech World by Yashrajsinh

Spring Security JWT Authentication Guide

Y
Yashrajsinh
··8 min read·Advanced

Spring Security JWT Authentication Guide

Securing a REST API requires more than a login form. Modern applications use JSON Web Tokens to authenticate stateless requests, manage user sessions without server-side storage, and enforce fine-grained authorization rules. This guide walks through implementing JWT authentication in Spring Boot from scratch, covering token generation, validation, refresh token rotation, role-based access control, and the security considerations that keep your application safe in production.

What You Will Learn

By the end of this guide you will have a complete JWT authentication system built on Spring Security. You will understand how to configure the security filter chain for stateless authentication, how to generate and validate JWTs with proper claims, how to implement refresh token rotation to maintain sessions securely, how to enforce role-based access control on your endpoints, and how to handle common security scenarios like token expiration, revocation, and CORS configuration. You will also learn the security pitfalls that lead to vulnerabilities and how to avoid them.

Prerequisites

  • Strong understanding of Spring Boot REST API development including controllers, services, and exception handling
  • Familiarity with Spring Data JPA for user persistence
  • Understanding of Advanced Java concepts including interfaces, generics, and functional programming
  • Basic knowledge of HTTP headers, cookies, and the stateless nature of REST
  • Understanding of hashing algorithms and why passwords must never be stored in plain text
  • A Java 17 or later JDK installed locally

Concept Overview

JWT authentication replaces traditional session-based authentication with a token-based approach. Instead of storing session state on the server, the server issues a signed token containing the user identity and permissions. The client includes this token in every request, and the server validates the signature without consulting a database or session store. This makes the system stateless and horizontally scalable.

A JWT consists of three parts: a header specifying the signing algorithm, a payload containing claims about the user, and a signature that proves the token was issued by your server. The token is Base64-encoded and transmitted in the Authorization header as a Bearer token. Spring Security intercepts every request, extracts the token, validates its signature and expiration, and populates the SecurityContext with the authenticated user.

The security filter chain in Spring Boot is a sequence of filters that process every HTTP request. For JWT authentication, you add a custom filter that runs before the UsernamePasswordAuthenticationFilter. This filter extracts the token from the request header, validates it, and sets the authentication in the SecurityContext. Subsequent filters and your controller code can then access the authenticated user through the SecurityContextHolder.

Step-by-Step Explanation

This section walks through the essential implementation steps in order. Each step builds on the previous one, guiding you from initial project setup to a fully functional application following Spring Boot conventions.

Security Configuration

Spring Security 6 uses a component-based configuration model. You define a SecurityFilterChain bean that configures which endpoints require authentication, which are public, and how the authentication mechanism works. For JWT, you disable session creation, disable CSRF for stateless APIs, and register your custom JWT filter.

package com.example.security;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
 
    private final JwtAuthenticationFilter jwtAuthFilter;
    private final CustomUserDetailsService userDetailsService;
 
    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
                          CustomUserDetailsService userDetailsService) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.userDetailsService = userDetailsService;
    }
 
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/articles/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/editor/**").hasAnyRole("ADMIN", "EDITOR")
                .anyRequest().authenticated()
            )
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
 
        return http.build();
    }
 
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }
 
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }
}

The configuration disables CSRF because JWT-authenticated APIs do not use cookies for authentication. Session creation is set to STATELESS so Spring Security never creates an HttpSession. Public endpoints like authentication and article reading are permitted without a token. Admin and editor endpoints require specific roles. Everything else requires authentication.

JWT Token Service

The token service handles generation and validation of JWTs. It uses a secret key to sign tokens and verifies signatures on incoming requests. The token contains the username as the subject, roles as a custom claim, and an expiration time.

package com.example.security;
 
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
 
import javax.crypto.SecretKey;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
 
@Service
public class JwtService {
 
    @Value("${jwt.secret}")
    private String secretKey;
 
    @Value("${jwt.access-token-expiration:900000}")
    private long accessTokenExpiration; // 15 minutes
 
    @Value("${jwt.refresh-token-expiration:604800000}")
    private long refreshTokenExpiration; // 7 days
 
    public String generateAccessToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        return buildToken(claims, userDetails.getUsername(), accessTokenExpiration);
    }
 
    public String generateRefreshToken(UserDetails userDetails) {
        return buildToken(new HashMap<>(), userDetails.getUsername(), refreshTokenExpiration);
    }
 
    private String buildToken(Map<String, Object> claims, String subject, long expiration) {
        return Jwts.builder()
                .claims(claims)
                .subject(subject)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey())
                .compact();
    }
 
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }
 
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }
 
    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }
 
    private <T> T extractClaim(String token, Function<Claims, T> resolver) {
        final Claims claims = extractAllClaims(token);
        return resolver.apply(claims);
    }
 
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
 
    private SecretKey getSigningKey() {
        byte[] keyBytes = Base64.getDecoder().decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

The secret key must be at least 256 bits for HMAC-SHA256. Store it in environment variables or a secrets manager, never in source code. The access token has a short lifetime of 15 minutes to limit the damage if it is compromised. The refresh token has a longer lifetime and is used only to obtain new access tokens.

JWT Authentication Filter

The authentication filter intercepts every request, extracts the JWT from the Authorization header, validates it, and sets the authentication in the SecurityContext. If the token is missing or invalid, the filter passes the request through without authentication, and Spring Security will reject it if the endpoint requires authentication.

package com.example.security;
 
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
 
import java.io.IOException;
 
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
    private final JwtService jwtService;
    private final CustomUserDetailsService userDetailsService;
 
    public JwtAuthenticationFilter(JwtService jwtService,
                                    CustomUserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }
 
    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                     @NonNull HttpServletResponse response,
                                     @NonNull FilterChain filterChain)
            throws ServletException, IOException {
 
        final String authHeader = request.getHeader("Authorization");
 
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
 
        final String jwt = authHeader.substring(7);
 
        try {
            final String username = jwtService.extractUsername(jwt);
 
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
 
                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(
                                    userDetails, null, userDetails.getAuthorities());
                    authToken.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (JwtException e) {
            // Token is invalid, continue without authentication
            logger.debug("Invalid JWT token: " + e.getMessage());
        }
 
        filterChain.doFilter(request, response);
    }
}

The filter extends OncePerRequestFilter to guarantee it runs exactly once per request, even if the request is forwarded internally. The try-catch around token parsing ensures that malformed tokens do not crash the filter chain. Instead, the request continues without authentication and Spring Security handles the 401 response.

Authentication Controller

The authentication controller exposes endpoints for registration, login, and token refresh. It uses the AuthenticationManager to validate credentials and the JwtService to generate tokens.

package com.example.controller;
 
import com.example.dto.AuthRequest;
import com.example.dto.AuthResponse;
import com.example.dto.RegisterRequest;
import com.example.security.JwtService;
import com.example.service.AuthenticationService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
 
@RestController
@RequestMapping("/api/auth")
public class AuthController {
 
    private final AuthenticationService authService;
 
    public AuthController(AuthenticationService authService) {
        this.authService = authService;
    }
 
    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
        AuthResponse response = authService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
 
    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@Valid @RequestBody AuthRequest request) {
        AuthResponse response = authService.authenticate(request);
        return ResponseEntity.ok(response);
    }
 
    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refresh(@RequestHeader("Authorization") String authHeader) {
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        String refreshToken = authHeader.substring(7);
        AuthResponse response = authService.refreshToken(refreshToken);
        return ResponseEntity.ok(response);
    }
 
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@RequestHeader("Authorization") String authHeader) {
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            authService.revokeToken(token);
        }
        return ResponseEntity.noContent().build();
    }
}

The register endpoint creates a new user, hashes the password, and returns both access and refresh tokens. The login endpoint validates credentials and returns tokens. The refresh endpoint accepts a valid refresh token and returns a new access token. The logout endpoint revokes the current token by adding it to a blocklist.

Refresh Token Rotation

Refresh tokens present a security risk because they have long lifetimes. If a refresh token is stolen, an attacker can generate new access tokens indefinitely. Refresh token rotation mitigates this by issuing a new refresh token every time the old one is used. If the old refresh token is used again after rotation, it indicates theft and all tokens for that user are revoked.

package com.example.service;
 
import com.example.domain.RefreshToken;
import com.example.domain.User;
import com.example.dto.AuthRequest;
import com.example.dto.AuthResponse;
import com.example.dto.RegisterRequest;
import com.example.repository.RefreshTokenRepository;
import com.example.repository.UserRepository;
import com.example.security.JwtService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.time.Instant;
import java.util.UUID;
 
@Service
public class AuthenticationService {
 
    private final UserRepository userRepository;
    private final RefreshTokenRepository refreshTokenRepository;
    private final JwtService jwtService;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManager authenticationManager;
 
    public AuthenticationService(UserRepository userRepository,
                                  RefreshTokenRepository refreshTokenRepository,
                                  JwtService jwtService,
                                  PasswordEncoder passwordEncoder,
                                  AuthenticationManager authenticationManager) {
        this.userRepository = userRepository;
        this.refreshTokenRepository = refreshTokenRepository;
        this.jwtService = jwtService;
        this.passwordEncoder = passwordEncoder;
        this.authenticationManager = authenticationManager;
    }
 
    @Transactional
    public AuthResponse authenticate(AuthRequest request) {
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.email(), request.password()));
 
        User user = userRepository.findByEmail(request.email())
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
 
        String accessToken = jwtService.generateAccessToken(user);
        String refreshToken = createRefreshToken(user);
 
        return new AuthResponse(accessToken, refreshToken);
    }
 
    @Transactional
    public AuthResponse refreshToken(String token) {
        RefreshToken storedToken = refreshTokenRepository.findByToken(token)
                .orElseThrow(() -> new RuntimeException("Invalid refresh token"));
 
        if (storedToken.isRevoked()) {
            // Token reuse detected - revoke all tokens for this user
            refreshTokenRepository.revokeAllByUser(storedToken.getUser());
            throw new RuntimeException("Refresh token reuse detected");
        }
 
        if (storedToken.getExpiresAt().isBefore(Instant.now())) {
            throw new RuntimeException("Refresh token expired");
        }
 
        // Rotate: revoke old token and issue new one
        storedToken.setRevoked(true);
        refreshTokenRepository.save(storedToken);
 
        User user = storedToken.getUser();
        String newAccessToken = jwtService.generateAccessToken(user);
        String newRefreshToken = createRefreshToken(user);
 
        return new AuthResponse(newAccessToken, newRefreshToken);
    }
 
    private String createRefreshToken(User user) {
        String tokenValue = UUID.randomUUID().toString();
        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setToken(tokenValue);
        refreshToken.setUser(user);
        refreshToken.setExpiresAt(Instant.now().plusSeconds(604800));
        refreshToken.setRevoked(false);
        refreshTokenRepository.save(refreshToken);
        return tokenValue;
    }
 
    @Transactional
    public void revokeToken(String token) {
        refreshTokenRepository.findByToken(token)
                .ifPresent(t -> {
                    t.setRevoked(true);
                    refreshTokenRepository.save(t);
                });
    }
}

The rotation mechanism stores refresh tokens in the database. When a refresh token is used, it is immediately revoked and a new one is issued. If a revoked token is presented again, it means either the legitimate user or an attacker is using a stolen token. In either case, all tokens for that user are revoked, forcing re-authentication.

Real-World Use Cases

Microservice architectures use JWT for service-to-service authentication. Each service validates the token signature locally without calling an authentication service. This eliminates a network hop on every request and removes the authentication service as a single point of failure. The token carries the user identity and permissions, so downstream services can make authorization decisions independently.

Mobile applications benefit from JWT because tokens can be stored securely on the device and sent with every API request. Unlike cookies, tokens work across different domains and are not automatically attached to requests, which prevents CSRF attacks. The refresh token mechanism keeps users logged in without requiring frequent password entry.

Single-page applications use JWT to authenticate API calls from the browser. The access token is stored in memory and the refresh token in an HttpOnly cookie. This combination prevents XSS attacks from accessing the refresh token while keeping the access token available for API calls. When the access token expires, the application silently refreshes it using the cookie-based refresh token.

Best Practices

Keep access token lifetimes short. Fifteen minutes is a good default. Short-lived tokens limit the window of opportunity for an attacker who obtains a token. The refresh token mechanism ensures users do not need to log in every fifteen minutes, but it adds a layer of protection because refresh tokens can be revoked server-side.

Never store secrets in your JWT payload. The payload is Base64-encoded, not encrypted. Anyone who intercepts the token can read its contents. Store only the minimum information needed for authentication and authorization: the user identifier, roles, and expiration time. Sensitive data like email addresses or personal information should be fetched from the database when needed.

Use asymmetric keys in production for multi-service architectures. With HMAC, every service that validates tokens needs the secret key, which means every service can also forge tokens. With RSA or ECDSA, only the authentication service has the private key. Other services validate tokens using the public key, which cannot be used to create new tokens.

Implement token revocation for logout and security events. Pure JWT is stateless, which means you cannot invalidate a token before it expires. Maintain a blocklist of revoked token IDs in Redis or your database. Check the blocklist on every request. The performance impact is minimal because the blocklist is small and lookups are fast.

Always validate the token signature, expiration, and issuer. Do not trust any claim in the token without verifying the signature first. Check that the token was issued by your server by validating the issuer claim. Check that the token has not expired. These three checks prevent token forgery, replay attacks, and use of expired credentials.

Common Mistakes

Storing JWTs in localStorage exposes them to XSS attacks. Any JavaScript running on your page can read localStorage and exfiltrate the token. Store access tokens in memory and refresh tokens in HttpOnly cookies. If you must use localStorage, ensure your application has strong Content Security Policy headers and sanitizes all user input.

Using a weak or hardcoded secret key is a critical vulnerability. If an attacker discovers your secret key, they can forge tokens for any user with any role. Use a cryptographically random key of at least 256 bits. Rotate keys periodically and support multiple active keys during the rotation window.

Not handling token expiration gracefully leads to poor user experience. When an access token expires, the client should automatically attempt a refresh before showing an error. Implement an HTTP interceptor that catches 401 responses, refreshes the token, and retries the original request transparently.

Putting too much data in the JWT payload increases token size and network overhead. Every request carries the token, so a 4KB token adds 4KB to every API call. Keep tokens small by including only essential claims. If you need to check permissions against a complex policy, fetch the full permission set from the database using the user ID from the token.

Disabling CSRF without understanding why is dangerous. CSRF protection is unnecessary for JWT-authenticated APIs because the token is not automatically attached to requests like cookies are. But if your application also uses cookie-based authentication for some endpoints, you need CSRF protection on those endpoints. Understand your authentication model before disabling security features.

Summary

JWT authentication in Spring Boot provides a stateless, scalable approach to securing REST APIs. The implementation requires a security filter chain configuration, a JWT service for token generation and validation, a custom authentication filter, and careful handling of refresh tokens. The key security principles are short-lived access tokens, refresh token rotation with reuse detection, proper secret key management, and defense against common attacks like XSS and token theft. Combined with Spring Security's role-based access control and method-level security annotations, JWT gives you a complete authentication and authorization system that scales horizontally without shared session state. Always remember that security is not a feature you add once but a practice you maintain through regular key rotation, dependency updates, and security audits.

Intermediate8 min read

Spring Boot Observability

Configure Spring Boot Actuator endpoints, integrate Micrometer metrics, distributed tracing, and structured logging for production observability.

Intermediate8 min read

Spring Boot Auto-Configuration Deep Dive

Understand how Spring Boot auto-configuration works internally, how to create custom auto-configurations, and how to debug configuration issues.

Intermediate7 min read

Spring Data JPA Guide

Master Spring Data JPA from repository interfaces to custom queries, projections, auditing, and performance optimization for production applications.