Spring Security JWT
Use when an application issues and validates its own first-party JWT access and refresh tokens, including authentication filters, password encoding, RBAC, and method security. For JWTs issued by Keycloak, Auth0, Okta, Cognito, or another authorization server, use oauth2-resource-server.
View SKILL.mdSpring Boot 4.x ships Spring Security 7: the lambda DSL is the only style — and(),
authorizeRequests(), antMatchers(), and WebSecurityConfigurerAdapter no longer exist, and
AntPathRequestMatcher/MvcRequestMatcher are replaced by PathPatternRequestMatcher
(requestMatchers("/path/**") uses it under the hood).
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.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>
Security configuration
Use the compiled SecurityConfig with the filter and service below. It uses the lambda DSL, stateless bearer authentication, role rules, JSON 401/403 handlers and disabled servlet registration for the security-chain filter. Bean method injection avoids constructor cycles between the configuration and its own AuthenticationProvider bean. Adapt the routes and roles to the project. CSRF disabling applies to header-only bearer APIs; keep CSRF protection when browsers send authentication cookies automatically.
JWT implementation
Use the tested JwtService and
JwtAuthenticationFilter templates together.
The configuration keys are app.jwt.secret, app.jwt.access-token-expiration, and
app.jwt.refresh-token-expiration (durations in milliseconds).
The filter rejects expired, malformed, tampered, missing-expiration and refresh tokens with 401 and a Bearer challenge. It checks the current user's enabled, locked, account-expired and credentials-expired flags before authentication. Deleted users also receive 401. Database outages and downstream application failures must remain server failures, not be masked as invalid credentials. Invalid supplied tokens are rejected even on public endpoints.
The filter's example error body uses Problem Details. For an existing legacy API, adapt this
response and the entry point below to the established error contract. Never log bearer tokens.
Register a filter bean only in the security chain: disable servlet-container registration
with a FilterRegistrationBean<JwtAuthenticationFilter> whose enabled flag is false.
These templates illustrate a single-service first-party token contract. Before sharing signing keys or accepting tokens across services, define and validate issuer and audience, key rotation, and revocation. Spring Security's resource-server support can also validate custom JWTs; preserve it when it already fits the application. Token generation is not a complete refresh flow: retain the rotation/reuse-detection requirements below.
JSON 401/403
The configuration and filter templates use Problem Details for authentication and authorization errors. Adapt both together for a legacy error contract. Missing credentials on a protected route return 401; an authenticated caller without the required role returns 403. An invalid supplied token returns 401 even when the route permits anonymous access. Controller advice cannot handle exceptions thrown before the dispatcher servlet.
Auth Controller
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ApiResponse<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.ok(authService.login(request));
}
@PostMapping("/refresh")
public ApiResponse<AuthResponse> refresh(@Valid @RequestBody RefreshRequest request) {
return ApiResponse.ok(authService.refresh(request.refreshToken()));
}
@PostMapping("/register")
public ResponseEntity<ApiResponse<AuthResponse>> register(@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.status(201).body(ApiResponse.ok(authService.register(request)));
}
}
public record AuthResponse(String accessToken, String refreshToken, long expiresIn) {}
Method-Level Security
// On service methods
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(UUID userId) { ... }
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public UserProfile getProfile(UUID userId) { ... }
@PostAuthorize("returnObject.email == authentication.name")
public User findById(UUID id) { ... }
application.yml
app:
jwt:
secret: ${JWT_SECRET} # min 256-bit base64 encoded key
access-token-expiration: 900000 # 15 minutes
refresh-token-expiration: 604800000 # 7 days
The example creates refresh tokens but does not implement a refresh endpoint. A production refresh
flow must accept only type=refresh, rotate the refresh token on every use, and revoke the previous
token (for example, with a hashed token-family record in a database or Redis).
Gotchas
- Agent catches AuthenticationException broadly around user lookup - preserve AuthenticationServiceException as a server failure.
- Agent logs in disabled or locked users from valid JWTs - validate current account status as well as claims.
- Agent uses non-lambda chaining (
http.csrf().disable(),.and(),authorizeRequests()) — removed in Security 7, won't compile; lambda DSL only:csrf(AbstractHttpConfigurer::disable),authorizeHttpRequests(...) - Agent writes
antMatchers()/mvcMatchers()orAntPathRequestMatcher/MvcRequestMatcher— removed in Security 7; userequestMatchers("/path/**")(backed byPathPatternRequestMatcher) orPathPatternRequestMatcher.withDefaults().matcher("/path/**") - Agent extends
WebSecurityConfigurerAdapter— long gone; declare aSecurityFilterChainbean - Agent calls
provider.setUserDetailsService(...)— gone in Security 7; pass it to the constructor:new DaoAuthenticationProvider(userDetailsService) - Agent lets
ExpiredJwtExceptionescape the filter — expired token becomes a 500 instead of 401; catch in filter - Agent skips
exceptionHandling()— clients get empty 401/403 bodies (or a login-page redirect);@RestControllerAdvicecan't catch filter-level exceptions - Agent stores JWT secret in code — always
${JWT_SECRET}from environment (HS256 needs a ≥256-bit key orKeys.hmacShaKeyForthrowsWeakKeyException) - Agent uses
SessionCreationPolicy.IF_REQUIRED— must beSTATELESSfor JWT - Agent validates only signature and expiry — the bearer filter must accept
type=accesstokens only; refresh tokens belong to a separate refresh endpoint - Agent forgets
@EnableMethodSecurityfor@PreAuthorizeto work - Agent uses BCrypt strength < 10 — use 12 for production
- Agent puts token validation logic in controller — belongs in filter
- Agent puts refresh tokens in localStorage examples — recommend httpOnly cookies or secure storage; refresh tokens are long-lived credentials
- Agent tests security with
@MockBean/@SpyBean— removed in Boot 4; use@MockitoBean/@MockitoSpyBean, and add@AutoConfigureMockMvc—@SpringBootTestno longer providesMockMvcon its own
Examples & templates
bad-security-config.javaexamples
// ❌ BAD — pre-Security-7 API (won't compile), session-based, hardcoded secret, missing method security
@Configuration
@EnableWebSecurity // missing @EnableMethodSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter { // REMOVED — gone since Security 6, still gone in 7
@Autowired // field injection
private JwtAuthenticationFilter jwtAuthFilter;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() // non-lambda DSL — removed in Security 7, won't compile
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // should be STATELESS for JWT
.and() // and() removed from HttpSecurity in Security 7
.authorizeRequests() // removed — use authorizeHttpRequests
.antMatchers("/api/**").authenticated() // antMatchers removed in Security 7 — use requestMatchers
.and()
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // default strength 10, should use 12
}
// secret hardcoded as string literal — should be in environment variable
private static final String SECRET = "mySecretKey12345678901234567890123456";
}good-security-config.javaexamples
// GOOD: same implementation as templates/SecurityConfig.java; header-only bearer API.
import com.example.auth.filter.JwtAuthenticationFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
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.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
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(proxyBeanMethods = false)
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter filter,
AuthenticationProvider provider) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// /error must be open: container error dispatches (404/405/500 forwarded there)
// carry no JWT, so without this every such error is rewritten to a 401.
.requestMatchers("/api/v1/auth/**", "/actuator/health", "/error").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, failure) -> {
response.setStatus(401);
response.setHeader("WWW-Authenticate", "Bearer");
response.setContentType("application/problem+json");
response.getWriter().write("{\"type\":\"about:blank\",\"title\":\"Unauthorized\",\"status\":401}");
})
.accessDeniedHandler((request, response, failure) -> {
response.setStatus(403);
response.setContentType("application/problem+json");
response.getWriter().write("{\"type\":\"about:blank\",\"title\":\"Forbidden\",\"status\":403}");
}))
.authenticationProvider(provider)
.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
FilterRegistrationBean<JwtAuthenticationFilter> disableServletRegistration(JwtAuthenticationFilter filter) {
var registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
@Bean
AuthenticationProvider authenticationProvider(UserDetailsService users, PasswordEncoder encoder) {
var provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return provider;
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
}JwtAuthenticationFilter.javatemplates
package com.example.auth.filter;
import com.example.auth.service.JwtService;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AccountStatusException;
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
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 UserDetailsService userDetailsService;
private final AccountStatusUserDetailsChecker accountChecker = new AccountStatusUserDetailsChecker();
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
boolean bearer = header != null && (header.equalsIgnoreCase("Bearer")
|| header.regionMatches(true, 0, "Bearer ", 0, 7));
if (!bearer) {
filterChain.doFilter(request, response);
return;
}
final String username;
try {
String token = header.length() > 6 ? header.substring(7).trim() : "";
username = jwtService.validateAccessTokenAndGetSubject(token);
} catch (JwtException | IllegalArgumentException ex) {
reject(response);
return;
}
final UserDetails user;
try {
user = userDetailsService.loadUserByUsername(username);
accountChecker.check(user);
} catch (UsernameNotFoundException | AccountStatusException ex) {
reject(response);
return;
}
if (!username.equals(user.getUsername())) {
reject(response);
return;
}
var authentication = UsernamePasswordAuthenticationToken.authenticated(
user, null, user.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
// Infrastructure and downstream failures must not be converted to credential errors.
filterChain.doFilter(request, response);
}
private void reject(HttpServletResponse response) throws IOException {
SecurityContextHolder.clearContext();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
response.setContentType("application/problem+json");
response.getWriter().write(
"{\"type\":\"about:blank\",\"title\":\"Unauthorized\",\"status\":401}");
}
}JwtService.javatemplates
package com.example.auth.service;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secretKey;
@Value("${app.jwt.access-token-expiration:900000}") // 15 minutes
private long accessTokenExpiration;
@Value("${app.jwt.refresh-token-expiration:604800000}") // 7 days
private long refreshTokenExpiration;
public String generateAccessToken(UserDetails userDetails) {
return buildToken(Map.of("type", "access"), userDetails, accessTokenExpiration);
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(Map.of("type", "refresh"), userDetails, refreshTokenExpiration);
}
public String extractUsername(String token) {
return extractClaims(token).getSubject();
}
public String validateAccessTokenAndGetSubject(String token) {
Claims claims = extractClaims(token);
if (!"access".equals(claims.get("type", String.class))
|| claims.getExpiration() == null
|| !claims.getExpiration().after(new Date())
|| claims.getSubject() == null || claims.getSubject().isBlank()) {
throw new JwtException("Invalid access token claims");
}
return claims.getSubject();
}
public boolean isAccessTokenValid(String token, UserDetails userDetails) {
Claims claims = extractClaims(token);
return "access".equals(claims.get("type", String.class))
&& userDetails.getUsername().equals(claims.getSubject())
&& claims.getExpiration() != null
&& claims.getExpiration().after(new Date());
}
private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails, long expiration) {
Instant now = Instant.now();
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.id(UUID.randomUUID().toString())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusMillis(expiration)))
.signWith(getSigningKey())
.compact();
}
private Claims extractClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}SecurityConfig.javatemplates
package com.example.auth.config;
import com.example.auth.filter.JwtAuthenticationFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
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.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
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(proxyBeanMethods = false)
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter filter,
AuthenticationProvider provider) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// /error must be open: container error dispatches (404/405/500 forwarded there)
// carry no JWT, so without this every such error is rewritten to a 401.
.requestMatchers("/api/v1/auth/**", "/actuator/health", "/error").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, failure) -> {
response.setStatus(401);
response.setHeader("WWW-Authenticate", "Bearer");
response.setContentType("application/problem+json");
response.getWriter().write("{\"type\":\"about:blank\",\"title\":\"Unauthorized\",\"status\":401}");
})
.accessDeniedHandler((request, response, failure) -> {
response.setStatus(403);
response.setContentType("application/problem+json");
response.getWriter().write("{\"type\":\"about:blank\",\"title\":\"Forbidden\",\"status\":403}");
}))
.authenticationProvider(provider)
.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
FilterRegistrationBean<JwtAuthenticationFilter> disableServletRegistration(JwtAuthenticationFilter filter) {
var registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
@Bean
AuthenticationProvider authenticationProvider(UserDetailsService users, PasswordEncoder encoder) {
var provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return provider;
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
}Verification scope
- Compiled sources
- Templates, both trees
- Runtime checks
- Token/account rejection, authorization and real HTTP authentication
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 4 skills
Install Boot 4 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-4-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-4-skills@spring-boot-skills