
Spring Boot Security
- 1 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Implements Spring Security 7 for Spring Boot 4: authentication, authorization, OAuth2/JWT resource servers, method security, and the mandatory Lambda DSL migration.
About
Implements authentication and authorization in Spring Boot 4 with Spring Security 7's mandatory Lambda DSL, SecurityFilterChain beans, and @PreAuthorize. A developer uses it to secure endpoints or migrate off removed Spring Security APIs.
- Lambda DSL and SecurityFilterChain migration
- OAuth2/JWT resource server and method security
Spring Boot Security by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,834 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill spring-boot-securityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Implements Spring Security 7 for Spring Boot 4: authentication, authorization, OAuth2/JWT resource servers, method security, and the mandatory Lambda DSL migration.
Files
Spring Security 7 for Spring Boot 4
Implements authentication and authorization with Spring Security 7's mandatory Lambda DSL.
Critical Breaking Changes
| Removed API | Replacement | Status |
|---|---|---|
and() method | Lambda DSL closures | Required |
authorizeRequests() | authorizeHttpRequests() | Required |
antMatchers() | requestMatchers() | Required |
WebSecurityConfigurerAdapter | SecurityFilterChain bean | Required |
@EnableGlobalMethodSecurity | @EnableMethodSecurity | Required |
Core Workflow
1. Create SecurityFilterChain → 2. Define authorization → 3. Configure authentication → 4. Add method security → 5. Handle CORS/CSRF
See WORKFLOW.md for detailed step-by-step instructions with code examples.
Quick Patterns
See EXAMPLES.md for complete working examples including:
- REST API Security with JWT/OAuth2 (Java + Kotlin)
- Form Login with Session Security and CSRF
- Method Security with @PreAuthorize and SpEL
- CORS Configuration for cross-origin APIs
- Password Encoder (Argon2 for Security 7)
Spring Boot 4 Specifics
- Lambda DSL is mandatory (no
and()chaining) - Argon2 password encoder:
Argon2PasswordEncoder.defaultsForSpring7() - CSRF for SPAs:
CookieCsrfTokenRepository.withHttpOnlyFalse() - @EnableMethodSecurity replaces
@EnableGlobalMethodSecurity
Detailed References
- Workflow: See WORKFLOW.md for detailed step-by-step security configuration
- Examples: See EXAMPLES.md for complete working code examples
- Troubleshooting: See TROUBLESHOOTING.md for common issues and Boot 4 migration
- Security Configuration: See references/SECURITY-CONFIG.md for complete SecurityFilterChain patterns
- Authentication: See references/AUTHENTICATION.md for UserDetailsService, password encoding
- JWT/OAuth2: See references/JWT-OAUTH2.md for resource server, token validation
Related Skills
| Need | Skill |
|---|---|
| Testing secured endpoints | spring-boot-testing |
| Actuator endpoint security | spring-boot-observability |
| Dependency verification | spring-boot-verify |
Anti-Pattern Checklist
| Anti-Pattern | Fix |
|---|---|
Using and() chaining | Use Lambda DSL closures |
antMatchers() | Replace with requestMatchers() |
authorizeRequests() | Replace with authorizeHttpRequests() |
| CSRF disabled without JWT | Keep CSRF for session-based auth |
| Hardcoded credentials | Use environment variables or Secret Manager |
permitAll() on sensitive endpoints | Audit all permit rules |
Missing authenticated() default | End with .anyRequest().authenticated() |
Critical Reminders
1. Lambda DSL is mandatory — No more and() chaining in Security 7 2. Order matters — More specific requestMatchers before general ones 3. CSRF for sessions — Only disable for stateless JWT APIs 4. Method security needs enabling — Add @EnableMethodSecurity 5. Test security configuration — Use @WithMockUser and JWT test support (see spring-boot-testing)
Spring Security 7 Examples
Complete working examples for Spring Boot 4 security patterns.
Minimal REST API Security
Stateless JWT/OAuth2 configuration with Lambda DSL.
Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()); // Stateless API
return http.build();
}
}Kotlin
import org.springframework.security.config.annotation.web.invoke
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/public/**", permitAll)
authorize("/api/admin/**", hasRole("ADMIN"))
authorize(anyRequest, authenticated)
}
oauth2ResourceServer { jwt { } }
sessionManagement { sessionCreationPolicy = SessionCreationPolicy.STATELESS }
csrf { disable() }
}
return http.build()
}
}Key points:
- Lambda DSL is mandatory in Security 7 (no
and()chaining) - Use
requestMatchers()instead of deprecatedantMatchers() - Disable CSRF only for stateless JWT APIs
---
Form Login with Session Security
Traditional web application with session management and CSRF.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error=true")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout=true")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
)
.sessionManagement(session -> session
.maximumSessions(1)
.expiredUrl("/login?expired=true")
)
.csrf(csrf -> csrf.csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse() // For SPA
));
return http.build();
}Key points:
- Keep CSRF enabled for session-based authentication
- Use
CookieCsrfTokenRepositoryfor SPA frontends - Limit concurrent sessions for security
---
Method Security
Fine-grained authorization with SpEL expressions.
@Configuration
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class MethodSecurityConfig {}
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN') or #customerId == authentication.principal.id")
public Order getOrder(Long customerId, Long orderId) {
// Admin or owner can access
}
@PreAuthorize("@orderSecurity.canModify(authentication, #orderId)")
public void updateOrder(Long orderId, OrderRequest request) {
// Delegates to security bean
}
@PostFilter("filterObject.isPublic or filterObject.ownerId == authentication.name")
public List<Order> findAll() {
// Filters results after execution
}
}
@Component("orderSecurity")
public class OrderSecurityEvaluator {
public boolean canModify(Authentication auth, Long orderId) {
// Custom authorization logic
return orderRepository.findById(orderId)
.map(order -> order.getOwnerId().equals(auth.getName()))
.orElse(false);
}
}Key points:
- Enable with
@EnableMethodSecurity(replaces@EnableGlobalMethodSecurity) - Use
@PreAuthorizefor pre-execution checks - Delegate complex logic to security beans with
@component.method()syntax
---
CORS Configuration
Complete CORS setup for cross-origin API access.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://frontend.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
// In SecurityFilterChain:
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));Key points:
- Configure CORS in security filter chain, not separately
- Set
allowCredentials(true)for cookie-based auth - Use specific origins in production (never
*with credentials)
---
Password Encoder (Spring Boot 4)
Argon2 password encoder recommended for Security 7.
@Bean
public PasswordEncoder passwordEncoder() {
return Argon2PasswordEncoder.defaultsForSpring7();
}Key points:
- Argon2 is memory-hard (resistant to GPU attacks)
defaultsForSpring7()provides secure default parameters- Bcrypt still supported if needed for compatibility
Authentication Patterns
UserDetailsService, password encoding, and authentication providers.
Table of Contents
- Custom UserDetailsService
- Java
- Kotlin
- Custom UserDetails Implementation
- Password Encoding
- Argon2 (Recommended for Spring Security 7)
- BCrypt (Legacy compatible)
- Delegating Encoder (Migration support)
- Registration Flow
- Password Reset Flow
- Authentication Events
- Multi-Factor Authentication Setup
- Role Hierarchy
Custom UserDetailsService
Java
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByEmail(username)
.map(this::toUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
private UserDetails toUserDetails(User user) {
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword())
.authorities(mapAuthorities(user.getRoles()))
.accountExpired(!user.isActive())
.accountLocked(user.isLocked())
.credentialsExpired(user.isPasswordExpired())
.disabled(!user.isEnabled())
.build();
}
private Collection<GrantedAuthority> mapAuthorities(Set<Role> roles) {
return roles.stream()
.flatMap(role -> {
// Add role + its permissions
Stream<GrantedAuthority> roleAuthority =
Stream.of(new SimpleGrantedAuthority("ROLE_" + role.getName()));
Stream<GrantedAuthority> permissions = role.getPermissions().stream()
.map(p -> new SimpleGrantedAuthority(p.getName()));
return Stream.concat(roleAuthority, permissions);
})
.collect(Collectors.toSet());
}
}Kotlin
@Service
class CustomUserDetailsService(private val userRepository: UserRepository) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails =
userRepository.findByEmail(username)
?.toUserDetails()
?: throw UsernameNotFoundException("User not found: $username")
private fun User.toUserDetails(): UserDetails =
org.springframework.security.core.userdetails.User.builder()
.username(email)
.password(password)
.authorities(roles.flatMap { role ->
listOf(SimpleGrantedAuthority("ROLE_${role.name}")) +
role.permissions.map { SimpleGrantedAuthority(it.name) }
})
.accountExpired(!isActive)
.accountLocked(isLocked)
.credentialsExpired(isPasswordExpired)
.disabled(!isEnabled)
.build()
}Custom UserDetails Implementation
For richer principal with additional fields:
public class CustomUserDetails implements UserDetails {
private final Long id;
private final String email;
private final String password;
private final String fullName;
private final String tenantId;
private final Collection<GrantedAuthority> authorities;
private final boolean enabled;
private final boolean accountNonLocked;
// Constructor, getters...
@Override
public String getUsername() { return email; }
@Override
public String getPassword() { return password; }
@Override
public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return accountNonLocked; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return enabled; }
// Custom accessors
public Long getId() { return id; }
public String getFullName() { return fullName; }
public String getTenantId() { return tenantId; }
}Access in controller:
@GetMapping("/profile")
public ProfileDto getProfile(@AuthenticationPrincipal CustomUserDetails user) {
return new ProfileDto(user.getId(), user.getFullName(), user.getTenantId());
}Password Encoding
Argon2 (Recommended for Spring Security 7)
@Bean
public PasswordEncoder passwordEncoder() {
return Argon2PasswordEncoder.defaultsForSpring7();
}BCrypt (Legacy compatible)
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // Strength 12
}Delegating Encoder (Migration support)
Supports multiple encodings for gradual migration:
@Bean
public PasswordEncoder passwordEncoder() {
String defaultEncoderId = "argon2";
Map<String, PasswordEncoder> encoders = Map.of(
"argon2", Argon2PasswordEncoder.defaultsForSpring7(),
"bcrypt", new BCryptPasswordEncoder(12),
"scrypt", SCryptPasswordEncoder.defaultsForSpring7()
);
return new DelegatingPasswordEncoder(defaultEncoderId, encoders);
}Stored passwords: {argon2}$argon2id$v=19$m=16384... or {bcrypt}$2a$12$...
Registration Flow
@Service
public class UserRegistrationService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public User register(RegistrationRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new EmailAlreadyExistsException(request.email());
}
User user = new User();
user.setEmail(request.email());
user.setPassword(passwordEncoder.encode(request.password()));
user.setFullName(request.fullName());
user.setRoles(Set.of(roleRepository.findByName("USER").orElseThrow()));
user.setEnabled(false); // Require email verification
user.setVerificationToken(UUID.randomUUID().toString());
User saved = userRepository.save(user);
eventPublisher.publishEvent(new UserRegisteredEvent(saved));
return saved;
}
@Transactional
public void verifyEmail(String token) {
User user = userRepository.findByVerificationToken(token)
.orElseThrow(() -> new InvalidTokenException("Invalid verification token"));
user.setEnabled(true);
user.setVerificationToken(null);
userRepository.save(user);
}
}Password Reset Flow
@Service
public class PasswordResetService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final PasswordResetTokenRepository tokenRepository;
@Transactional
public void initiateReset(String email) {
userRepository.findByEmail(email).ifPresent(user -> {
// Invalidate existing tokens
tokenRepository.deleteByUser(user);
PasswordResetToken token = new PasswordResetToken();
token.setUser(user);
token.setToken(UUID.randomUUID().toString());
token.setExpiryDate(Instant.now().plus(24, ChronoUnit.HOURS));
tokenRepository.save(token);
eventPublisher.publishEvent(new PasswordResetRequestedEvent(user, token.getToken()));
});
// Always return success to prevent email enumeration
}
@Transactional
public void resetPassword(String token, String newPassword) {
PasswordResetToken resetToken = tokenRepository.findByToken(token)
.filter(t -> t.getExpiryDate().isAfter(Instant.now()))
.orElseThrow(() -> new InvalidTokenException("Token expired or invalid"));
User user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(newPassword));
user.setPasswordExpired(false);
userRepository.save(user);
tokenRepository.delete(resetToken);
// Invalidate all sessions for this user
eventPublisher.publishEvent(new PasswordChangedEvent(user));
}
}Authentication Events
@Component
public class AuthenticationEventListener {
private final LoginAttemptService loginAttemptService;
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
loginAttemptService.loginSucceeded(username);
log.info("Successful login: {}", username);
}
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
String username = (String) event.getAuthentication().getPrincipal();
loginAttemptService.loginFailed(username);
log.warn("Failed login attempt: {} - {}", username, event.getException().getMessage());
}
}
@Service
public class LoginAttemptService {
private final Cache<String, Integer> attemptsCache;
private static final int MAX_ATTEMPTS = 5;
public void loginFailed(String username) {
int attempts = attemptsCache.get(username, k -> 0) + 1;
attemptsCache.put(username, attempts);
if (attempts >= MAX_ATTEMPTS) {
userRepository.findByEmail(username).ifPresent(user -> {
user.setLocked(true);
user.setLockedUntil(Instant.now().plus(30, ChronoUnit.MINUTES));
userRepository.save(user);
});
}
}
public void loginSucceeded(String username) {
attemptsCache.invalidate(username);
}
}Multi-Factor Authentication Setup
@Service
public class TotpService {
private final GoogleAuthenticator googleAuth = new GoogleAuthenticator();
public TotpSetup generateSecret(User user) {
GoogleAuthenticatorKey key = googleAuth.createCredentials();
String qrCodeUrl = GoogleAuthenticatorQRGenerator.getOtpAuthTotpURL(
"MyApp",
user.getEmail(),
key
);
return new TotpSetup(key.getKey(), qrCodeUrl);
}
public boolean verifyCode(String secret, int code) {
return googleAuth.authorize(secret, code);
}
}
// Custom authentication filter for 2FA
public class TwoFactorAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && requiresTwoFactor(auth)) {
if (!hasTwoFactorCompleted(auth)) {
response.sendRedirect("/2fa/verify");
return;
}
}
chain.doFilter(request, response);
}
}Role Hierarchy
@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("MANAGER")
.role("MANAGER").implies("USER")
.role("USER").implies("GUEST")
.build();
}
@Bean
public MethodSecurityExpressionHandler methodSecurityExpressionHandler(
RoleHierarchy roleHierarchy) {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setRoleHierarchy(roleHierarchy);
return handler;
}With this configuration, hasRole('ADMIN') automatically includes MANAGER, USER, and GUEST permissions.
JWT and OAuth2 Resource Server
Token validation, claims extraction, and OAuth2 configuration.
Table of Contents
- JWT Resource Server Configuration
- Basic Setup
- Kotlin
- Custom Claims Extraction
- Custom JWT Authentication Converter
- Access in Controller
- Multiple JWT Issuers
- OAuth2 Client (for calling external APIs)
- Opaque Token (Token Introspection)
- JWT with JWKS Endpoint
- Configuration Properties
- Error Handling for OAuth2
- Public Key Configuration (No JWKS endpoint)
JWT Resource Server Configuration
Basic Setup
@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(jwtDecoder())
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withIssuerLocation("https://auth.example.com")
.build();
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(60)),
new JwtIssuerValidator("https://auth.example.com"),
audienceValidator()
);
decoder.setJwtValidator(validator);
return decoder;
}
private OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtClaimValidator<List<String>>(
"aud",
aud -> aud != null && aud.contains("my-api")
);
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("permissions");
grantedAuthoritiesConverter.setAuthorityPrefix(""); // No prefix
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
converter.setPrincipalClaimName("sub");
return converter;
}
}Kotlin
@Configuration
@EnableWebSecurity
class JwtSecurityConfig {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/api/public/**", permitAll)
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
jwt {
jwtDecoder = jwtDecoder()
jwtAuthenticationConverter = jwtAuthenticationConverter()
}
}
sessionManagement { sessionCreationPolicy = SessionCreationPolicy.STATELESS }
csrf { disable() }
}
return http.build()
}
@Bean
fun jwtDecoder(): JwtDecoder {
val decoder = NimbusJwtDecoder
.withIssuerLocation("https://auth.example.com")
.build()
val validator = DelegatingOAuth2TokenValidator(
JwtTimestampValidator(Duration.ofSeconds(60)),
JwtIssuerValidator("https://auth.example.com"),
JwtClaimValidator<List<String>>("aud") { it?.contains("my-api") == true }
)
decoder.setJwtValidator(validator)
return decoder
}
@Bean
fun jwtAuthenticationConverter() = JwtAuthenticationConverter().apply {
setJwtGrantedAuthoritiesConverter(
JwtGrantedAuthoritiesConverter().apply {
setAuthoritiesClaimName("permissions")
setAuthorityPrefix("")
}
)
}
}Custom Claims Extraction
Custom JWT Authentication Converter
@Component
public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = extractAuthorities(jwt);
CustomPrincipal principal = extractPrincipal(jwt);
return new CustomJwtAuthenticationToken(jwt, principal, authorities);
}
private Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
Set<GrantedAuthority> authorities = new HashSet<>();
// Extract roles
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.forEach(authorities::add);
}
// Extract permissions/scopes
List<String> permissions = jwt.getClaimAsStringList("permissions");
if (permissions != null) {
permissions.stream()
.map(SimpleGrantedAuthority::new)
.forEach(authorities::add);
}
// Extract scope claim (space-separated)
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
Arrays.stream(scope.split(" "))
.map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
.forEach(authorities::add);
}
return authorities;
}
private CustomPrincipal extractPrincipal(Jwt jwt) {
return new CustomPrincipal(
jwt.getSubject(),
jwt.getClaimAsString("email"),
jwt.getClaimAsString("name"),
jwt.getClaimAsString("tenant_id")
);
}
}
public record CustomPrincipal(
String userId,
String email,
String name,
String tenantId
) {}
public class CustomJwtAuthenticationToken extends AbstractAuthenticationToken {
private final Jwt jwt;
private final CustomPrincipal principal;
public CustomJwtAuthenticationToken(Jwt jwt, CustomPrincipal principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.jwt = jwt;
this.principal = principal;
setAuthenticated(true);
}
@Override
public Object getPrincipal() { return principal; }
@Override
public Object getCredentials() { return jwt; }
public Jwt getJwt() { return jwt; }
}Access in Controller
@RestController
@RequestMapping("/api")
public class SecuredController {
@GetMapping("/me")
public UserInfo getCurrentUser(@AuthenticationPrincipal CustomPrincipal principal) {
return new UserInfo(principal.userId(), principal.email(), principal.name());
}
@GetMapping("/tenant-data")
@PreAuthorize("#principal.tenantId == @tenantResolver.getCurrentTenant()")
public TenantData getTenantData(@AuthenticationPrincipal CustomPrincipal principal) {
return tenantService.getData(principal.tenantId());
}
// Direct JWT access
@GetMapping("/token-info")
public Map<String, Object> getTokenInfo(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"subject", jwt.getSubject(),
"issuer", jwt.getIssuer().toString(),
"issuedAt", jwt.getIssuedAt(),
"expiresAt", jwt.getExpiresAt(),
"claims", jwt.getClaims()
);
}
}Multiple JWT Issuers
@Configuration
public class MultiIssuerJwtConfig {
@Bean
public JwtDecoder jwtDecoder() {
Map<String, JwtDecoder> decoders = Map.of(
"https://auth.example.com", createDecoder("https://auth.example.com"),
"https://partner-auth.example.com", createDecoder("https://partner-auth.example.com")
);
return token -> {
String issuer = JWTParser.parse(token).getJWTClaimsSet().getIssuer();
JwtDecoder decoder = decoders.get(issuer);
if (decoder == null) {
throw new JwtException("Unknown issuer: " + issuer);
}
return decoder.decode(token);
};
}
private JwtDecoder createDecoder(String issuer) {
return NimbusJwtDecoder.withIssuerLocation(issuer).build();
}
}OAuth2 Client (for calling external APIs)
spring:
security:
oauth2:
client:
registration:
external-api:
client-id: ${CLIENT_ID}
client-secret: ${CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: api.read, api.write
provider:
external-api:
token-uri: https://auth.external.com/oauth/token@Configuration
public class OAuth2ClientConfig {
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService clientService) {
OAuth2AuthorizedClientProvider provider = OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.refreshToken()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager manager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository, clientService);
manager.setAuthorizedClientProvider(provider);
return manager;
}
@Bean
public RestClient externalApiClient(OAuth2AuthorizedClientManager clientManager) {
OAuth2ClientHttpRequestInterceptor interceptor =
new OAuth2ClientHttpRequestInterceptor(clientManager);
interceptor.setClientRegistrationId("external-api");
return RestClient.builder()
.baseUrl("https://api.external.com")
.requestInterceptor(interceptor)
.build();
}
}Opaque Token (Token Introspection)
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://auth.example.com/oauth/introspect
client-id: ${INTROSPECTION_CLIENT_ID}
client-secret: ${INTROSPECTION_CLIENT_SECRET}@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(opaque -> opaque
.introspector(opaqueTokenIntrospector())
)
);
return http.build();
}
@Bean
public OpaqueTokenIntrospector opaqueTokenIntrospector() {
return new NimbusOpaqueTokenIntrospector(
"https://auth.example.com/oauth/introspect",
"client-id",
"client-secret"
);
}JWT with JWKS Endpoint
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
.jwsAlgorithms(algorithms -> {
algorithms.add(SignatureAlgorithm.RS256);
algorithms.add(SignatureAlgorithm.RS384);
})
.build();
}Configuration Properties
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
# OR specify JWKS directly:
jwk-set-uri: https://auth.example.com/.well-known/jwks.json
# Optional: specify expected audiences
audiences: my-api, my-other-apiError Handling for OAuth2
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder()))
.authenticationEntryPoint((request, response, exception) -> {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
String error = "invalid_token";
String description = "The access token is invalid or expired";
if (exception instanceof InvalidBearerTokenException) {
description = exception.getMessage();
}
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
problem.setTitle("Unauthorized");
problem.setDetail(description);
problem.setProperty("error", error);
new ObjectMapper().writeValue(response.getOutputStream(), problem);
})
.accessDeniedHandler((request, response, exception) -> {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.FORBIDDEN);
problem.setTitle("Forbidden");
problem.setDetail("Insufficient permissions");
new ObjectMapper().writeValue(response.getOutputStream(), problem);
})
);
return http.build();
}Public Key Configuration (No JWKS endpoint)
@Bean
public JwtDecoder jwtDecoder(@Value("${jwt.public-key}") RSAPublicKey publicKey) {
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}Or from PEM file:
@Bean
public JwtDecoder jwtDecoder(@Value("classpath:public-key.pem") Resource publicKeyResource)
throws Exception {
String key = new String(publicKeyResource.getInputStream().readAllBytes());
key = key.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
byte[] decoded = Base64.getDecoder().decode(key);
X509EncodedKeySpec spec = new X509EncodedKeySpec(decoded);
RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec);
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}Security Configuration Patterns
Complete SecurityFilterChain configurations for common scenarios.
Table of Contents
- Full REST API Configuration
- Java
- Kotlin
- Multiple Security Filter Chains
- Request Matchers Patterns
- Exception Handling
- Session Management
- Headers Security
- Remember-Me
Full REST API Configuration
Java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/**").hasAuthority("SCOPE_read")
.requestMatchers(HttpMethod.POST, "/api/**").hasAuthority("SCOPE_write")
.requestMatchers(HttpMethod.PUT, "/api/**").hasAuthority("SCOPE_write")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAuthority("SCOPE_delete")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType("application/problem+json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("""
{"type":"about:blank","title":"Unauthorized","status":401}
""");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType("application/problem+json");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("""
{"type":"about:blank","title":"Forbidden","status":403}
""");
})
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register", "/error").permitAll()
.requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("permissions");
grantedAuthoritiesConverter.setAuthorityPrefix("");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"https://app.example.com",
"http://localhost:3000"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("Location", "X-Total-Count"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}Kotlin
import org.springframework.security.config.annotation.web.invoke
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
class SecurityConfig {
@Bean
@Order(1)
fun apiFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
securityMatcher("/api/**")
authorizeHttpRequests {
authorize(HttpMethod.OPTIONS, "/**", permitAll)
authorize(HttpMethod.GET, "/api/public/**", permitAll)
authorize("/api/admin/**", hasRole("ADMIN"))
authorize(HttpMethod.GET, "/api/**", hasAuthority("SCOPE_read"))
authorize(HttpMethod.POST, "/api/**", hasAuthority("SCOPE_write"))
authorize(anyRequest, authenticated)
}
oauth2ResourceServer { jwt { } }
sessionManagement { sessionCreationPolicy = SessionCreationPolicy.STATELESS }
csrf { disable() }
cors { configurationSource = corsConfigurationSource() }
}
return http.build()
}
@Bean
@Order(2)
fun webFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
authorizeHttpRequests {
authorize("/login", permitAll)
authorize("/css/**", permitAll)
authorize(anyRequest, authenticated)
}
formLogin {
loginPage = "/login"
defaultSuccessUrl("/dashboard", true)
}
logout {
logoutSuccessUrl = "/login?logout"
}
}
return http.build()
}
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val config = CorsConfiguration().apply {
allowedOrigins = listOf("https://app.example.com")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
allowedHeaders = listOf("*")
allowCredentials = true
maxAge = 3600L
}
return UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/api/**", config)
}
}
}Multiple Security Filter Chains
Use @Order and securityMatcher() for different authentication per path:
@Configuration
@EnableWebSecurity
public class MultiSecurityConfig {
// API endpoints - JWT authentication
@Bean
@Order(1)
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
// Actuator endpoints - Basic authentication
@Bean
@Order(2)
public SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.anyRequest().hasRole("ACTUATOR")
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
// Web pages - Form authentication
@Bean
@Order(3)
public SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form.loginPage("/login").permitAll());
return http.build();
}
}Request Matchers Patterns
.authorizeHttpRequests(auth -> auth
// Exact path
.requestMatchers("/api/orders").hasRole("USER")
// Path pattern with wildcard
.requestMatchers("/api/orders/**").hasRole("USER")
// HTTP method specific
.requestMatchers(HttpMethod.POST, "/api/orders").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/**").hasRole("ADMIN")
// Multiple patterns
.requestMatchers("/public/**", "/assets/**", "/error").permitAll()
// MVC pattern (recommended for MVC apps)
.requestMatchers(new MvcRequestMatcher(introspector, "/users/{id}")).authenticated()
// Regex pattern
.requestMatchers(new RegexRequestMatcher("/api/v[0-9]+/.*", null)).authenticated()
// IP-based (internal only)
.requestMatchers(new IpAddressMatcher("192.168.1.0/24")).permitAll()
// Actuator endpoints
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ACTUATOR")
// Default deny
.anyRequest().authenticated()
)Exception Handling
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler((request, response, denied) -> {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN,
"Access denied: " + denied.getMessage()
);
new ObjectMapper().writeValue(response.getOutputStream(), problem);
})
)Session Management
.sessionManagement(session -> session
// Stateless for APIs
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// Or for web apps with concurrent session control
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
.maxSessionsPreventsLogin(false) // Kicks out previous session
.expiredUrl("/login?expired")
// Session fixation protection
.sessionFixation().migrateSession()
)Headers Security
.headers(headers -> headers
.frameOptions(frame -> frame.deny())
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'")
)
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
.referrerPolicy(referrer -> referrer
.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
)
)Remember-Me
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
PersistentTokenRepository tokenRepository) throws Exception {
http
.rememberMe(remember -> remember
.tokenRepository(tokenRepository)
.tokenValiditySeconds(86400 * 14) // 14 days
.userDetailsService(userDetailsService)
.key("uniqueAndSecretKey")
);
return http.build();
}
@Bean
public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
return repo;
}Spring Security 7 Troubleshooting
Common issues and solutions for Spring Boot 4 security.
Common Issues
Issue: Lambda DSL Migration from Method Chaining
Symptom: Compilation error and() method not found or deprecation warnings
Cause: Security 7 removed and() chaining, Lambda DSL is mandatory
Solution:
// Before (Boot 3.x / Security 6) - DOES NOT COMPILE
http
.authorizeHttpRequests()
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and() // Removed in Security 7!
.oauth2ResourceServer()
.jwt();
// After (Boot 4.x / Security 7) - Lambda DSL
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
);---
Issue: OAuth2 Resource Server JWT Validation Failures
Symptom: 401 Unauthorized with valid JWT token
Cause: Wrong issuer URI, missing JWKS endpoint, or clock skew
Solution:
1. Verify issuer URI matches token's iss claim:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/2. Or configure JWK Set URI directly:
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://auth.example.com/.well-known/jwks.json3. Allow clock skew for distributed systems:
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri(jwkSetUri)
.build();
decoder.setClaimSetConverter(new JwtTimestampValidator(
Duration.ofSeconds(60) // Allow 60s clock skew
));
return decoder;
}---
Issue: CSRF Token Not Being Sent with SPA
Symptom: POST/PUT/DELETE requests fail with 403 Forbidden
Cause: CSRF token not accessible to JavaScript or not sent in request
Solution:
1. Use cookie-based CSRF for SPAs:
.csrf(csrf -> csrf.csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse() // JS can read
))2. Send token in request header (JavaScript):
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('XSRF-TOKEN='))
?.split('=')[1];
fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': csrfToken
},
body: JSON.stringify(data)
});3. For pure stateless APIs with JWT:
.csrf(csrf -> csrf.disable()) // Only with JWT, never with sessions---
Issue: @PreAuthorize Not Working
Symptom: Method executes without authorization check
Cause: Method security not enabled or wrong annotation
Solution:
1. Enable method security:
@Configuration
@EnableMethodSecurity(prePostEnabled = true) // Required!
public class SecurityConfig {}2. Check annotation placement:
// Wrong - on interface method (may not work)
public interface OrderService {
@PreAuthorize("hasRole('ADMIN')")
void deleteOrder(Long id);
}
// Correct - on implementation class
@Service
public class OrderServiceImpl implements OrderService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(Long id) { ... }
}3. Verify SpEL expression syntax:
// Wrong - role without ROLE_ prefix in hasAuthority
@PreAuthorize("hasAuthority('ADMIN')") // Needs 'ROLE_ADMIN'
// Correct options:
@PreAuthorize("hasRole('ADMIN')") // Auto-adds ROLE_ prefix
@PreAuthorize("hasAuthority('ROLE_ADMIN')") // Explicit prefix---
Issue: requestMatchers Order Causing Unexpected Access
Symptom: More restrictive rule ignored, everyone can access endpoint
Cause: More general pattern matched before specific pattern
Solution:
// Wrong - /api/** matches first, /api/admin never checked
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Never reached!
);
// Correct - specific patterns before general
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN") // Checked first
.requestMatchers("/api/**").authenticated()
);---
Issue: CORS Preflight Requests Failing
Symptom: OPTIONS requests return 401/403 before actual request
Cause: Security filter blocking preflight requests
Solution:
Configure CORS in security filter chain:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// ... rest of config
return http.build();
}Not separately with @CrossOrigin or WebMvcConfigurer.
---
Spring Boot 4 / Security 7 Migration Issues
WebSecurityConfigurerAdapter Removal
// Before - DOES NOT COMPILE in Security 7
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) { ... }
}
// After - use SecurityFilterChain bean
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// ... configuration
return http.build();
}
}Method Annotation Changes
// Before
@EnableGlobalMethodSecurity(prePostEnabled = true)
// After
@EnableMethodSecurity(prePostEnabled = true)Request Matcher Changes
// Before
.antMatchers("/api/**")
.mvcMatchers("/api/**")
.regexMatchers("/api/.*")
// After (all unified)
.requestMatchers("/api/**")
.requestMatchers(new AntPathRequestMatcher("/api/**"))
.requestMatchers(new RegexRequestMatcher("/api/.*", null))Import Changes
// Security 7 imports
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.Customizer;Spring Security 7 Configuration Workflow
Detailed step-by-step process for implementing security with Spring Security 7 and Spring Boot 4.
---
Step 1: Create SecurityFilterChain Bean
Define the security configuration using the mandatory Lambda DSL.
1a. Basic Configuration Structure
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// Define rules here
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
}1b. Lambda DSL (Mandatory in Security 7)
| Old Pattern (Removed) | New Pattern (Required) |
|---|---|
http.authorizeRequests().and()... | http.authorizeHttpRequests(auth -> auth...) |
http.formLogin().and().csrf() | http.formLogin(form -> form...).csrf(csrf -> csrf...) |
.antMatchers("/api/**") | .requestMatchers("/api/**") |
1c. Multiple Filter Chains
For different security requirements on different paths:
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}Output: SecurityFilterChain bean configured with Lambda DSL.
---
Step 2: Define Authorization Rules
Configure which endpoints require which access levels.
2a. Request Matcher Rules
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/orders").hasAuthority("SCOPE_write")
.requestMatchers("/api/**").authenticated()
.anyRequest().authenticated()
)2b. Ordering Rules
1. Most specific first — /api/admin/** before /api/** 2. Permit rules before restrict — Public endpoints first 3. Always end with default — .anyRequest().authenticated() as catch-all
2c. Common Patterns
| Pattern | Use Case |
|---|---|
permitAll() | Public endpoints (health, login, static resources) |
authenticated() | Any logged-in user |
hasRole("ADMIN") | Specific role (adds ROLE_ prefix automatically) |
hasAuthority("SCOPE_write") | Specific authority (no prefix) |
denyAll() | Block endpoint entirely |
Output: Authorization rules matching your endpoint security requirements.
---
Step 3: Configure Authentication
Set up the authentication mechanism.
3a. Password Encoding (Security 7)
@Bean
public PasswordEncoder passwordEncoder() {
return Argon2PasswordEncoder.defaultsForSpring7();
}Argon2 is the recommended encoder for Spring Security 7 (replaces BCrypt as default).
3b. UserDetailsService
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}3c. OAuth2/JWT Resource Server
http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthConverter())
)
);See references/JWT-OAUTH2.md for complete JWT configuration.
Output: Authentication mechanism configured with password encoding.
---
Step 4: Add Method Security
Enable fine-grained access control on individual methods.
4a. Enable Method Security
@Configuration
@EnableMethodSecurity // Replaces @EnableGlobalMethodSecurity
public class MethodSecurityConfig {
}4b. @PreAuthorize with SpEL
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN') or #order.customerId == authentication.principal.id")
public void cancelOrder(Order order) { ... }
@PreAuthorize("hasAuthority('SCOPE_read')")
public List<Order> listOrders() { ... }
}4c. @PostAuthorize for Response Filtering
@PostAuthorize("returnObject.customerId == authentication.principal.id")
public Order getOrder(Long id) { ... }4d. Common SpEL Expressions
| Expression | Checks |
|---|---|
hasRole('ADMIN') | User has ROLE_ADMIN authority |
hasAuthority('SCOPE_write') | User has exact authority |
#id == authentication.principal.id | Method param matches authenticated user |
returnObject.owner == authentication.name | Return value belongs to caller |
isAuthenticated() | User is authenticated |
Output: Method-level security with SpEL expressions.
---
Step 5: Handle CORS and CSRF
Configure cross-origin and cross-site request forgery protection.
5a. CORS for REST APIs
http.cors(cors -> cors
.configurationSource(request -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
return config;
})
);5b. CSRF Configuration
| Scenario | CSRF Setting |
|---|---|
| Session-based auth (forms) | Keep enabled (default) |
| Stateless JWT API | Disable: csrf(csrf -> csrf.disable()) |
| SPA with session | Use cookie-based: CookieCsrfTokenRepository.withHttpOnlyFalse() |
// For SPAs
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
);5c. Headers Security
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'")
)
.frameOptions(frame -> frame.deny())
.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.maxAgeInSeconds(31536000)
)
);Output: CORS and CSRF configured for your application type.
---
Verification Checklist
After implementing security:
- [ ] No
and()chaining — Lambda DSL only - [ ] No
antMatchers()— UserequestMatchers() - [ ] No
authorizeRequests()— UseauthorizeHttpRequests() - [ ] Password encoder is Argon2 (Security 7 default)
- [ ] Most specific request matchers before general ones
- [ ]
anyRequest().authenticated()as catch-all - [ ] CSRF enabled for session-based auth
- [ ] Method security uses
@EnableMethodSecurity(not@EnableGlobalMethodSecurity) - [ ] Security tested with
@WithMockUser— seespring-boot-testingskill