← Back to the section

Spring Security is one of the most complex parts of the ecosystem. The good news: for a standard REST service with OAuth2 tokens, the typical configuration is 20 lines. This article shows the skeleton and the extension points, without an encyclopedia of every feature.

Basic architecture

Requests pass through the SecurityFilterChain:

HTTP request
    ↓
[CorsFilter]
[CsrfFilter]
[BearerTokenAuthenticationFilter]   ← extracts the JWT, creates an Authentication
[AuthorizationFilter]                ← checks authorization against the rules
[FilterSecurityInterceptor]
    ↓
DispatcherServlet → @RestController

Each filter has a specific job. The configuration describes which filters run in which order + the rules for them.

A typical configuration for a REST service with JWT

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(csrf -> csrf.disable())                  // no cookies → no CSRF
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/public/**").permitAll()
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .oauth2ResourceServer(o -> o.jwt(jwt -> jwt
                .jwtAuthenticationConverter(jwtAuthConverter())))
            .build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthConverter() {
        var conv = new JwtAuthenticationConverter();
        var ga = new JwtGrantedAuthoritiesConverter();
        ga.setAuthoritiesClaimName("roles");
        ga.setAuthorityPrefix("ROLE_");
        conv.setJwtGrantedAuthoritiesConverter(ga);
        return conv;
    }
}
# From the JWT issuer-uri Spring itself verifies and downloads the JWKS to check the signature
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com/realms/marketplace

This is a ready-to-use configuration for a REST service with OAuth2 tokens:

  • Stateless: no sessions, no CSRF.
  • Resource Server: JWT is checked by signature, expiration, and issuer.
  • Authorities come from the roles claim → ROLE_ADMIN, ROLE_USER, and so on.
  • /api/v1/public/** — no authentication; /api/v1/admin/** — ADMIN only.

Authentication vs authorization

Authentication (AuthN)who are you? Spring checks credentials (JWT, Basic, OIDC token), creates an Authentication object, and puts it into the SecurityContext.

Authorization (AuthZ)what are you allowed to do? Spring checks the roles/permissions from Authentication against the configuration rules or @PreAuthorize.

Separating them is useful for debugging: 401 (Unauthorized) is an AuthN problem, 403 (Forbidden) is an AuthZ problem.

Source of Truth: JWT vs opaque token

JWT (JSON Web Token) — the token is self-contained: the claims inside are signed by the issuer, the service verifies the signature via the public key (JWKS) and trusts the claims. No requests to the auth server on every request.

Authorization: Bearer eyJhbGciOiJSUzI1NiIs.eyJzdWIiOi...
  • Pros: fast (no network call), scales well, works offline.
  • Cons: you cannot revoke a token before expiration. Solved by a short TTL (5-15 minutes) + a refresh token.

Opaque token — this is just an identifier string; the service makes an introspection request to the auth server for every request:

@Bean
public SecurityFilterChain api(HttpSecurity http) throws Exception {
    return http
        .oauth2ResourceServer(o -> o.opaqueToken(t -> t
            .introspectionUri("https://auth.example.com/introspect")
            .introspectionClientCredentials("client-id", "secret")))
        .build();
}
  • Pros: you can revoke a token immediately (the auth server returns active=false).
  • Cons: a network call on every request. Caching the introspection result helps partially.

In the UCP stack, the standard is JWT with a TTL of 5-15 minutes + a refresh token. Introspection is for critical operations where revocation matters more than latency.

Method security: @PreAuthorize, @PostAuthorize

SecurityFilterChain authorizes at the URL level. @PreAuthorize works at the method level:

@Service
public class OrderService {

    @PreAuthorize("hasRole('ADMIN') or #request.customerId == authentication.name")
    public OrderResponse createForCustomer(CreateOrderRequest request) { ... }

    @PostAuthorize("returnObject.customerId == authentication.name")
    public OrderResponse get(UUID id) {
        return orderRepo.findById(id);
    }
}

@PreAuthorize — a check before the method is called. @PostAuthorize — after, with access to the returned object (returnObject).

Activation:

@Configuration
@EnableMethodSecurity
public class SecurityConfig { ... }

Under the hood it is a Spring AOP proxy (see AOP). The same pitfalls: public only, Spring beans only, self-invocation does not work.

SecurityContext in code

@RestController
public class OrderController {

    @GetMapping("/me/orders")
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        UUID customerId = UUID.fromString(jwt.getSubject());
        return orderService.findByCustomer(customerId);
    }

    @GetMapping("/some")
    public ResponseEntity<?> some() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        // ...
    }
}

@AuthenticationPrincipal Jwt jwt — Spring injects the parsed JWT. Access through SecurityContextHolder also works, but only in the current thread. For reactive / async code there are separate mechanisms.

OAuth2 Client — obtaining a token

If service A calls service B with its own OAuth2 token (via the client credentials grant):

spring.security.oauth2.client.registration.pricing-service.client-id=orders
spring.security.oauth2.client.registration.pricing-service.client-secret=${PRICING_SECRET}
spring.security.oauth2.client.registration.pricing-service.authorization-grant-type=client_credentials
spring.security.oauth2.client.provider.pricing-service.token-uri=https://auth.example.com/realms/marketplace/protocol/openid-connect/token
@Component
@RequiredArgsConstructor
public class PricingClient {

    private final WebClient webClient;
    private final OAuth2AuthorizedClientManager clientManager;

    public Price quote(QuoteRequest request) {
        var authorizedClient = clientManager.authorize(
            OAuth2AuthorizeRequest.withClientRegistrationId("pricing-service")
                .principal("orders")
                .build());

        return webClient.post()
            .uri("https://pricing.internal/quote")
            .headers(h -> h.setBearerAuth(authorizedClient.getAccessToken().getTokenValue()))
            .bodyValue(request)
            .retrieve()
            .bodyToMono(Price.class)
            .block();
    }
}

Spring Security caches the token until expiration and re-requests it automatically.

CSRF — when you need it, when you don't

CSRF (Cross-Site Request Forgery) is an attack that exploits cookies. If the application uses cookies for authentication, you need a CSRF token.

  • REST service with a JWT in the Authorization header — CSRF is not needed (the attacker cannot read headers cross-domain).
  • SPA + cookies (session-based) — CSRF is needed, and Spring Security enables it by default.
  • Server-side rendered with form-based login — needed.

The rule in SecurityConfig:

.csrf(csrf -> csrf.disable())                              // stateless REST
.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))  // SPA

Session management

.sessionManagement(s -> s
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS))    // REST with JWT
  • STATELESS — Spring never creates an HttpSession.
  • IF_REQUIRED (default) — creates one if needed.
  • ALWAYS — always.
  • NEVER — do not create one, but use an existing one.

For REST services with JWT, use STATELESS. This lets you scale in a stateless way and removes session invalidation.

Common mistakes

Disabling security "temporarily for debugging". A year later it turns out it stayed that way in production.

A hardcoded permitAll() for a large set of endpoints. Usually requestMatchers("/api/v1/public/**").permitAll() is enough, with everything else locked down.

A JWT secret in the database or a property without encryption. It must live only in a secret manager.

Using SAML/SSO without single sign-out. After logout the user stays logged in to the IdP, and on the next request logs back in automatically.

  • Authorization patterns — an overview of OAuth2, OIDC, RBAC, ABAC, mTLS patterns.
  • Spring AOP — @PreAuthorize via AOP, and its limitations.
  • Spring MVC — filters in the overall request flow.
  • Auth Patterns Style Guide — authorization rules for AI review.