← Back to the section

SPRING SECURITY · OAUTH2 Phase 15. REST security

Spring Security: filters, AuthN, AuthZ, OAuth2

Java · Spring Boot

Filter chains, JWT, method authorization, client credentials and access-rule tests.

1. SecurityFilterChain acts before the controller

1 / 5

Filters extract credentials, authenticate them and store Authentication in the SecurityContext. AuthorizationFilter then evaluates endpoint rules.

  • 401 means identity was not established.
  • 403 means identity is known but access is denied.
  • Order rules from specific to general.
Request BearerTokenFilterJwtDecoderAuthentication Authorization401 / 403 Controller

2. A resource server verifies JWT signature and audience

2 / 5

JWKS verifies the signature while issuer and expiration establish origin and lifetime. Audience proves the token was issued for this API.

  • Validate issuer, signature, exp and aud.
  • Local validation does not observe instant revocation.
  • Introspection adds freshness at network cost.
JWT claimsiss: keycloak/realmaud: orders-apiexp: 12:10 JwtDecodersignature + claimsJWKS public key AcceptedAuthentication

3. Authorization checks a specific operation

3 / 5

Endpoint rules protect routes, @PreAuthorize checks roles and attributes before invocation, and @PostAuthorize can inspect the returned resource.

  • hasRole adds ROLE_ automatically.
  • Scope and role are different claims.
  • Resource ownership belongs in the domain query.
Authentication @PreAuthorizehasRole('ADMIN')or order.owner == principal Allow / 403

4. A service call obtains its own access token

4 / 5

OAuth2 Client uses client credentials to request a token for the downstream API. Its audience and minimal scopes constrain authority.

  • Do not blindly forward the user token.
  • Cache the token until near expiration.
  • Store the client secret in a secret manager.
Orders API Keycloak Billing API access token: aud=billing-api, scope=invoice.write

5. Stateless REST avoids sessions and CSRF tokens

5 / 5

A bearer token is sent explicitly in a header rather than automatically by the browser. Cookie-based applications still need CSRF protection.

  • Test 200, 401 and 403 behavior.
  • Create test JWTs with the required claims.
  • Expose only minimal actuator endpoints.
MockMvc security tests No token401 ROLE_USER403 ROLE_ADMIN200

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

The main decision in such a configuration is how the service checks the token: by the signature locally or by asking the authentication server. The difference between the options shows up at exactly the moment a user's access is revoked.

the service handles 1200 requests/min; at 12:00 an employee's access is revoked 11:52JWT issued, exp = 12:0712:00access revoked in auth server12:03GET /api/v1/admin/reportsAuthorization: Bearer eyJhbGci...↓BearerTokenAuthenticationFiltersignature — key from JWKS cacheexp 12:07 not reached yet → valid↓AuthorizationFilter → ROLE_ADMIN200 OK — access already revoked 11:59JWT issued, exp = 12:0412:00access revoked in auth server12:03GET /api/v1/admin/reportssignature valid, exp still ahead200 OK — window still open12:04exp reached → 401client goes for a new tokenPOST /token (refresh) → auth↓the auth server already sees itrefresh rejected — access closed 11:52opaque string 8f3c... issued12:00access revoked in auth server12:03GET /api/v1/admin/reportsAuthorization: Bearer 8f3c...↓BearerTokenAuthenticationFilterPOST /introspect → auth serverresponse: {"active": false}↓never reached AuthorizationFilter401 — on the very first request JWT: the service asks no onethe signature is checked with a keyfrom the JWKS cache — no network callbut it cannot learn about the revoke:the token is accepted until exp 12:07window after the revoke: 7 minworst case: up to 15 mincalls from the service to auth: 0 TTL 5 min: the same, only shorterexp = 12:04, at 12:03 still allowed;at 12:04 a new token is neededrefresh goes to the auth server —which already sees the revoke → denywindow after the revoke: 4 minworst case: up to 5 mincalls from the service to auth: 0 opaque: we ask on every requestintrospection call to the auth serveron every request; network in latencythe {"active": false} response comesright after the revoke → 401window after the revoke: 0 minprice: 1200 calls per minutean introspection cache reopens it JWT: no questions asked outward — and no chance to learn about the revoke a short TTL does not close the window instantly — refresh does introspection closes the window at once — paying with a call every time validation option worst-case window auth calls per minute JWT, 15 min up to 15 min 0 JWT, 5 min + refreshup to 5 min0 opaque + introspection0 min1200

A revocation at 12:00 reaches the service in different ways. With JWT the service asks no one — and a revoked token is accepted until exp: seven minutes here, and with a TTL of 15 minutes all 15 in the worst case. A short TTL does not close the window, it only shortens it; what closes it is the refresh request that goes to the authentication server. Introspection closes the window at once — at the price of a network call in each of the 1200 requests per minute.

Basic architecture

Requests pass through the SecurityFilterChain:

HTTP request CorsFilter CsrfFilter BearerTokenAuthenticationFilter extracts the JWT, creates an Authentication ExceptionTranslationFilter catches refusals, turns them into 401 or 403 AuthorizationFilter checks access against the rules DispatcherServlet → @RestController

A request reaches the controller only after passing the whole chain: every filter does one job and hands the request on. Configuring Spring Security is exactly this choice — which filters stand in the chain and by which rules they work; the controller itself knows nothing about the access check.

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.