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.
Scanned 9/1/2026
Install to Claude Code
npx -y skills add rrezartprebreza/spring-boot-skills --skill spring-security-jwt --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Spring Security Jwt?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/rrezartprebreza-spring-security-jwt)More formats (shields.io, HTML) on the badges page.
---
name: spring-security-jwt
description: >
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.
---
# Spring Security — JWT
## 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>
```
## Security Configuration
```java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
var 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);
}
}
```
## JWT Service
```java
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secretKey;
@Value("${app.jwt.access-token-expiry:900000}") // 15 min default
private long accessTokenExpiry;
@Value("${app.jwt.refresh-token-expiry:604800000}") // 7 days default
private long refreshTokenExpiry;
public String generateAccessToken(UserDetails user) {
return generateToken(Map.of("type", "access"), user, accessTokenExpiry);
}
public String generateRefreshToken(UserDetails user) {
return generateToken(Map.of("type", "refresh"), user, refreshTokenExpiry);
}
private String generateToken(Map<String, Object> claims, UserDetails user, long expiry) {
return Jwts.builder()
.claims(claims)
.subject(user.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiry))
.signWith(getSigningKey())
.compact();
}
public boolean isAccessTokenValid(String token, UserDetails user) {
Claims claims = extractClaims(token);
return "access".equals(claims.get("type", String.class))
&& user.getUsername().equals(claims.getSubject())
&& !claims.getExpiration().before(new Date());
}
public String extractUsername(String token) {
return extractClaims(token).getSubject();
}
private Claims extractClaims(String token) {
return Jwts.parser().verifyWith(getSigningKey()).build()
.parseSignedClaims(token).getPayload();
}
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
}
```
## JWT Filter
```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 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.extractUsername(token);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isAccessTokenValid(token, user)) {
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
} catch (ExpiredJwtException | MalformedJwtException | SignatureException e) {
// Parsing throws on expired/tampered tokens. Without this catch the exception
// escapes the filter chain as a 500. Leave the context empty — the entry
// point below turns it into a clean 401.
SecurityContextHolder.clearContext();
}
chain.doFilter(request, response);
}
}
```
## JSON 401/403 — Don't Ship the Defaults
Out of the box, an unauthenticated API request gets an empty 401 (or worse, a redirect to a login
page) and `AccessDeniedException` becomes an empty 403. REST clients need a body:
```java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// ... as above ...
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, e) -> { // 401 — not authenticated
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{"success":false,"error":{"code":"UNAUTHORIZED","message":"Authentication required"}}""");
})
.accessDeniedHandler((request, response, e) -> { // 403 — authenticated, no permission
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("""
{"success":false,"error":{"code":"FORBIDDEN","message":"Insufficient permissions"}}""");
})
)
.build();
}
```
`@RestControllerAdvice` cannot catch these — security filters run **before** the dispatcher servlet,
so exceptions thrown there never reach your exception handler.
## Auth Controller
```java
@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
```java
// 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
```yaml
app:
jwt:
secret: ${JWT_SECRET} # min 256-bit base64 encoded key
access-token-expiry: 900000 # 15 minutes
refresh-token-expiry: 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 uses `HttpSecurity.csrf().disable()` old API — use `AbstractHttpConfigurer::disable`
- Agent lets `ExpiredJwtException` escape 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); `@RestControllerAdvice` can't catch filter-level exceptions
- Agent stores JWT secret in code — always `${JWT_SECRET}` from environment (HS256 needs a ≥256-bit key or `Keys.hmacShaKeyFor` throws `WeakKeyException`)
- Agent uses `SessionCreationPolicy.IF_REQUIRED` — must be `STATELESS` for JWT
- Agent validates only signature and expiry — the bearer filter must accept `type=access` tokens only; refresh tokens belong to a separate refresh endpoint
- Agent forgets `@EnableMethodSecurity` for `@PreAuthorize` to 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
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!