Claude Code for Spring Boot Security: JWT, OAuth2, and Method Security — Claude Skills 360 Blog
Blog / Development / Claude Code for Spring Boot Security: JWT, OAuth2, and Method Security
Development

Claude Code for Spring Boot Security: JWT, OAuth2, and Method Security

Published: June 26, 2026
Read time: 10 min read
By: Claude Skills 360

Spring Security’s power comes at the cost of complexity — filter chains, SecurityContext, and the gap between what the documentation says and what production applications need. Claude Code understands the Spring Security model well enough to generate configurations that actually work: stateless JWT auth, method-level security, and OAuth2 resource server setup without the magic annotations that break at scale.

This guide covers Spring Security with Claude Code: JWT authentication, custom filters, OAuth2, and security testing.

Spring Security Configuration

CLAUDE.md for Spring Boot Projects

## Spring Boot Application

- Java 21, Spring Boot 3.3+, Spring Security 6.x
- Build: Gradle with Kotlin DSL
- Auth: Stateless JWT (no sessions)
- Database: PostgreSQL via Spring Data JPA
- Testing: JUnit 5, MockMvc, Spring Security Test

## Security conventions
- All configs in SecurityConfig.java — no @EnableWebSecurity spread across files
- JWT validation via Spring Security OAuth2 resource server (not manual filter)
- Method security: @PreAuthorize with SpEL for fine-grained access control
- Passwords: BCryptPasswordEncoder with strength 12
- No sensitive data in JWT payload — only userId and roles
- CORS: explicit allowlist in production (never CorsConfiguration().setAllowedOrigins(List.of("*")))

## Authentication flow
1. POST /api/auth/login → returns access + refresh tokens
2. Refresh: POST /api/auth/refresh with refresh token in HttpOnly cookie
3. Access token: 15 minute expiry, in Authorization header
4. Refresh token: 7 day expiry, HttpOnly cookie (not accessible via JS)

SecurityConfig

// src/main/java/com/example/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // Enables @PreAuthorize, @PostAuthorize
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                            JwtAuthenticationFilter jwtFilter) throws Exception {
        return http
            // Disable CSRF — stateless API doesn't need it
            .csrf(csrf -> csrf.disable())
            
            // Stateless — no session cookies
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            
            // CORS configuration
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            
            // Request authorization rules
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            
            // Add JWT filter before the default username/password filter
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            
            // Handle auth errors without redirects (API response instead)
            .exceptionHandling(exceptions -> exceptions
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
            )
            .build();
    }
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(
            "https://myapp.com",
            "https://www.myapp.com"
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Request-Id"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }
    
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }
}

JWT Filter

// src/main/java/com/example/security/JwtAuthenticationFilter.java
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    
    private final JwtTokenProvider jwtTokenProvider;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        
        String token = extractToken(request);
        
        if (token != null && jwtTokenProvider.validateToken(token)) {
            UsernamePasswordAuthenticationToken authentication =
                jwtTokenProvider.getAuthentication(token);
            
            // Store in SecurityContext for this request
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        
        filterChain.doFilter(request, response);
    }
    
    private String extractToken(HttpServletRequest request) {
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
    
    // Skip JWT validation for public endpoints
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getServletPath();
        return path.startsWith("/api/auth/") || path.startsWith("/api/public/");
    }
}
// src/main/java/com/example/security/JwtTokenProvider.java
@Component
public class JwtTokenProvider {
    
    @Value("${jwt.secret}")
    private String jwtSecret;
    
    private static final long ACCESS_TOKEN_EXPIRY = 15 * 60 * 1000; // 15 min
    private static final long REFRESH_TOKEN_EXPIRY = 7 * 24 * 60 * 60 * 1000; // 7 days
    
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
    }
    
    public String generateAccessToken(Long userId, Set<String> roles) {
        return Jwts.builder()
            .subject(userId.toString())
            .claim("roles", roles)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRY))
            .signWith(getSigningKey())
            .compact();
    }
    
    public boolean validateToken(String token) {
        try {
            Jwts.parser().verifyWith(getSigningKey()).build().parseSignedClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException ex) {
            return false;
        }
    }
    
    public UsernamePasswordAuthenticationToken getAuthentication(String token) {
        Claims claims = Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
        
        Long userId = Long.parseLong(claims.getSubject());
        @SuppressWarnings("unchecked")
        Set<String> roles = new HashSet<>((List<String>) claims.get("roles"));
        
        List<GrantedAuthority> authorities = roles.stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
            .collect(Collectors.toList());
        
        UserPrincipal principal = new UserPrincipal(userId, authorities);
        return new UsernamePasswordAuthenticationToken(principal, null, authorities);
    }
}

Method-Level Security

Different operations on documents need different permission checks.
Owners can edit, readers can only view, admins can do anything.
// src/main/java/com/example/service/DocumentService.java
@Service
@RequiredArgsConstructor
public class DocumentService {
    
    private final DocumentRepository documentRepository;
    
    // Anyone authenticated can list publicly shared documents
    @PreAuthorize("isAuthenticated()")
    public Page<Document> listPublicDocuments(Pageable pageable) {
        return documentRepository.findBySharedTrue(pageable);
    }
    
    // Only the document owner or admins can update
    @PreAuthorize("@documentSecurity.isOwner(#documentId, authentication) or hasRole('ADMIN')")
    public Document updateDocument(Long documentId, DocumentUpdateRequest request) {
        Document doc = documentRepository.findById(documentId)
            .orElseThrow(() -> new EntityNotFoundException("Document not found: " + documentId));
        
        doc.setTitle(request.title());
        doc.setContent(request.content());
        return documentRepository.save(doc);
    }
    
    // Return only documents the user is allowed to see
    @PostFilter("filterObject.ownerId == authentication.principal.userId or filterObject.shared == true")
    public List<Document> getUserDocuments(Long userId) {
        return documentRepository.findByOwnerId(userId);
    }
}
// src/main/java/com/example/security/DocumentSecurity.java
@Component("documentSecurity")
@RequiredArgsConstructor
public class DocumentSecurity {
    
    private final DocumentRepository documentRepository;
    
    public boolean isOwner(Long documentId, Authentication authentication) {
        if (!(authentication.getPrincipal() instanceof UserPrincipal principal)) {
            return false;
        }
        return documentRepository.existsByIdAndOwnerId(documentId, principal.getUserId());
    }
}

Security Testing

Write MockMvc tests that verify authentication and authorization.
Test that anonymous users are rejected and permissions are enforced.
@SpringBootTest
@AutoConfigureMockMvc
class DocumentControllerTest {
    
    @Autowired MockMvc mockMvc;
    @Autowired JwtTokenProvider jwtTokenProvider;
    @Autowired ObjectMapper objectMapper;
    
    @Test
    void getDocument_unauthenticated_returns401() throws Exception {
        mockMvc.perform(get("/api/documents/1"))
            .andExpect(status().isUnauthorized());
    }
    
    @Test
    void updateDocument_notOwner_returns403() throws Exception {
        String token = generateTokenForUser(999L, Set.of("USER")); // Not the owner
        
        mockMvc.perform(put("/api/documents/1")
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(new DocumentUpdateRequest("Hacked", "..."))))
            .andExpect(status().isForbidden());
    }
    
    @Test
    void updateDocument_owner_succeeds() throws Exception {
        Long ownerId = 1L;  // Document belongs to user 1
        String token = generateTokenForUser(ownerId, Set.of("USER"));
        
        mockMvc.perform(put("/api/documents/1")
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(new DocumentUpdateRequest("Updated", "Content"))))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value("Updated"));
    }
    
    @Test
    void updateDocument_admin_canUpdateAnyDocument() throws Exception {
        String adminToken = generateTokenForUser(999L, Set.of("ADMIN")); // Not owner, but admin
        
        mockMvc.perform(put("/api/documents/1")
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken)
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(new DocumentUpdateRequest("Admin Edit", "..."))))
            .andExpect(status().isOk());
    }
    
    private String generateTokenForUser(Long userId, Set<String> roles) {
        return jwtTokenProvider.generateAccessToken(userId, roles);
    }
}

For the broader Java/Spring Boot application beyond security, the Java Spring Boot guide covers application architecture, REST endpoints, and Hibernate patterns. For security testing patterns that go beyond unit tests to penetration testing, see the security testing guide. The Claude Skills 360 bundle includes Spring Security skill sets for JWT, OAuth2, and method security configurations. Start with the free tier to generate security filter chains for your Spring Boot API.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free