# Spring Security Core

> Implements Spring Security 6.x filter chain configuration, JWT authentication filters, method-level security with @EnableMethodSecurity, password encoding, and CORS/CSRF handling for production Spring Boot applications.

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

---






# Spring Security Core & JWT Authentication

Implements production-grade Spring Security 6.x (Spring Boot 3.x) security configurations including `SecurityFilterChain` bean definition without the deprecated `WebSecurityConfigurerAdapter`, JWT token authentication filters, method-level authorization with `@EnableMethodSecurity`, password encoding strategies, and CORS/CSRF handling. When loaded, this skill makes the model write secure request matching rules, custom authentication providers, and role-based authorization annotations using modern Spring Security APIs.

## TL;DR Checklist

- [ ] Confirm `WebSecurityConfigurerAdapter` is never used — all configuration goes through `SecurityFilterChain` beans
- [ ] Verify JWT filter extends `OncePerRequestFilter` or implements `AuthenticationFilter` interface
- [ ] Ensure `PasswordEncoder` bean uses BCrypt (`BCryptPasswordEncoder`) or Argon2 (`Argon2PasswordEncoder`)
- [ ] Check that `@EnableMethodSecurity` is present and `@PreAuthorize` / `@Secured` annotations guard endpoints
- [ ] Validate CORS configuration is explicit per origin (never `.cors().configurationSource(request -> { cors.setAllowedOrigins(List.of("*")); })`)
- [ ] Confirm CSRF is disabled for stateless JWT APIs but enabled for form-based or session authentication

---

## When to Use

Use this skill when:

- Configuring `SecurityFilterChain` beans to define authorization rules per URL pattern in Spring Boot 3.x
- Implementing JWT-based stateless authentication with a custom filter in the security filter chain
- Adding method-level security annotations (`@PreAuthorize`, `@Secured`, `@RolesAllowed`) on service or controller methods
- Setting up password encoding with BCrypt for user credential storage and verification
- Configuring CORS policies for cross-origin API access with specific origin whitelisting

---

## When NOT to Use

Avoid this skill for:

- OAuth2 / OpenID Connect flows — use the `spring-security-oauth2-resource-server` or `spring-security-oauth2-client` starters instead (this skill covers JWT-based direct authentication only)
- LDAP or SAML enterprise identity integration — these require dedicated Spring Security modules beyond core filter chain configuration
- Simple in-memory user authentication for testing — use `spring.security.user.name/password` auto-configuration without manual beans
- Authorization logic that belongs in domain service methods (business rules) — security annotations should guard the entry point, not replace business-level checks

---

## Core Workflow

1. **Define the SecurityFilterChain Bean** — Create a `@Bean` method returning `SecurityFilterChain`. Apply `.authorizeHttpRequests()` with `.requestMatchers("/public/**").permitAll()`, `.requestMatchers("/admin/**").hasRole("ADMIN")`, and `.anyRequest().authenticated()` as the final catch-all. Call `.csrf(csrf -> csrf.disable())` for stateless JWT APIs or configure CSRF token validation for form-based auth. Enable CORS via `.cors(cors -> cors.configurationSource(...))` with explicit allowed origins. Always register a `SecurityFilterChain` bean — never use the deprecated `WebSecurityConfigurerAdapter` which was removed in Spring Security 5.7+ and fully dropped in 6.x.

   **Checkpoint:** Every URL pattern must have an explicit authorization decision — `.anyRequest().authenticated()` should be the last matcher. Verify that `.requestMatchers()` uses exact paths or ant patterns (not regex), since `requestMatchers` replaced `regexMatchers` for performance reasons. Confirm CSRF is disabled only when the application is truly stateless (no session, no form submissions).

2. **Implement the JWT Authentication Filter** — Create a class extending `OncePerRequestFilter` that intercepts requests with an `Authorization: Bearer <token>` header. Parse and validate the JWT using a library like `jjwt` (io.jsonwebtoken). Extract the username and authorities from the token claims, create an `Authentication` object (typically `UsernamePasswordAuthenticationToken`), and set it on `SecurityContextHolder.getContext().setAuthentication(...)`. If the token is invalid or expired, return HTTP 401 without writing to the response body.

   **Checkpoint:** The JWT filter must call `super.doFilterInternal()` only when authentication succeeds — otherwise call `response.sendError(HttpServletResponse.SC_UNAUTHORIZED)` and return immediately. Verify that the filter chain order places the JWT filter before `UsernamePasswordAuthenticationFilter` so pre-authenticated requests bypass form login. Test with an expired token to confirm 401 response.

3. **Configure Password Encoder and UserDetailsService** — Register a `PasswordEncoder` bean using `BCryptPasswordEncoder` (default in Spring Boot) or `Argon2PasswordEncoder` for stronger hashing. Implement a `UserDetailsService` that loads user credentials and authorities from a database (typically via JPA repository). The `loadUserByUsername()` method must throw `UsernameNotFoundException` when the user does not exist — never return a null or disabled user silently. Map entity roles to `SimpleGrantedAuthority` objects with the `ROLE_` prefix.

   **Checkpoint:** Every password stored in the database must be BCrypt-hashed (not plaintext or reversible encryption). Verify that `UserDetails.builder().password(encoder.encode(rawPassword)).roles("USER").build()` produces a valid `UserDetails` object. Confirm `loadUserByUsername()` throws `UsernameNotFoundException` (not just returns null) to trigger proper authentication failure handling.

4. **Enable Method-Level Security** — Annotate the application class or a configuration class with `@EnableMethodSecurity` (replaces `@EnableGlobalMethodSecurity` in Spring Security 6.x). Use `@PreAuthorize("hasRole('ADMIN')")` on controller methods to restrict access before method execution. Use `@Secured({"ROLE_ADMIN", "ROLE_SUPERUSER"})` for role-based checks that do not require SpEL expressions. Optionally use `@PostAuthorize("returnObject.owner == authentication.name")` for post-execution object-level authorization.

   **Checkpoint:** `@EnableMethodSecurity` requires Spring Security 6.x with `spring-security-oauth2-jose` not on the classpath (it enables method security by default). Verify that `@PreAuthorize` expressions use correct SpEL syntax: `hasRole()` without the `ROLE_` prefix, `hasAnyAuthority()`, or custom expression evaluators. Test authorization failures return HTTP 403 Forbidden, not 401 Unauthorized.

---

## Implementation Patterns / Reference Guide

### Pattern 1: SecurityFilterChain Bean with Explicit Request Matching

This is the modern Spring Security 6.x approach — no adapter class, only `SecurityFilterChain` beans.

```java
package com.example.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final AccessDeniedHandler accessDeniedHandler;

    public SecurityConfig(
            JwtAuthenticationFilter jwtAuthFilter,
            AuthenticationEntryPoint authenticationEntryPoint,
            AccessDeniedHandler accessDeniedHandler) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.accessDeniedHandler = accessDeniedHandler;
    }

    @Bean
    public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler))
            .authorizeHttpRequests(auth -> auth
                // Public endpoints — no authentication required
                .requestMatchers("/api/v1/auth/**").permitAll()
                .requestMatchers("/api/v1/public/health").permitAll()
                // Read-only API — authenticated users only
                .requestMatchers(HttpMethod.GET, "/api/v1/users/**").hasAnyRole("USER", "ADMIN")
                // Write operations — admin only
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                // All other endpoints require authentication
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12); // strength factor 12 (default is 10)
    }
}
```

> **Production pitfall:** The `.requestMatchers()` order matters — Spring Security evaluates matchers in declaration order and uses the first match. Place specific patterns before broad ones. Never put `.anyRequest().authenticated()` before specific rules.

### Pattern 2: JWT Authentication Filter (OncePerRequestFilter)

A production-grade JWT filter that extracts, validates, and authenticates requests without duplicating filter execution.

```java
package com.example.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.crypto.SecretKey;
import java.io.IOException;
import java.util.List;

/**
 * JWT authentication filter that runs once per HTTP request.
 * Extracts the Bearer token, validates its signature and expiration,
 * and sets the authenticated principal in SecurityContextHolder.
 */
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";

    private final SecretKey signingKey;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(
            @org.springframework.beans.factory.annotation.Value("${app.security.jwt.secret-key}") String secretKey,
            UserDetailsService userDetailsService) {
        this.signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {

        final String authHeader = request.getHeader(AUTHORIZATION_HEADER);

        if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
            // No JWT present — proceed with the filter chain; authentication stays anonymous
            filterChain.doFilter(request, response);
            return;
        }

        final String jwt = authHeader.substring(BEARER_PREFIX.length());

        try {
            Claims claims = Jwts.parser()
                    .verifyWith(signingKey)
                    .build()
                    .parseSignedClaims(jwt)
                    .getPayload();

            String username = claims.getSubject();

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                // Verify token was issued after the last password change (optional security check)
                String issuedAt = claims.getIssuedAt().toString();
                if (isTokenValid(userDetails, jwt)) {
                    List<SimpleGrantedAuthority> authorities = userDetails.getAuthorities().stream()
                            .map(SimpleGrantedAuthority::new)
                            .toList();

                    UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(
                                    userDetails, null, authorities);
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (Exception e) {
            // Invalid, expired, or tampered token — return 401 Unauthorized
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.getWriter().write(
                    "{\"error\":\"Invalid or expired JWT token\",\"status\":401}");
            return;
        }

        filterChain.doFilter(request, response);
    }

    private boolean isTokenValid(UserDetails userDetails, String jwt) {
        // Placeholder: in production, check claims like "iat", "exp", and custom fields
        return true;
    }
}
```

### Pattern 3: UserDetailsService with Database-backed Authentication

Load user credentials and roles from a JPA entity to enable real authentication.

```java
package com.example.security;

import com.example.model.UserEntity;
import com.example.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
 * Loads user details from the database using a JPA repository.
 * Maps entity roles to Spring Security authorities with ROLE_ prefix.
 */
@Service
public class DatabaseUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public DatabaseUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(
                        "User not found: " + username));

        if (!user.isEnabled()) {
            throw new UsernameNotFoundException(
                    "User account is disabled: " + username);
        }

        List<SimpleGrantedAuthority> authorities = user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
                .toList();

        return User.builder()
                .username(user.getUsername())
                .password(user.getPasswordHash()) // already BCrypt-hashed in DB
                .authorities(authorities)
                .accountExpired(false)
                .accountLocked(false)
                .credentialsExpired(false)
                .disabled(!user.isEnabled())
                .build();
    }
}
```

### Pattern 4: Method-Level Security with @PreAuthorize and @Secured

Demonstrates different authorization strategies at the method level.

```java
package com.example.api;

import com.example.service.UserService;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    /**
     * Any authenticated user can read the user list.
     */
    @GetMapping
    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    public List<UserDto> listUsers() {
        return userService.findAll();
    }

    /**
     * Only admin users can create or delete users.
     */
    @PostMapping
    @PreAuthorize("hasRole('ADMIN')")
    public UserDto createUser(@Valid @RequestBody CreateUserRequest request) {
        return userService.create(request);
    }

    /**
     * @Secured uses role strings directly without SpEL expressions.
     */
    @DeleteMapping("/{id}")
    @Secured({"ROLE_ADMIN", "ROLE_SUPERUSER"})
    public void deleteUser(@PathVariable Long id) {
        userService.delete(id);
    }

    /**
     * Object-level authorization: user can only update their own profile.
     */
    @PutMapping("/me")
    @PreAuthorize("hasRole('USER') and #request.id == authentication.principal.id")
    public UserDto updateProfile(@Valid @RequestBody UpdateProfileRequest request) {
        return userService.updateOwnProfile(request);
    }

    /**
     * JSR-250 @RolesAllowed from javax.annotation (enabled via jsr250Enabled=true).
     */
    @GetMapping("/me")
    @jakarta.annotation.security.RolesAllowed({"USER", "ADMIN"})
    public UserDto getMyProfile() {
        return userService.findOwn();
    }
}

// DTOs for the controller
record UserDto(Long id, String username, List<String> roles) {}
record CreateUserRequest(String username, String password, List<String> roles) {}
record UpdateProfileRequest(Long id, String email, String displayName) {}
```

---

## Constraints

### MUST DO
- Define all security configuration via `SecurityFilterChain` beans annotated with `@Bean` — never use the removed `WebSecurityConfigurerAdapter` class
- Register a `BCryptPasswordEncoder(12)` bean (strength 12 for production, minimum 10) or an `Argon2PasswordEncoder` for enhanced security
- Extend `OncePerRequestFilter` for the JWT authentication filter and set the authenticated token in `SecurityContextHolder.getContext().setAuthentication()` before calling `filterChain.doFilter()`
- Enable method-level security with `@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)` to support both `@PreAuthorize` (SpEL) and `@Secured` annotations
- Use `.requestMatchers()` for all path matching — it is faster than regex-based matching and the only officially supported API in Spring Security 6.x
- Return HTTP 401 for unauthenticated requests and HTTP 403 for authenticated-but-unauthorized requests via custom `AuthenticationEntryPoint` and `AccessDeniedHandler`

### MUST NOT DO
- Use `.cors().configurationSource(request -> { cors.setAllowedOrigins(List.of("*")); })` — wildcard CORS origins with authentication expose the application to cross-site attacks; always whitelist specific origins
- Store plaintext passwords in the database — every password must be encoded with BCrypt before persistence using `passwordEncoder.encode(rawPassword)`
- Place `.anyRequest().authenticated()` before specific request matchers in the filter chain — matchers are evaluated in order and earlier rules take precedence
- Return null from `loadUserByUsername()` instead of throwing `UsernameNotFoundException` — Spring Security requires the exception to trigger proper authentication failure handling
- Use `@EnableGlobalMethodSecurity` — this annotation was deprecated in Spring Security 5.4 and removed in 6.x; use `@EnableMethodSecurity` instead

---

## Output Template

When applying this skill, produce outputs following this structure:

1. **SecurityConfig Class** — Complete `SecurityFilterChain` bean with request matchers, session management, exception handling, and JWT filter registration
2. **JWT Authentication Filter** — `OncePerRequestFilter` implementation with token parsing, validation, and SecurityContext population
3. **UserDetailsService Implementation** — Database-backed user loading with role mapping to Spring Security authorities
4. **Password Encoder Bean** — BCrypt or Argon2 configuration with strength parameter
5. **Method-Security Examples** — Controller classes annotated with `@PreAuthorize`, `@Secured`, and `@RolesAllowed` demonstrating different authorization strategies
6. **CORS Configuration Block** — Explicit origin-whitelisted CORS setup for the configured origins

---

## Related Skills

| Skill | Purpose |
|---|---|
| `spring-boot-auto-config` | Auto-configuration infrastructure that registers security beans conditionally based on classpath presence and property flags |
| `spring-data-jpa` | JPA repository layer that UserDetailsService queries against to load user credentials and role assignments from the database |

---

## Live References

1. [Spring Security 6.x Reference — SecurityFilterChain](https://docs.spring.io/spring-security/reference/6.3/servlet/configuration/java.html#servlet-authn-filter-chain)
2. [JWT Authentication with Spring Security](https://docs.spring.io/spring-security/reference/6.3/servlet/oauth2/resource-server/jwt.html)
3. [@EnableMethodSecurity Documentation](https://docs.spring.io/spring-security/reference/6.3/api/org/springframework/security/config/annotation/method/configuration/EnableMethodSecurity.html)
4. [Password Encoding with BCrypt](https://docs.spring.io/spring-security/reference/6.3/api/org/springframework/security/crypto/bcrypt/BCryptPasswordEncoder.html)
5. [UserDetailsService Interface](https://docs.spring.io/spring-security/reference/6.3/api/org/springframework/security/core/userdetails/UserDetailsService.html)
6. [CORS Configuration in Spring Security](https://docs.spring.io/spring-security/reference/6.3/servlet/configuration/java.html#cors-configuration-java)
7. [Spring Security Java Config Guide](https://docs.spring.io/spring-security/reference/6.3/servlet/configuration/java.html)

