# Spring Security 6: Complete JWT Authentication > Practical guide to implementing JWT authentication with Spring Security 6. Configuration, token generation, validation, and security best practices. - Published: 2026-02-19 - Updated: 2026-03-31 - Author: SharpSkill - Tags: spring security, jwt, java, spring boot, authentication - Reading time: 15 min --- JWT (JSON Web Token) authentication has become the standard for securing modern REST APIs. Spring Security 6 introduces a functional configuration approach that simplifies implementation while strengthening security. This guide covers the entire process, from initial setup to endpoint protection. > **Prerequisites** > > This tutorial uses Spring Boot 3.2+ and Spring Security 6.2+. The concepts remain valid for later versions, with minor syntax adjustments. ## JWT Architecture in Spring Security JWT authentication relies on a stateless principle: the server stores no session. Each request contains a signed token that proves the user's identity. This architecture enables horizontal scalability without session sharing between instances. The JWT authentication flow breaks down into several steps. The user authenticates with credentials, receives a signed JWT token, then includes this token in every subsequent request. The server validates the signature and extracts user information from the token. ```java // JwtAuthenticationFlow.java // Conceptual representation of the authentication flow public class JwtAuthenticationFlow { // 1. Initial authentication: POST /api/auth/login // → Verify credentials against database // → Generate signed JWT token // → Return token to client // 2. Authenticated requests: GET /api/protected // → Header: Authorization: Bearer // → Extract and validate token // → Create SecurityContext // → Access protected resource } ``` This approach eliminates sticky session problems and simplifies deployment in distributed environments. ## Maven Dependencies Configuration The project requires Spring Security dependencies and a JWT library. JJWT (Java JWT) offers a fluent and well-maintained API for token manipulation. ```xml org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-web io.jsonwebtoken jjwt-api 0.12.5 io.jsonwebtoken jjwt-impl 0.12.5 runtime io.jsonwebtoken jjwt-jackson 0.12.5 runtime org.springframework.boot spring-boot-starter-data-jpa ``` The separation into three JJWT modules (api, impl, jackson) follows the encapsulation principle: only the API is visible at compile-time, the implementation remains a runtime detail. ## JWT Generation and Validation Service The JWT service centralizes all token operations: generation, claims extraction, and validation. A secure secret key signs each token, ensuring its integrity. ```java // JwtService.java @Service public class JwtService { // Secret key injected from application.yml @Value("${app.jwt.secret}") private String secretKey; // Token validity duration (24 hours by default) @Value("${app.jwt.expiration:86400000}") private long jwtExpiration; // Generates a JWT token for an authenticated user public String generateToken(UserDetails userDetails) { return generateToken(new HashMap<>(), userDetails); } // Generates a token with custom claims public String generateToken(Map extraClaims, UserDetails userDetails) { return Jwts.builder() .claims(extraClaims) // Additional claims (roles, permissions) .subject(userDetails.getUsername()) // Principal identifier .issuedAt(new Date()) // Creation date .expiration(new Date(System.currentTimeMillis() + jwtExpiration)) .signWith(getSigningKey(), Jwts.SIG.HS256) // HMAC-SHA256 signature .compact(); } // Extracts the username (subject) from the token public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } // Extracts a specific claim via an extraction function public T extractClaim(String token, Function claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } // Validates the token: correct signature and not expired public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); } // Checks if the token has expired private boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } // Extracts the expiration date private Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } // Parses the token and extracts all claims private Claims extractAllClaims(String token) { return Jwts.parser() .verifyWith(getSigningKey()) // Verifies the signature .build() .parseSignedClaims(token) // Parses the signed token .getPayload(); // Returns the claims } // Generates the signing key from the Base64-encoded secret private SecretKey getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); return Keys.hmacShaKeyFor(keyBytes); } } ``` The secret key must be sufficiently long (256 bits minimum for HS256) and stored securely, never in source code. > **Secret Key Security** > > Use an environment variable or secrets manager for the JWT key. A compromised key allows forging valid tokens for any user. ## User Entity Configuration The user entity implements Spring Security's `UserDetails`, enabling direct integration with the authentication system. ```java // User.java @Entity @Table(name = "users") public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String email; @Column(nullable = false) private String password; @Column(nullable = false) private String firstName; @Column(nullable = false) private String lastName; // Role stored as enum for type safety @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role; // UserDetails implementation: returns user authorities @Override public Collection getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); } // Username corresponds to email in this implementation @Override public String getUsername() { return email; } // Account always active (adapt as needed) @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } // Getters and setters omitted for brevity } ``` ```java // Role.java public enum Role { USER, // Standard user ADMIN // Administrator with extended privileges } ``` The `Role` enum limits possible values and simplifies authorization checking in SpEL expressions. ## JWT Authentication Filter The JWT filter intercepts every request to extract and validate the token. It inserts itself into the Spring Security filter chain before the standard authentication filter. ```java // JwtAuthenticationFilter.java @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 { // Retrieve the Authorization header final String authHeader = request.getHeader("Authorization"); // Check for Bearer prefix if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } // Extract the token (without the "Bearer " prefix) final String jwt = authHeader.substring(7); try { // Extract username from token final String userEmail = jwtService.extractUsername(jwt); // Check that user is not already authenticated if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { // Load user details from database UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail); // Validate token (signature + expiration + user match) if (jwtService.isTokenValid(jwt, userDetails)) { // Create authentication object UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); // Add request details authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); // Set authentication in security context SecurityContextHolder.getContext().setAuthentication(authToken); } } } catch (ExpiredJwtException e) { // Token expired: user must re-authenticate response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Token expired"); return; } catch (JwtException e) { // Invalid token: incorrect signature or malformed format response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Invalid token"); return; } // Continue the filter chain filterChain.doFilter(request, response); } } ``` `OncePerRequestFilter` guarantees the filter executes only once per request, even in case of forward or include. ## Spring Security 6 Configuration Spring Security 6 uses a functional approach with lambdas to configure the security chain. This configuration defines access rules and integrates the JWT filter. ```java // SecurityConfig.java @Configuration @EnableWebSecurity @EnableMethodSecurity // Enables @PreAuthorize and @PostAuthorize @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http // Disable CSRF since API is stateless (no session cookies) .csrf(csrf -> csrf.disable()) // Configure authorization rules .authorizeHttpRequests(auth -> auth // Public endpoints: authentication and registration .requestMatchers("/api/auth/**").permitAll() // API documentation accessible without authentication .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Admin endpoints reserved for administrators .requestMatchers("/api/admin/**").hasRole("ADMIN") // All other requests require authentication .anyRequest().authenticated() ) // Stateless mode: no server-side HTTP session .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) // Custom authentication provider .authenticationProvider(authenticationProvider) // Insert JWT filter before standard authentication filter .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .build(); } } ``` ```java // ApplicationConfig.java @Configuration @RequiredArgsConstructor public class ApplicationConfig { private final UserRepository userRepository; // User loading service for Spring Security @Bean public UserDetailsService userDetailsService() { return username -> userRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); } // Authentication provider with password encoder @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService()); authProvider.setPasswordEncoder(passwordEncoder()); return authProvider; } // Authentication manager exposed as bean @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } // BCrypt encoder for secure password hashing @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } ``` Disabling CSRF is safe for stateless APIs because authentication relies on an explicit header, not a cookie automatically sent by the browser. ## Authentication Controller The controller exposes registration and login endpoints. These endpoints are public and return the JWT token after successful authentication. ```java // AuthenticationController.java @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthenticationController { private final AuthenticationService authService; // POST /api/auth/register - Register a new user @PostMapping("/register") public ResponseEntity register( @Valid @RequestBody RegisterRequest request ) { return ResponseEntity.ok(authService.register(request)); } // POST /api/auth/login - Login existing user @PostMapping("/login") public ResponseEntity login( @Valid @RequestBody AuthenticationRequest request ) { return ResponseEntity.ok(authService.authenticate(request)); } // POST /api/auth/refresh - Token refresh (optional) @PostMapping("/refresh") public ResponseEntity refresh( @RequestHeader("Authorization") String authHeader ) { return ResponseEntity.ok(authService.refreshToken(authHeader)); } } ``` ```java // AuthenticationRequest.java public record AuthenticationRequest( @NotBlank(message = "Email required") @Email(message = "Invalid email format") String email, @NotBlank(message = "Password required") String password ) {} ``` ```java // RegisterRequest.java public record RegisterRequest( @NotBlank(message = "First name required") String firstName, @NotBlank(message = "Last name required") String lastName, @NotBlank(message = "Email required") @Email(message = "Invalid email format") String email, @NotBlank(message = "Password required") @Size(min = 8, message = "Password must contain at least 8 characters") String password ) {} ``` ```java // AuthenticationResponse.java public record AuthenticationResponse( String token, String type, long expiresIn ) { public AuthenticationResponse(String token, long expiresIn) { this(token, "Bearer", expiresIn); } } ``` Java records simplify DTO definitions while ensuring immutability. ## Authentication Service The service orchestrates registration and authentication logic, delegating to `JwtService` for token generation. ```java // AuthenticationService.java @Service @RequiredArgsConstructor public class AuthenticationService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; private final AuthenticationManager authenticationManager; @Value("${app.jwt.expiration:86400000}") private long jwtExpiration; // Register a new user @Transactional public AuthenticationResponse register(RegisterRequest request) { // Check that email is not already in use if (userRepository.existsByEmail(request.email())) { throw new EmailAlreadyExistsException("Email already registered"); } // Create user entity with hashed password User user = new User(); user.setFirstName(request.firstName()); user.setLastName(request.lastName()); user.setEmail(request.email()); user.setPassword(passwordEncoder.encode(request.password())); user.setRole(Role.USER); // Persist the user userRepository.save(user); // Generate and return JWT token String jwtToken = jwtService.generateToken(user); return new AuthenticationResponse(jwtToken, jwtExpiration); } // Authenticate an existing user public AuthenticationResponse authenticate(AuthenticationRequest request) { // Delegate verification to AuthenticationManager authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.email(), request.password() ) ); // Load user (authentication succeeded) User user = userRepository.findByEmail(request.email()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); // Generate token with additional claims Map claims = new HashMap<>(); claims.put("role", user.getRole().name()); claims.put("userId", user.getId()); String jwtToken = jwtService.generateToken(claims, user); return new AuthenticationResponse(jwtToken, jwtExpiration); } // Refresh token (extend session) public AuthenticationResponse refreshToken(String authHeader) { if (authHeader == null || !authHeader.startsWith("Bearer ")) { throw new InvalidTokenException("Invalid token"); } String oldToken = authHeader.substring(7); String userEmail = jwtService.extractUsername(oldToken); User user = userRepository.findByEmail(userEmail) .orElseThrow(() -> new UsernameNotFoundException("User not found")); // Generate new token String newToken = jwtService.generateToken(user); return new AuthenticationResponse(newToken, jwtExpiration); } } ``` The `AuthenticationManager` centralizes credential verification, allowing easy changes to authentication strategy. > **Refresh Token** > > For enhanced security, implement a refresh token system with longer validity, stored in the database and revocable. ## Endpoint Protection with Annotations Method-level security enables granular authorization control directly in business code. ```java // UserController.java @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { private final UserService userService; // Accessible to all authenticated users @GetMapping("/me") public ResponseEntity getCurrentUser( @AuthenticationPrincipal User currentUser ) { return ResponseEntity.ok(UserDTO.from(currentUser)); } // Only the concerned user or an admin can modify the profile @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id") @PutMapping("/{id}") public ResponseEntity updateUser( @PathVariable Long id, @Valid @RequestBody UpdateUserRequest request ) { return ResponseEntity.ok(userService.updateUser(id, request)); } // Reserved for administrators @PreAuthorize("hasRole('ADMIN')") @GetMapping public ResponseEntity> getAllUsers() { return ResponseEntity.ok(userService.findAll()); } // Deletion with post-execution verification @PreAuthorize("hasRole('ADMIN')") @DeleteMapping("/{id}") public ResponseEntity deleteUser(@PathVariable Long id) { userService.deleteUser(id); return ResponseEntity.noContent().build(); } } ``` ```java // AdminController.java @RestController @RequestMapping("/api/admin") @PreAuthorize("hasRole('ADMIN')") // All methods require ADMIN public class AdminController { private final AdminService adminService; @GetMapping("/dashboard") public ResponseEntity getDashboard() { return ResponseEntity.ok(adminService.getDashboardStats()); } @PostMapping("/users/{id}/role") public ResponseEntity changeUserRole( @PathVariable Long id, @RequestParam Role newRole ) { return ResponseEntity.ok(adminService.changeUserRole(id, newRole)); } } ``` The `@AuthenticationPrincipal` annotation injects the authenticated user directly, avoiding manual access to the `SecurityContext`. ## Authentication Error Handling Centralized error handling ensures consistent and informative responses when authentication fails. ```java // SecurityExceptionHandler.java @RestControllerAdvice public class SecurityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(SecurityExceptionHandler.class); // Authentication error (incorrect credentials) @ExceptionHandler(BadCredentialsException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleBadCredentials(BadCredentialsException ex) { return new ErrorResponse( "INVALID_CREDENTIALS", "Invalid email or password", null ); } // Access denied (authenticated but not authorized) @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ErrorResponse handleAccessDenied(AccessDeniedException ex) { return new ErrorResponse( "ACCESS_DENIED", "Access to this resource is not authorized", null ); } // Expired JWT token @ExceptionHandler(ExpiredJwtException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleExpiredToken(ExpiredJwtException ex) { return new ErrorResponse( "TOKEN_EXPIRED", "Session expired, please log in again", null ); } // Invalid JWT token @ExceptionHandler(JwtException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleInvalidToken(JwtException ex) { log.warn("Invalid JWT token: {}", ex.getMessage()); return new ErrorResponse( "INVALID_TOKEN", "Invalid authentication token", null ); } // Email already in use during registration @ExceptionHandler(EmailAlreadyExistsException.class) @ResponseStatus(HttpStatus.CONFLICT) public ErrorResponse handleEmailExists(EmailAlreadyExistsException ex) { return new ErrorResponse( "EMAIL_EXISTS", ex.getMessage(), null ); } } ``` ```java // ErrorResponse.java public record ErrorResponse( String code, String message, Map details ) {} ``` Standardized error codes facilitate client-side processing and debugging. ## Application.yml Configuration Externalized configuration allows adapting JWT parameters per environment. ```yaml # application.yml app: jwt: # Base64 secret key (256 bits minimum for HS256) # Generate with: openssl rand -base64 32 secret: ${JWT_SECRET:yourSuperSecretKeyOf256BitsMinimum} # Validity duration in milliseconds (24 hours) expiration: 86400000 spring: datasource: url: jdbc:postgresql://localhost:5432/springjwt username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:postgres} jpa: hibernate: ddl-auto: validate show-sql: false ``` ```yaml # application-dev.yml app: jwt: # Short expiration for development (1 hour) expiration: 3600000 spring: jpa: show-sql: true hibernate: ddl-auto: update logging: level: org.springframework.security: DEBUG ``` In production, the secret key should come from an environment variable or a secrets management service like Vault. ## Integration Tests Tests verify the complete behavior of the authentication system, from registration to accessing protected resources. ```java // AuthenticationIntegrationTest.java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @AutoConfigureTestDatabase(replace = Replace.ANY) class AuthenticationIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Autowired private UserRepository userRepository; @BeforeEach void setUp() { userRepository.deleteAll(); } @Test void shouldRegisterAndAuthenticateUser() { // Registration RegisterRequest registerRequest = new RegisterRequest( "John", "Doe", "john@example.com", "password123" ); ResponseEntity registerResponse = restTemplate .postForEntity("/api/auth/register", registerRequest, AuthenticationResponse.class); assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(registerResponse.getBody().token()).isNotBlank(); // Login with same credentials AuthenticationRequest loginRequest = new AuthenticationRequest( "john@example.com", "password123" ); ResponseEntity loginResponse = restTemplate .postForEntity("/api/auth/login", loginRequest, AuthenticationResponse.class); assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(loginResponse.getBody().token()).isNotBlank(); } @Test void shouldRejectInvalidCredentials() { AuthenticationRequest request = new AuthenticationRequest( "unknown@example.com", "wrongpassword" ); ResponseEntity response = restTemplate .postForEntity("/api/auth/login", request, ErrorResponse.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test void shouldAccessProtectedResourceWithValidToken() { // Register to get a token RegisterRequest registerRequest = new RegisterRequest( "Jane", "Doe", "jane@example.com", "password123" ); AuthenticationResponse authResponse = restTemplate .postForObject("/api/auth/register", registerRequest, AuthenticationResponse.class); // Access protected resource with token HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(authResponse.token()); ResponseEntity response = restTemplate.exchange( "/api/users/me", HttpMethod.GET, new HttpEntity<>(headers), UserDTO.class ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().email()).isEqualTo("jane@example.com"); } @Test void shouldRejectAccessWithoutToken() { ResponseEntity response = restTemplate .getForEntity("/api/users/me", Void.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } } ``` These tests cover the main scenarios: registration, login, authorized access, and denied access. ## Conclusion Implementing JWT authentication with Spring Security 6 follows a well-defined pattern. The JWT service handles token generation and validation, the filter intercepts requests to establish the security context, and the configuration defines access rules. **Deployment checklist:** - ✅ Secret key of 256 bits minimum stored in environment variable - ✅ HTTPS mandatory in production to protect tokens in transit - ✅ Token validity duration adapted to context (15 min to 24h) - ✅ Refresh token for long sessions without re-authentication - ✅ Standardized error handling with business codes - ✅ Logging of failed authentication attempts - ✅ Integration tests covering authentication scenarios - ✅ Rate limiting on authentication endpoints This architecture provides a solid foundation for securing REST APIs, while remaining extensible for more complex needs like multi-factor authentication or OAuth2 integration. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/spring-boot/spring-security-6-jwt-authentication