# Spring Security

> When to activate: Spring Security, authentication, authorization, OAuth2 resource server, custom filters, UserDetailsService, security context

- Skill: `mattakushi432/spring-security` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mattakushi432/spring-security`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mattakushi432/spring-security/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: Mattakushi432 (https://skillmd.com/u/mattakushi432)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mattakushi432/spring-security

---

# Spring Security Patterns

## Custom UserDetailsService

```java
@Service
@RequiredArgsConstructor
public class AppUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByEmail(email)
            .map(this::toUserDetails)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
    }

    private UserDetails toUserDetails(User user) {
        List<GrantedAuthority> authorities = user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
            .collect(Collectors.toList());
        return new org.springframework.security.core.userdetails.User(
            user.getEmail(), user.getPasswordHash(), user.isActive(), true, true, true, authorities);
    }
}
```

## OAuth2 Resource Server (JWT)

```java
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
                .anyRequest().hasAuthority("SCOPE_api")
            )
            .build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthConverter() {
        var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");

        var converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return converter;
    }
}
```

## Custom Authentication Filter

```java
public class ApiKeyAuthFilter extends OncePerRequestFilter {
    private static final String API_KEY_HEADER = "X-API-Key";
    private final ApiKeyService apiKeyService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String apiKey = request.getHeader(API_KEY_HEADER);
        if (apiKey != null) {
            apiKeyService.validateKey(apiKey).ifPresent(principal -> {
                var auth = new UsernamePasswordAuthenticationToken(
                    principal, null, principal.getAuthorities());
                auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(auth);
            });
        }
        chain.doFilter(request, response);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return new AntPathMatcher().match("/public/**", request.getServletPath());
    }
}

// Register in SecurityFilterChain
http.addFilterBefore(apiKeyAuthFilter(), UsernamePasswordAuthenticationFilter.class);
```

## Security Context Access

```java
// In any Spring bean
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
boolean isAdmin = auth.getAuthorities().stream()
    .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));

// In controller via @AuthenticationPrincipal
@GetMapping("/me")
public UserResponse getCurrentUser(@AuthenticationPrincipal JwtAuthenticationToken token) {
    String userId = token.getToken().getSubject();
    return userService.findBySubject(userId);
}

// With custom principal
@GetMapping("/profile")
public Profile getProfile(@AuthenticationPrincipal AppUserPrincipal principal) {
    return profileService.findByUserId(principal.getUserId());
}
```

## Request-Level Authorization with SpEL

```java
.authorizeHttpRequests(auth -> auth
    // Only users accessing their own data
    .requestMatchers("/api/users/{id}/**").access(
        new WebExpressionAuthorizationManager("#id == authentication.name or hasRole('ADMIN')")
    )
    // IP allowlist
    .requestMatchers("/actuator/**").access(
        new WebExpressionAuthorizationManager("hasIpAddress('10.0.0.0/8')")
    )
)
```

## Security Event Listening

```java
@Component
public class AuthenticationEventListener {

    @EventListener
    public void onSuccess(AuthenticationSuccessEvent event) {
        log.info("Login success: {}", event.getAuthentication().getName());
    }

    @EventListener
    public void onFailure(AbstractAuthenticationFailureEvent event) {
        log.warn("Login failure for {}: {}", event.getAuthentication().getName(),
            event.getException().getMessage());
        auditService.recordFailedLogin(event.getAuthentication().getName());
    }
}
```

## Key Rules
- Clear `SecurityContextHolder` after async processing — it's `ThreadLocal` and leaks across thread boundaries
- Never expose internal user IDs in JWT subjects; use opaque UUIDs or email
- Use `@AuthenticationPrincipal` in controllers instead of calling `SecurityContextHolder` directly
- Rate-limit authentication endpoints independently — Spring Security has no built-in rate limiting
- `hasAuthority('ROLE_X')` and `hasRole('X')` are equivalent — `hasRole` auto-prefixes `ROLE_`; be consistent

