The gateway accepted the bearer token, so the request must be safe—right? Only until someone reaches the service directly, a route is misconfigured, or an internal caller asks for an action the identity is not allowed to perform.

The edge and the service answer different questions. A gateway proves identity and applies broad route policy; the service owns the data needed to decide whether that identity may cancel this order or read that tenant. Authenticate at the boundary; authorize where the business rule lives.

This post starts where Spring Cloud Gateway MVC stops. We will not rebuild its edge setup. Instead, we will make an order service a JWT resource server, define request rules with SecurityFilterChain, and protect business methods with @PreAuthorize.

Where service security fits

Use defense in depth without duplicating every rule:

Client
  |
  | Authorization: Bearer <JWT>
  v
Gateway
  |  authenticate identity, reject obviously invalid traffic,
  |  apply coarse route/scope policy
  v
Order service (OAuth2 resource server)
  |  verify JWT signature, issuer, expiry
  |  map scopes to authorities
  |  authorize order-specific business actions
  v
Database

The gateway can reject a caller without orders.read before consuming service capacity. It usually cannot decide whether user alice owns order 42, because that fact belongs to the order service.

Do not treat an internal network as an authorization boundary. Service discovery, ingress mistakes, compromised workloads, and maintenance ports all create paths that may bypass the gateway.

The three layers to configure

Spring Security service-side protection is easier to reason about as three layers:

  1. Token validation — the resource server verifies the JWT and creates an Authentication.
  2. HTTP authorization — SecurityFilterChain controls which request paths are public or authenticated.
  3. Business authorization — method security checks scopes, roles, ownership, tenant, or object state at the operation itself.

HTTP rules are useful for coarse policy. Method rules stay attached to the use case when a controller, message listener, scheduled job, or another method invokes it.

Lab: protect an order service

The lab uses Java 21 and a current Spring Boot 3.x release. It assumes an OpenID Connect or OAuth2 authorization server issues signed JWT access tokens with an iss claim and a space-separated scope claim.

Add the resource-server dependencies

Start with Spring MVC and Spring Security’s resource-server support:

<properties>
  <java.version>21</java.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>
</dependencies>

The resource-server starter supplies bearer-token processing and JWT support. Your service is not an OAuth2 login client: it receives access tokens and validates them.

Point the service at the issuer

Configure the issuer URL from the token’s iss claim:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://id.example.com/realms/shop

management:
  endpoints:
    web:
      exposure:
        include: health,info

At startup, Spring Security discovers the issuer metadata and JSON Web Key Set (JWKS). For each request it verifies the token signature and checks claims such as issuer and expiry. Signing-key rotation is then handled through the published JWKS.

Note: If startup must not depend on authorization-server discovery, configure jwk-set-uri as well as issuer-uri, or supply a JwtDecoder bean. Keep issuer validation enabled; a valid signature from the wrong issuer is still the wrong trust domain.

Build the HTTP filter chain

Enable method security and define one explicit request policy:

import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
class SecurityConfig {

  @Bean
  SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(authorize -> authorize
            .requestMatchers("/actuator/health", "/actuator/info").permitAll()
            .requestMatchers("/api/**").authenticated()
            .anyRequest().denyAll())
        .oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
        .build();
  }
}

This chain does four important things:

  • Leaves only the selected operational endpoints public.
  • Requires an authenticated bearer token for /api/**.
  • Denies forgotten endpoints by default.
  • Installs JWT bearer-token authentication for the service.

Spring Security’s default CSRF behavior is appropriate for browser sessions, but a stateless bearer-token API may disable CSRF explicitly:

import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;

// Add to the HttpSecurity chain only for a stateless bearer-token API:
.csrf(AbstractHttpConfigurer::disable)

Do not copy that line into an application that also authenticates with cookies. Browsers automatically attach cookies, which is the condition CSRF protection addresses. Token storage and transport—not “REST” in the abstract—determine whether disabling it is safe.

Understand scopes and authorities

By default, Spring Security maps JWT scopes to authorities with the SCOPE_ prefix:

JWT claim:  "scope": "orders.read orders.cancel"
Authorities:
  SCOPE_orders.read
  SCOPE_orders.cancel

That means hasAuthority('SCOPE_orders.cancel') checks an OAuth2 scope. It is not the same as hasRole('ADMIN'), which checks for an authority named ROLE_ADMIN.

Use scopes for what a client may call. Use domain roles, ownership, tenant membership, and resource state for what the business operation may do. If your provider stores permissions in a custom claim, configure a JwtAuthenticationConverter rather than scattering provider-specific claim parsing through controllers.

Put business authorization on the method

The controller should translate HTTP into a use-case call, not become the policy engine:

import java.security.Principal;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
class OrderController {

  private final OrderService orders;

  OrderController(OrderService orders) {
    this.orders = orders;
  }

  @DeleteMapping("/{orderId}")
  ResponseEntity<Void> cancel(@PathVariable long orderId, Principal principal) {
    orders.cancel(orderId, principal.getName());
    return ResponseEntity.noContent().build();
  }
}

The service first requires the OAuth2 scope, then delegates the ownership decision to a policy bean:

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class OrderService {

  private final OrderRepository orders;

  OrderService(OrderRepository orders) {
    this.orders = orders;
  }

  @PreAuthorize("""
      hasAuthority('SCOPE_orders.cancel')
      and @orderAuthorization.canCancel(#orderId, authentication)
      """)
  void cancel(long orderId, String requestedBy) {
    Order order = orders.findById(orderId)
        .orElseThrow(() -> new OrderNotFoundException(orderId));

    if (!order.isCancellable()) {
      throw new OrderStateException("Order can no longer be cancelled");
    }

    orders.markCancelled(orderId, requestedBy);
  }
}

The policy component can inspect the authenticated JWT and load domain data:

import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;

@Component("orderAuthorization")
class OrderAuthorization {

  private final OrderRepository orders;

  OrderAuthorization(OrderRepository orders) {
    this.orders = orders;
  }

  boolean canCancel(long orderId, Authentication authentication) {
    JwtAuthenticationToken token = (JwtAuthenticationToken) authentication;
    String subject = token.getToken().getSubject();
    String tenantId = token.getToken().getClaimAsString("tenant_id");

    return orders.existsByIdAndCustomerSubjectAndTenantId(
        orderId, subject, tenantId);
  }
}

This rule checks both tenant and owner rather than trusting an orderId supplied by the caller. The operation also checks cancellable state inside the method because business validity is not an identity concern.

Note: Spring applies method security through a proxy. Calls from one method to another on the same instance bypass that proxy. Put protected operations on a separate Spring bean, or call them through an injected collaborator; do not rely on self-invocation.

Choose method expressions deliberately

Common patterns are compact enough to keep near the operation:

@PreAuthorize("hasAuthority('SCOPE_orders.read')")
Order find(long orderId) { /* ... */ }

@PreAuthorize("hasRole('SUPPORT') and hasAuthority('SCOPE_orders.read')")
Order supportView(long orderId) { /* ... */ }

@PreAuthorize("@tenantAccess.canRead(#tenantId, authentication)")
List<Order> listForTenant(String tenantId) { /* ... */ }

Use a simple expression for a simple rule. When an expression starts encoding several queries or branches, move it into a named policy component. That gives the policy normal Java tests and makes the annotation read as intent.

@PostAuthorize can inspect returnObject, and @PostFilter can remove collection elements after a method returns. Both are useful occasionally, but filtering after loading data is often wasteful. Prefer tenant and ownership constraints in the repository query so unauthorized rows never leave the database.

Smoke-test the protected endpoint

Export a JWT access token issued for this API, then call the service directly:

export ACCESS_TOKEN='eyJ...'

curl -i -X DELETE \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  http://localhost:8080/api/orders/42

Expected outcomes distinguish authentication from authorization:

No token / invalid / expired token        -> 401 Unauthorized
Valid token, missing orders.cancel scope  -> 403 Forbidden
Scope present, wrong owner or tenant      -> 403 Forbidden
Allowed identity, order cannot cancel     -> domain response (for example 409)
Allowed identity and valid order state    -> 204 No Content

That distinction is operationally valuable. A wave of 401 responses points toward token issuance, expiry, or validation. A wave of 403 responses points toward policy, scopes, ownership, or tenant mapping.

Identity headers or JWT verification?

Some gateways validate the JWT and forward headers such as X-User-Id, X-Tenant-Id, and X-Scopes. This is fast and convenient, but a plain header is only a claim made by the immediate caller.

Prefer forwarding the original bearer token and verifying it at each service when:

  • Services can be reached through more than one network path.
  • Workloads from different trust zones share a cluster or network.
  • Auditing must prove issuer, audience, subject, and token expiry.
  • A compromised internal caller must not impersonate another user.

Accept gateway-generated identity headers only when all of these are true:

  • The gateway strips client-supplied copies before adding its own.
  • Direct access to the service is blocked by network policy or private ingress.
  • The service authenticates the gateway, ideally with mTLS or a signed internal token.
  • Header names, encoding, and trust rules are a versioned platform contract.

Even then, the service must still authorize the business action. X-User-Id: admin is not proof by itself.

For most teams, local JWT verification is the clearer default. Public-key verification does not call the authorization server on every request; keys are cached and refreshed from JWKS. If token exchange or audience isolation is required, issue a service-appropriate token rather than forwarding a broad edge token everywhere.

Production checks before you ship

  • Validate the expected issuer and audience, not only the signature and expiry.
  • Keep access tokens short-lived and let JWKS rotation work without redeploying.
  • Use anyRequest().denyAll() so a new controller is not public by accident.
  • Return 401 for failed authentication and 403 for authenticated callers denied by policy.
  • Avoid logging bearer tokens; log stable subject, client, tenant, and trace identifiers.
  • Test method security directly, including missing scope, wrong tenant, wrong owner, and allowed access.
  • Ensure background consumers and internal calls have an explicit service identity rather than a fabricated end-user header.

Audience validation is especially important when one issuer creates tokens for several APIs. Add an audience validator to your JwtDecoder when your provider and Spring Boot version do not configure it from properties. A token meant for the catalog API should not automatically unlock the order API.

Wrap-up

SecurityFilterChain protects the HTTP surface; the OAuth2 resource server turns a verified JWT into an authenticated principal; @PreAuthorize keeps authorization attached to the business operation. Together they let the gateway remain a thin front door while each service enforces the rules only it can know.

The practical default is simple: forward the bearer token, verify it again at the service, map scopes intentionally, and combine them with tenant or ownership checks. Trusted identity headers can work, but only inside an explicit authenticated trust boundary—not because the request arrived on an internal IP.

Next optional step Slice tests and Testcontainers without booting the whole app every time. Testing Slices + Testcontainers