Backend Systemsintermediate
Spring Security + JWT Authentication: Stateless API Security
Secure a Spring Boot REST API with Spring Security 6 and JWT — configure the security filter chain, implement JWT generation and validation, add role-based authorization, and handle refresh tokens.
LearnixoApril 16, 20265 min read
Spring SecurityJWTSpring BootJavaAuthenticationAuthorizationSecurity
Spring Security Architecture
Request
↓
SecurityFilterChain (chain of filters)
├── CorsFilter
├── JwtAuthenticationFilter ← our custom filter
├── UsernamePasswordAuthFilter ← disabled for JWT
└── ...
↓
SecurityContext (holds authenticated user)
↓
Controller method (with @PreAuthorize)Spring Security 6 uses a SecurityFilterChain bean rather than extending WebSecurityConfigurerAdapter (deprecated). You declare the chain as a @Bean:
Dependencies
XML
<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.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>JWT Service
JAVA
@Service
public class JwtService {
private final SecretKey signingKey;
private final long accessTokenExpiry;
private final long refreshTokenExpiry;
public JwtService(JwtProperties props) {
byte[] keyBytes = Base64.getDecoder().decode(props.secret());
this.signingKey = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenExpiry = props.accessTokenExpiryMs();
this.refreshTokenExpiry = props.refreshTokenExpiryMs();
}
public String generateAccessToken(UserDetails user) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
return buildToken(claims, user.getUsername(), accessTokenExpiry);
}
public String generateRefreshToken(UserDetails user) {
return buildToken(Map.of("type", "refresh"), user.getUsername(), refreshTokenExpiry);
}
private String buildToken(Map<String, Object> claims, String subject, long expiryMs) {
Instant now = Instant.now();
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusMillis(expiryMs)))
.signWith(signingKey)
.compact();
}
public String extractSubject(String token) {
return parseClaims(token).getSubject();
}
@SuppressWarnings("unchecked")
public List<String> extractRoles(String token) {
return parseClaims(token).get("roles", List.class);
}
public boolean isValid(String token, UserDetails user) {
try {
String subject = extractSubject(token);
return subject.equals(user.getUsername()) && !isExpired(token);
} catch (JwtException e) {
return false;
}
}
private boolean isExpired(String token) {
return parseClaims(token).getExpiration().before(new Date());
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(signingKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}JWT Authentication Filter
JAVA
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
String username = jwtService.extractSubject(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isValid(token, user)) {
List<String> roles = jwtService.extractRoles(token);
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.toList();
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(user, null, authorities);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (JwtException e) {
// Invalid token — continue without authentication, protected routes will 401
log.debug("JWT validation failed: {}", e.getMessage());
}
chain.doFilter(request, response);
}
}Security Configuration
JAVA
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // enables @PreAuthorize
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // stateless — no CSRF needed
.cors(Customizer.withDefaults()) // use CorsConfigurationSource bean
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/clinics/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(
(req, res, e) -> res.sendError(SC_UNAUTHORIZED, "Unauthorized"))
.accessDeniedHandler(
(req, res, e) -> res.sendError(SC_FORBIDDEN, "Forbidden"))
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}Auth Controller
JAVA
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authManager;
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
private final UserService userService;
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
try {
authManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password()));
} catch (AuthenticationException e) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
}
UserDetails user = userDetailsService.loadUserByUsername(request.email());
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);
// Store refresh token hash in DB for rotation
userService.saveRefreshToken(request.email(), refreshToken);
return ResponseEntity.ok(new TokenResponse(accessToken, refreshToken));
}
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
String subject;
try {
subject = jwtService.extractSubject(request.refreshToken());
} catch (JwtException e) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid refresh token");
}
// Validate stored refresh token (prevents replay attacks)
if (!userService.isRefreshTokenValid(subject, request.refreshToken())) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token revoked");
}
UserDetails user = userDetailsService.loadUserByUsername(subject);
String newAccess = jwtService.generateAccessToken(user);
String newRefresh = jwtService.generateRefreshToken(user);
userService.rotateRefreshToken(subject, request.refreshToken(), newRefresh);
return ResponseEntity.ok(new TokenResponse(newAccess, newRefresh));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody LogoutRequest request) {
String subject = jwtService.extractSubject(request.refreshToken());
userService.revokeRefreshToken(subject);
return ResponseEntity.noContent().build();
}
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password) {}
public record RefreshRequest(@NotBlank String refreshToken) {}
public record LogoutRequest(@NotBlank String refreshToken) {}
public record TokenResponse(String accessToken, String refreshToken) {}
}Method-Level Security
JAVA
@Service
public class AppointmentService {
// Only users with ROLE_ADMIN can call this
@PreAuthorize("hasRole('ADMIN')")
public List<AppointmentResponse> listAll() { ... }
// User can access their own appointments, admins can access any
@PreAuthorize("hasRole('ADMIN') or #clinicId == authentication.principal.clinicId")
public Page<AppointmentResponse> listByClinic(String clinicId, Pageable pageable) { ... }
// Check after the method returns — filter results
@PostFilter("filterObject.clinicId == authentication.principal.clinicId")
public List<AppointmentResponse> listForUser() { ... }
}UserDetailsService Implementation
JAVA
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPasswordHash())
.roles(user.getRole().name())
.accountExpired(!user.isActive())
.credentialsExpired(false)
.disabled(!user.isActive())
.build();
}
}Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.