rrezartprebreza/spring-security-jwt
> Use when implementing authentication, authorization, JWT tokens, security filters, password encoding, or any Spring Security configuration. Covers stateless JWT auth, access and refresh token validation, RBAC, and method-level security.
npx skills add https://github.com/rrezartprebreza/spring-boot-skills --skill spring-security-jwt
<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>
@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);
}
}
@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));
}
}
@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);
}
}
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:
@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.
@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) {}
// 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) { ... }
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).
HttpSecurity.csrf().disable() old API — use AbstractHttpConfigurer::disableExpiredJwtException escape the filter — expired token becomes a 500 instead of 401; catch in filterexceptionHandling() — clients get empty 401/403 bodies (or a login-page redirect); @RestControllerAdvice can't catch filter-level exceptions${JWT_SECRET} from environment (HS256 needs a ≥256-bit key or Keys.hmacShaKeyFor throws WeakKeyException)SessionCreationPolicy.IF_REQUIRED — must be STATELESS for JWTtype=access tokens only; refresh tokens belong to a separate refresh endpoint@EnableMethodSecurity for @PreAuthorize to workTake rrezartprebreza/spring-security-jwt from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.