| 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