← Back to the section

When a user sends a request, the application has to answer three different questions:

  1. Who is this, really? — is the token genuine, not expired, not forged?
  2. Can they reach this endpoint? — do they have the required role?
  3. Can they act on this specific resource? — is this their order, or someone else's?

These are three distinct questions, and each is asked at its own level. Mix them into a single place and you get either duplication with the risk of divergence, or holes: one endpoint checks everything, another checks nothing.

Three levels — three questions

LevelQuestionWhat it checks
Gateway / API edgeWho is this?JWT signature, expiry, issuer
BFF / Application LayerAre they allowed in here?User role (RBAC)
Domain ServiceAre they allowed to act on this object?Resource ownership (ABAC)

Gateway — who is knocking at the door

The first checkpoint is the Gateway (or the service itself, if there is no Gateway). Its job is to answer the question "who is this client?".

What the Gateway does:

  • Extracts the token from the Authorization: Bearer <jwt> header.
  • Validates the token signature against the JWK Set (the IdP's public keys).
  • Checks the expiry (exp), issuer (iss), and audience (aud).
  • Applies rate limiting.
  • Passes the identity downstream — through the same header, or via X-User-Id, X-User-Roles.

If the token is invalid, the request gets 401 Unauthorized, and no internal services are called.

User → POST /orders  +  Bearer <token>
                 ↓
              Gateway
                 ↓ (if the token is valid)
           order-service: knows that user-42 arrived

What the Gateway does not do: it does not know which endpoints exist or which roles they require. Still less does it know the business model — who owns order #12345.

In Spring Boot, the Gateway is Spring Cloud Gateway or Istio with a JWT filter. If there is no external Gateway, the service validates the token itself via oauth2ResourceServer in Spring Security — the behavior is the same.

BFF — do they have the right to enter this door

Suppose the token is valid. Now the second question: "can a user with this role reach this specific endpoint?".

This is called RBAC (Role-Based Access Control). In Spring it is done via @PreAuthorize right on the controller:

@RestController
@RequestMapping("/admin/orders")
public class AdminOrderController {

    @PostMapping("/{id}/refund")
    @PreAuthorize("hasRole('ADMIN')")
    public Order refund(@PathVariable Long id) {
        return dispatcher.dispatch(new RefundOrderCommand(id));
    }
}

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

    @GetMapping("/{id}")
    @PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
    public OrderResponse get(@PathVariable Long id) {
        return dispatcher.dispatch(new GetOrderByIdQuery(id));
    }
}

If the role does not match, Spring Security returns 403 Forbidden before the request ever reaches the business logic.

A typical split:

  • POST /admin/*ADMIN only.
  • GET /orders/*CUSTOMER or ADMIN.
  • POST /ordersCUSTOMER only (a customer creates their own orders).

What RBAC does not check: it does not know whose order #12345 actually is. That is not its job.

Domain Service — can they act on this specific object

The third question is the subtlest: "does this user have the right to read or change this particular resource?".

Every buyer has the CUSTOMER role. But a buyer must not see other people's orders. This cannot be checked by role — you have to load the object and compare its owner with the current user.

This approach is called ABAC (Attribute-Based Access Control). It lives inside the business-logic handler:

@UseCase
@RequiredArgsConstructor
public class GetOrderByIdHandler implements UseCaseHandler<GetOrderByIdQuery, Order> {

    private final OrderRepository orderRepository;

    @Override
    @Transactional(readOnly = true)
    public Order handle(GetOrderByIdQuery query) {
        var order = orderRepository.findById(query.orderId())
            .orElseThrow(() -> new OrderNotFoundException(query.orderId()));

        var currentUserId = SecurityContextHolder.getContext().getAuthentication().getName();
        if (!order.getCustomerId().equals(Long.valueOf(currentUserId)) && !hasAdminRole()) {
            throw new ForbiddenException("Order does not belong to current user");
        }
        return order;
    }
}

The logic: we loaded order #12345, it has customerId=42, the current user is sub=99 — denied. The CUSTOMER role is present, the endpoint is allowed, but this buyer is reading someone else's order.

For more on implementing ABAC, see the article ABAC: resource ownership.

Why ABAC cannot live on the Gateway

Sometimes there is a temptation to check everything on the Gateway — to "cut it off earlier". But with ABAC that does not work.

The problem: for the Gateway to answer "can user-99 read order 12345", it needs to know who owns the order. To do that it has to hit the database or call order-service. In other words, the Gateway effectively becomes yet another service that understands the business model.

What is wrong with that:

  • When the model changes (say, you add order co-owners), you have to update both order-service and the Gateway.
  • You end up with two sources of truth about who owns a resource.
  • The Gateway gets overloaded with logic that does not belong to it.

The rule: the Gateway does authentication only. ABAC belongs only inside the Domain Service, where the aggregate lives.

Common mistakes

Endpoint without @PreAuthorize. Forget the annotation and Spring Security lets the request through with any role. Every endpoint must explicitly declare who has access.

RBAC only, no ABAC for resource-oriented endpoints. GET /orders/{id} checks the role but not the owner — any CUSTOMER can read any order.

JWT validation inside the Handler. The business-logic handler must not parse the token by hand — that is the job of the OAuth2 Resource Server at the edge. The Handler receives already-extracted data from SecurityContextHolder.

Duplicating JWT validation at every layer. If the Gateway has already validated the token, there is no need to repeat it in every service. Trusting the propagated identity within a secured internal network is enough.

In short

  • Auth is three separate checks, not one: authentication, RBAC, ABAC.
  • The Gateway validates the JWT: signature, expiry, issuer. Invalid token → 401, the request goes no further.
  • The BFF / Controller checks the role via @PreAuthorize. No role → 403.
  • The Domain Service checks ownership of a specific resource. Someone else's object → 403.
  • ABAC cannot live on the Gateway: the Gateway does not know the domain model.
  • Every endpoint must carry an explicit RBAC annotation — without one the door is open to everyone.

Further reading

  • ABAC: resource ownership — how to implement an ownership check via an @access bean or inside the Handler.
  • JWT validation — the standard Spring Security flow for validating tokens.
  • RBAC: role mapping — how to configure @PreAuthorize and a catalog of roles.
  • Service-to-service — how services authenticate each other.