| name | springboot-security |
| description | Use when configuring Spring Security, implementing JWT/OAuth2 auth, adding role-based access control, or hardening Spring Boot APIs. Do NOT use for general patterns (use springboot-patterns), JPA (use jpa-patterns), or testing (use springboot-tdd). |
| paths | **/*.java, **/build.gradle*, **/pom.xml, **/application*.yml, **/application*.properties |
Spring Boot Security Patterns
Spring Security 6+ with lambda DSL. Deny by default, validate inputs, least privilege.
When to Activate
- SecurityFilterChain 구성
- JWT / OAuth2 인증 구현
- @PreAuthorize 인가 규칙 추가
- CORS, CSRF, 보안 헤더 설정
- 비밀번호 인코딩, 시크릿 관리
- Rate limiting, 의존성 보안 스캔
CRITICAL Rules
- Deny by default — 명시적으로 허용한 경로만 접근 가능
- Never store plaintext passwords — BCrypt(12+) 또는 Argon2 사용
- Never hardcode secrets —
${ENV_VAR} 또는 Vault
- Never trust X-Forwarded-For directly — ForwardedHeaderFilter + trusted proxy 설정 필수
- Never concatenate SQL strings — parameterized query 또는 Spring Data 사용
- Never log tokens, passwords, PII — 구조화된 로깅에서 민감 필드 제외
SecurityFilterChain (Spring Security 6+)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtAuthFilter jwtAuthFilter) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.headers(headers -> headers
.contentSecurityPolicy(csp ->
csp.policyDirectives("default-src 'self'"))
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny))
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
JWT Authentication
Approach 1: OAuth2 Resource Server (PREFERRED)
외부 IdP(Keycloak, Auth0, Okta) 또는 자체 발급 JWT를 검증할 때 공식 권장 방식.
Spring Boot가 JwtDecoder를 자동 구성하므로 커스텀 필터가 불필요하다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults()))
.build();
}
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/myapp
Spring Boot가 자동으로 JWT 서명 검증, iss/exp/aud 클레임 검증, scope→authority 매핑을 처리한다.
Approach 2: Custom JWT Filter (자체 토큰 발급 시)
OAuth2 인프라 없이 직접 JWT를 발급/검증해야 할 때만 사용:
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
String token = header.substring(7);
String username = jwtService.extractUsername(token);
if (username != null
&& SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isValid(token, user)) {
var auth = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource()
.buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(request, response);
}
}
JWT Service
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration:PT1H}")
private Duration expiration;
public String generateToken(UserDetails user) {
return Jwts.builder()
.subject(user.getUsername())
.issuedAt(new Date())
.expiration(Date.from(Instant.now().plus(expiration)))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isValid(String token, UserDetails user) {
return extractUsername(token).equals(user.getUsername())
&& !isExpired(token);
}
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
}
}
JWT Role Mapping (Custom Claims)
IdP의 JWT에 커스텀 roles 클레임이 있을 때:
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthorityPrefix("ROLE_");
converter.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter authConverter = new JwtAuthenticationConverter();
authConverter.setJwtGrantedAuthoritiesConverter(converter);
return authConverter;
}
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())))
Method Security
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PreAuthorize("hasRole('ADMIN')")
@GetMapping
public List<OrderDto> listAll() { }
@PreAuthorize("@authz.isOwner(#id, authentication)")
@GetMapping("/{id}")
public OrderDto getById(@PathVariable Long id) { }
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) { }
}
@Component("authz")
public class AuthorizationService {
private final OrderRepository orderRepo;
public boolean isOwner(Long orderId, Authentication auth) {
return orderRepo.findById(orderId)
.map(order -> order.getUserId().equals(getUserId(auth)))
.orElse(false);
}
}
CORS Configuration
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
Rules:
- Production에서
* origin 금지
allowCredentials(true) 사용 시 specific origin 필수
- Security filter level에서 설정 (controller-level 아님)
CSRF Strategy
| App Type | CSRF | Reason |
|---|
| Stateless API (JWT/Bearer) | Disable | Token itself prevents CSRF |
| Session-based web app | Enable | Browser auto-sends cookies |
| Mixed (API + web) | Enable for web paths | Selective protection |
http.csrf(csrf -> csrf.disable());
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()));
Secrets Management
spring:
datasource:
password: mySecretPassword123
spring:
datasource:
password: ${DB_PASSWORD}
spring:
cloud:
vault:
uri: https://vault.example.com
token: ${VAULT_TOKEN}
kv:
backend: secret
default-context: myapp
Rate Limiting
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String clientIp = request.getRemoteAddr();
Bucket bucket = buckets.computeIfAbsent(clientIp, k ->
Bucket.builder()
.addLimit(Bandwidth.classic(100,
Refill.greedy(100, Duration.ofMinutes(1))))
.build());
if (bucket.tryConsume(1)) {
chain.doFilter(request, response);
} else {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(
"{\"type\":\"about:blank\",\"title\":\"Too Many Requests\",\"status\":429}");
}
}
}
Input Validation
@PostMapping("/users")
public User create(@RequestBody UserDto dto) { return userService.create(dto); }
public record CreateUserRequest(
@NotBlank @Size(max = 100) String name,
@NotBlank @Email String email,
@NotNull @Min(0) @Max(150) Integer age) {}
@PostMapping("/users")
public ResponseEntity<UserDto> create(@Valid @RequestBody CreateUserRequest req) {
return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(req));
}
SQL Injection Prevention
@Query(value = "SELECT * FROM users WHERE name = '" + name + "'", nativeQuery = true)
@Query(value = "SELECT * FROM users WHERE name = :name", nativeQuery = true)
List<User> findByName(@Param("name") String name);
List<User> findByEmailAndActiveTrue(String email);
HTTPS Enforcement
server:
port: 8443
ssl:
bundle: my-server
HTTP → HTTPS 리다이렉트:
http.requiresChannel(channel ->
channel.anyRequest().requiresSecure());
Security Checklist
Gotchas
- ❌
permitAll()을 requestMatchers() 전에 선언 → 순서 중요, 구체적 매칭 먼저
- ❌
@PreAuthorize에서 하드코딩된 role 문자열 → 상수 또는 enum 사용
- ❌ JWT 비밀키를 application.yml에 직접 작성 → 환경변수 필수
- ❌ CORS 설정에서
allowedOrigins("*") → 명시적 도메인 지정
References