AgentStack
SKILL verified MIT Self-run

Spring Security Jwt

skill-vaquarkhan-fullstack-development-agent-skills-spring-security-jwt · by vaquarkhan

Use when implementing authentication, authorization, JWT tokens, security filters, password encoding, or any Spring Security configuration. Covers stateless JWT auth, token rotation, RBAC, and method-level security.

No reviews yet
0 installs
17 views
0.0% view→install

Install

$ agentstack add skill-vaquarkhan-fullstack-development-agent-skills-spring-security-jwt

✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

Are you the author of Spring Security Jwt? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Spring Security Jwt

Use When

  • Implementing JWT authentication and RBAC with Spring Security
  • Spring Boot code generation or refactor where agent defaults would be wrong

Workflow

  1. Confirm the change matches this skill's domain triggers before coding.
  2. Follow the domain guide conventions and gotchas below — not generic Spring Boot defaults.
  3. Apply project-specific response envelopes, DTO boundaries, and dependency injection rules.
  4. Validate with targeted tests (slice, integration, or contract as appropriate).
  5. Capture evidence before merge: tests, migration notes, or observability proof.

Required Checks

  • Constructor injection used; no @Autowired field injection on new code
  • Controllers return DTOs/envelopes — never raw JPA entities
  • Business logic stays in @Service layer, not controllers or repositories
  • Error handling uses project-standard envelope or RFC 9457 ProblemDetail

Domain Guide

Dependencies


    org.springframework.boot
    spring-boot-starter-security

    io.jsonwebtoken
    jjwt-api
    0.12.6

    io.jsonwebtoken
    jjwt-impl
    0.12.6
    runtime

    io.jsonwebtoken
    jjwt-jackson
    0.12.6
    runtime

Security Configuration

@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

@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 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 isTokenValid(String token, UserDetails user) {
        return extractUsername(token).equals(user.getUsername()) && !isExpired(token);
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    private boolean isExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    private  T extractClaim(String token, Function resolver) {
        return resolver.apply(Jwts.parser().verifyWith(getSigningKey()).build().parseSignedClaims(token).getPayload());
    }

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
    }
}

JWT Filter

@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.isTokenValid(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:

@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

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/login")
    public ApiResponse login(@Valid @RequestBody LoginRequest request) {
        return ApiResponse.ok(authService.login(request));
    }

    @PostMapping("/refresh")
    public ApiResponse refresh(@Valid @RequestBody RefreshRequest request) {
        return ApiResponse.ok(authService.refresh(request.refreshToken()));
    }

    @PostMapping("/register")
    public ResponseEntity> 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-expiry: 900000   # 15 minutes
    refresh-token-expiry: 604800000 # 7 days

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 forgets @EnableMethodSecurity for @PreAuthorize to work
  • Agent uses BCrypt strength Field injection hides dependencies and breaks testability; use constructor injection.
  • "The agent knows Spring Boot." -> Agents default to outdated patterns; follow this skill's gotchas and GA coordinates.
  • "We can skip Flyway for this column." -> Manual DDL drifts from environments; use versioned migrations.

Evidence Pack

  • Test output for changed endpoints, services, or migrations
  • Diff showing DTO boundaries and no entity leakage in API layer
  • Dependency or coordinate list confirming GA artifact names
  • Observability or security checklist for auth/AI changes

Exit Criteria

  • Generated code matches project layering and naming conventions
  • No pre-GA Spring AI or MCP artifact names in pom/build files
  • Tests pass for happy path and at least one failure/edge case

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet — be the first.

Versions

  • v0.1.0 Imported from the upstream source.