Security the Practical Way¶
Spring Security is famously “powerful but confusing.” Ninety percent of that confusion came from WebSecurityConfigurerAdapter — an abstract class you extended, overrode multiple configure methods on, and prayed the ordering was right. It was deprecated in Spring Security 5.7 and removed in 6.x. In 2026 you configure security with beans, one SecurityFilterChain per authentication style, and it finally feels like Spring.
This file is the minimum-viable-security you need to (a) protect a REST API with JWT, (b) not embarrass yourself in a security-focused study, and (c) avoid the common mistakes that turn Spring Security into a puzzle.
1. The New Configuration Style¶
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // enables @PreAuthorize, @PostAuthorize
public class SecurityConfig {
@Bean
SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable) // stateless JWT: CSRF is not applicable
.cors(Customizer.withDefaults()) // uses the CorsConfigurationSource bean below
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/urls/*").permitAll() // public redirects
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.exceptionHandling(e -> e
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler()))
.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource(
@Value("${app.cors.allowed-origins}") List<String> origins) {
var cfg = new CorsConfiguration();
cfg.setAllowedOrigins(origins); // e.g. https://myapp.com
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(Duration.ofHours(1));
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
}
Read this carefully. It’s the whole security config for a modern stateless REST API. Anything else — login pages, form auth, CSRF cookies — is a different world (server-side rendering) and should live in its own SecurityFilterChain bean with @Order set.
2. JWT vs Session: The Choice That Actually Matters¶
Aspect |
Session-based |
JWT (stateless) |
|---|---|---|
Server state |
Session store required (Redis, DB) |
None |
Logout |
Delete session server-side, instant |
Impossible until token expires (mitigate with short TTL + refresh + optional blocklist) |
Revocation |
Server owns it |
Not native — add a token-id blocklist in Redis |
Payload |
Just a session ID |
Contains claims (user id, roles) — no lookup needed |
CSRF |
Vulnerable, need CSRF tokens |
Not applicable if using |
Fit |
Server-rendered apps, banking, admin panels |
SPAs, mobile apps, microservices |
Rule of thumb: if your app is a SPA (React/Vue) or mobile, use JWT. If it’s Thymeleaf/JSP with browser sessions, use sessions. Do NOT mix (“stateless JWT with CSRF cookies” is the classic anti-pattern).
3. JWT the Right Way¶
Do not hand-roll JWT signing and validation. Use OAuth2 Resource Server (built into Spring Security) with a JWK Set URI from an identity provider (Keycloak, Auth0, Cognito, Okta), OR sign locally with a keypair if the service issues and consumes its own tokens.
Self-issued JWT (small service, no external IdP)¶
# application.yml
app:
jwt:
issuer: https://shortener.example.com
private-key: classpath:jwt-private.pem
public-key: classpath:jwt-public.pem
access-token-ttl: 15m
refresh-token-ttl: 30d
@Bean
JwtDecoder jwtDecoder(@Value("${app.jwt.public-key}") RSAPublicKey publicKey) {
return NimbusJwtDecoder.withPublicKey(publicKey).build();
}
@Bean
JwtEncoder jwtEncoder(@Value("${app.jwt.public-key}") RSAPublicKey publicKey,
@Value("${app.jwt.private-key}") RSAPrivateKey privateKey) {
var jwk = new RSAKey.Builder(publicKey).privateKey(privateKey).build();
return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk)));
}
Third-party IdP (Keycloak, Auth0, etc.)¶
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/myapp
# Spring auto-discovers /.well-known/openid-configuration for JWK Set URI
That’s the entire config. Spring validates the signature via JWKs fetched from the issuer, checks iss, exp, nbf, and populates the security context.
Extracting claims¶
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"userId", jwt.getSubject(),
"email", jwt.getClaim("email"),
"roles", jwt.getClaimAsStringList("roles")
);
}
Map JWT claims to Spring authorities with a JwtAuthenticationConverter. Default expects a scope claim; enterprise setups use roles or realm_access.roles (Keycloak).
5. Password Storage (When You Own Authentication)¶
If your service issues its own credentials (rare in enterprise — usually delegated to IdP), use BCrypt or Argon2:
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // 12 rounds ~250ms on modern hardware. Tune upward every few years.
}
Never:
Store plaintext (obvious).
Use MD5 or SHA-256 as a “hash” — they’re not password hashes, they’re too fast.
Use
NoOpPasswordEncoderoutside toy demos — it does exist and it does what you fear.
Use DelegatingPasswordEncoder (Spring Security’s default since 5.0) so you can upgrade algorithms without breaking existing users.
6. CSRF for Stateful Apps¶
If you use sessions with browser clients, CSRF protection is mandatory. Spring Security enables it by default. For SPAs, use the double-submit cookie pattern:
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
For pure JSON/JWT APIs with no cookies, disable CSRF (as in the sample config above). Do not enable both a CSRF filter and Bearer-only auth — they don’t cooperate, and you’ll spend a day chasing 403s.
7. CORS: The Right Way¶
Don’t .setAllowedOrigins(List.of("*")) with .setAllowCredentials(true) — browsers reject this. If you need credentials, list origins explicitly.
CORS is a browser enforcement. It does not protect your API from curl, mobile apps, or servers. Use it for browser access control, not as a security boundary.
8. Rate Limiting¶
Not in Spring Security, but adjacent. Options:
Bucket4j — in-JVM token bucket. Fine for a single instance; not distributed.
Redis-backed rate limiter (Bucket4j Redis adapter or a small custom filter) — works across instances.
API gateway (Spring Cloud Gateway, Kong, Envoy) — the honest answer for microservices. See ../08_distributed_systems_applied_integration/01_microservices_patterns.md.
Add rate limiting on /api/v1/auth/** (login/refresh) before anything else. Brute force login is the top-two attack against every public API.
9. Common Security Sins¶
Sin |
Consequence |
|---|---|
Trusting user input in queries |
SQL injection. Use parameterized queries always. |
|
Public endpoints leak. Prefer explicit paths. |
Long-lived JWTs (24h+) with no revocation |
A stolen token owns the account for a day. |
|
|
Logging JWTs / passwords / PII |
Leaks in log aggregators. Filter these in Logback. |
No security headers |
Missing |
Verbose error responses |
Attackers enumerate “user exists” vs “password wrong.” Return same generic 401. |
Bumping BCrypt cost to 20 “for safety” |
1s per login destroys throughput. Measure, don’t guess. |
10. A Test Every Auth Setup Needs¶
@SpringBootTest
@AutoConfigureMockMvc
class AuthorizationTest {
@Autowired MockMvc mvc;
@Test
void anonymous_gets_401_on_protected_endpoint() throws Exception {
mvc.perform(get("/api/v1/urls"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void user_can_list_own_urls() throws Exception {
mvc.perform(get("/api/v1/urls"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void user_cannot_reach_admin_endpoint() throws Exception {
mvc.perform(get("/api/v1/admin/users"))
.andExpect(status().isForbidden());
}
}
@WithMockUser is the shortcut. For JWT-specific tests, SecurityMockMvcRequestPostProcessors.jwt() lets you inject fake claims without a real token.
Practice Exercises¶
Configure a
SecurityFilterChainthat permits/actuator/healthand/api/v1/auth/**, requires auth for everything else, and requiresADMINfor/api/v1/admin/**.Wire self-issued JWT signing with an RSA keypair. Issue a token for a fake user, decode it in a downstream request.
Add
@PreAuthorizeon a service method with a SpEL expression referencing a method argument.Write three tests: anonymous 401, user 200 on own resource, user 403 on another user’s resource.
Stretch: integrate Keycloak in Docker locally, configure your app as a resource server against it, and get a real token via the client-credentials flow.
Return to README.md · Previous: 04_testing_the_full_stack.md · Next: 06_observability_metrics_logs_traces.md