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 Authorization: Bearer header

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).


4. Method Security — @PreAuthorize in Practice

@Service
public class UrlService {

    @PreAuthorize("hasRole('USER')")
    public UrlResponse create(CreateUrlRequest req) { ... }

    @PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
    public List<UrlResponse> listFor(String username) { ... }

    @PreAuthorize("@urlOwnership.canDelete(#code, authentication)")
    public void delete(String code) { ... }
}
  • hasRole, hasAuthority, hasAnyRole — built-ins.

  • #paramName — references method arguments.

  • @beanName.method(...) — delegate to a bean for complex checks. Keeps SpEL expressions short.

  • @PostAuthorize — evaluates after method returns; useful with returnObject.ownerId == authentication.name.

Rule: put @PreAuthorize on service methods, not repositories, not controllers. Controllers are HTTP concerns; services are the business boundary.


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 NoOpPasswordEncoder outside 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:

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.

permitAll() too broadly

Public endpoints leak. Prefer explicit paths.

Long-lived JWTs (24h+) with no revocation

A stolen token owns the account for a day.

.hasRole("ADMIN") vs .hasAuthority("ADMIN") mix-up

hasRole auto-prefixes ROLE_; hasAuthority doesn’t. Bug for hours.

Logging JWTs / passwords / PII

Leaks in log aggregators. Filter these in Logback.

No security headers

Missing Strict-Transport-Security, X-Content-Type-Options. Add with http.headers(...).

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

  1. Configure a SecurityFilterChain that permits /actuator/health and /api/v1/auth/**, requires auth for everything else, and requires ADMIN for /api/v1/admin/**.

  2. Wire self-issued JWT signing with an RSA keypair. Issue a token for a fake user, decode it in a downstream request.

  3. Add @PreAuthorize on a service method with a SpEL expression referencing a method argument.

  4. Write three tests: anonymous 401, user 200 on own resource, user 403 on another user’s resource.

  5. 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