Security patterns: OAuth2/OIDC, JWT validation, RBAC/ABAC, and token propagation
Chapter 4’s gateway configuration included a minimal OAuth2 resource-server snippet with an explicit warning: “the full setup… is covered in depth in this series’ security chapter.” This is that chapter — implementing real Keycloak-backed authentication, deciding what identity information propagates to inventory-service and payment-service, and adding the authorization layer this series has assumed but never built.
1. Problem the Pattern Solves
Northwind’s gateway (Chapter 4) validates that an incoming request carries a valid JWT — but nothing yet checks what that JWT’s holder is actually allowed to do. A customer’s own valid token, if replayed against the wrong endpoint, could currently call payment-service’s administrative refund-issuance endpoint, since no authorization check exists downstream of “is this token valid” at the gateway.
A second, subtler problem: when order-service calls inventory-service (Chapter 2) or the legacy ambassador (Chapter 15), what identity does that call carry? If it forwards the customer’s own token unchanged, inventory-service sees “customer X is calling me directly,” which is misleading — it’s actually order-service acting on behalf of customer X, a distinction that matters for both authorization (does order-service have the right to make this specific call regardless of which customer triggered it) and for audit trails (which service actually made this call, versus which user’s action ultimately caused it).
Forces in tension:
- Authentication vs. authorization. Verifying a token is valid and unexpired (authentication) says nothing about whether its holder is permitted to perform the specific action being attempted (authorization) — conflating the two, as Northwind’s current gateway-only check does, leaves every valid customer able to attempt every endpoint.
- Token propagation fidelity vs. service-to-service identity. Forwarding the customer’s exact token to every downstream call preserves the most information but conflates “the user this request is for” with “the service actually making this call” — a distinction that matters for least-privilege authorization at each hop.
- Coarse-grained (RBAC) vs. fine-grained (ABAC) authorization. Role-based rules (“admins can issue refunds”) are simple to reason about and audit but can’t express rules that depend on request-specific context (“a customer-service rep can issue a refund only for orders under $100 without manager approval”) — that requires attribute-based policy, at real added complexity.
- Zero-trust at the mesh layer vs. application-level authorization. Chapter 21’s mesh-level
AuthorizationPolicyverifies which service is calling which service — a coarse, service-identity-level check. It says nothing about which user, propagated through that service-to-service call, is allowed to perform the specific action — the two layers are complementary, not substitutes, and conflating them leaves a real gap.
2. Core Idea
OAuth2 / OIDC: Northwind’s gateway (Chapter 4) already acts as an OAuth2 resource server, validating JWTs issued by Keycloak (the identity provider) on every incoming request — this chapter completes that setup with real Keycloak configuration and adds the OIDC layer providing verified identity claims (who the user actually is), not just an opaque valid token.
JWT validation: verifying a token’s signature (against the identity provider’s published public keys), expiration, issuer, and audience — done once, at the gateway, so downstream services don’t each need their own connection to Keycloak, but propagated in a form downstream services can still inspect for authorization decisions.
RBAC (role-based access control): authorization decisions based on a user’s assigned role(s) — simple, auditable, and sufficient for coarse distinctions like “only staff can access the admin API.”
ABAC (attribute-based access control): authorization decisions based on richer context — the requesting user’s attributes, the specific resource being accessed, and situational factors (request amount, time of day) — needed for Northwind’s “reps can refund under $100 without approval” rule, which no fixed role alone can express.
Token propagation: the deliberate decision about what identity information flows to each downstream service call — this chapter uses token exchange (RFC 8693), converting the customer’s original token into a new, narrower-scoped, service-identity-carrying token for each internal hop, rather than blindly forwarding the original.
Commonly confused with:
- Chapter 21’s mesh-level zero trust. The mesh verifies service identity (is this really
order-servicecalling, per its mTLS certificate) — a network-layer, service-to-service check. This chapter’s authorization checks user identity and permissions, carried inside the token, which the mesh’s mTLS layer has no visibility into at all. A request can pass the mesh’sAuthorizationPolicy(a legitimate service is calling) while still failing this chapter’s authorization check (that service, acting for this specific user, isn’t allowed to do this specific thing). - API keys. An API key typically identifies a calling application or partner, often with a long-lived, coarse-grained credential — OAuth2/OIDC’s JWTs identify a user (or a service acting on a user’s behalf, via token exchange), are short-lived, and carry richer, verifiable claims. Northwind’s partner integrations (Chapter 4’s future partner BFF) might reasonably use API keys for partner-level authentication in addition to this chapter’s user-level OAuth2 flow — different problems, sometimes combined.
- Session-based authentication. A traditional server-side session (a cookie referencing session state stored somewhere) requires the storing service to be consulted on every request — OAuth2’s JWTs are self-contained and independently verifiable by any service holding the issuer’s public key, a better fit for a distributed system where session-store consultation on every hop would reintroduce a synchronous coupling point.
3. When to Use It
Strong indicators:
- Multiple client types (Chapter 4’s web and mobile BFFs) needing to authenticate users against one identity provider, rather than each service implementing its own login and credential storage.
- A real distinction exists between what a user is authenticated to do broadly and what they’re authorized to do for a specific action — Northwind’s refund-approval-threshold rule is exactly this.
- Service-to-service calls need to carry “on behalf of which user” information for downstream authorization or audit purposes, without simply forwarding a raw, unscoped user token everywhere.
Concrete use cases:
- E-commerce, as here: customer-facing authentication via OIDC, staff/admin RBAC for internal tools, and ABAC for context-dependent business rules like refund thresholds.
- Healthcare: RBAC alone (“doctors can view patient records”) is often insufficient — ABAC is frequently required for rules like “a doctor can view a patient’s record only if they’re part of that patient’s current care team,” a genuinely contextual, not just role-based, decision.
- Financial services: transaction-approval workflows almost always need ABAC-style rules (amount thresholds, account ownership, time-based restrictions) layered on top of basic role checks.
- Multi-tenant SaaS (the next chapter builds on this directly): authorization must account for tenant boundaries as a first-class attribute, not just user roles — a role alone (“admin”) is meaningless without also checking which tenant’s admin.
Prerequisites:
- An identity provider (Keycloak, self-hosted, as this chapter uses; or a managed alternative) capable of issuing and validating OIDC tokens, with realistic role and claim modeling already thought through.
- A clear inventory of which endpoints need which authorization model — RBAC where a role suffices, ABAC only where genuine context-dependent rules exist (Section 4’s overengineering warning applies directly to defaulting to ABAC everywhere).
- A token-propagation strategy decided deliberately (Section 5) rather than defaulting to “just forward the original token everywhere,” which Section 1 already showed has real downsides.
4. When Not to Use It
- Internal, non-user-facing service-to-service calls with no user context to propagate. A purely internal batch job or a service-to-service call that genuinely has no “acting on behalf of a user” context doesn’t need OIDC’s user-identity machinery — Chapter 21’s mesh-level mTLS and
AuthorizationPolicyalready cover service-identity authorization for these cases. - ABAC for every authorization decision, including simple ones. Implementing a full attribute-based policy engine for “only admins can access this admin endpoint” — a case RBAC handles perfectly and more simply — is unnecessary complexity for a decision that doesn’t actually depend on contextual attributes.
- Overengineering signal: building a custom, in-house token-exchange and policy-evaluation system when a well-established identity provider (Keycloak) and a standard policy-evaluation approach already solve the problem — this is exactly the kind of foundational security infrastructure where reinventing a well-tested wheel carries outsized risk relative to the effort saved.
- Risk: treating authentication (Chapter 4’s original gateway check) as if it were sufficient authorization, and never building the layer this chapter adds — Section 1’s refund-endpoint gap is exactly what results, and it’s a security gap, not a missing convenience feature.
5. Implementation Example
Keycloak realm configuration (conceptual, not exhaustive), defining Northwind’s roles:
{ "roles": { "realm": [ { "name": "customer" }, { "name": "customer-service-rep" }, { "name": "customer-service-manager" }, { "name": "admin" } ] }}JWT validation at the gateway, completing Chapter 4’s deferred configuration:
spring: security: oauth2: resourceserver: jwt: issuer-uri: https://keycloak.northwind.internal/realms/northwindThis one property is enough for Spring Security to fetch Keycloak’s public keys, validate every incoming JWT’s signature, expiration, and issuer automatically — the mechanical part of “JWT validation” that Chapter 4 deferred, now fully specified.
RBAC, for the straightforward admin-endpoint case:
package `in`.o612.eng.northwind.payment.api
import org.springframework.security.access.prepost.PreAuthorizeimport org.springframework.web.bind.annotation.*import java.util.UUID
@RestController@RequestMapping("/api/v1/refunds")class RefundController(private val refundService: RefundService) {
@PostMapping @PreAuthorize("hasRole('customer-service-rep') or hasRole('customer-service-manager') or hasRole('admin')") fun issueRefund(@RequestBody request: RefundRequest, authentication: org.springframework.security.core.Authentication): RefundResponse = refundService.issueRefund(request, requestedBy = authentication.name)}ABAC, for the genuinely contextual rule RBAC alone can’t express — a rep can approve small refunds unilaterally, but a larger one needs a manager, checked against the specific request’s amount, not just the caller’s role:
package `in`.o612.eng.northwind.payment.internal
import org.springframework.security.core.Authenticationimport org.springframework.stereotype.Componentimport java.math.BigDecimal
@Componentclass RefundAuthorizationPolicy { private val repApprovalLimit = BigDecimal("100.00")
fun canApprove(authentication: Authentication, refundAmount: BigDecimal): AuthorizationDecision { val roles = authentication.authorities.map { it.authority } return when { roles.contains("ROLE_admin") -> AuthorizationDecision.Allowed roles.contains("ROLE_customer-service-manager") -> AuthorizationDecision.Allowed roles.contains("ROLE_customer-service-rep") && refundAmount <= repApprovalLimit -> AuthorizationDecision.Allowed roles.contains("ROLE_customer-service-rep") -> AuthorizationDecision.Denied("Refund exceeds rep approval limit of $repApprovalLimit — requires manager") else -> AuthorizationDecision.Denied("Insufficient role") } }}
sealed interface AuthorizationDecision { data object Allowed : AuthorizationDecision data class Denied(val reason: String) : AuthorizationDecision}@PostMappingfun issueRefund(@RequestBody request: RefundRequest, authentication: Authentication): ResponseEntity<*> { val decision = refundAuthorizationPolicy.canApprove(authentication, request.amount) return when (decision) { is AuthorizationDecision.Denied -> ResponseEntity.status(403).body(mapOf("error" to decision.reason)) AuthorizationDecision.Allowed -> ResponseEntity.ok(refundService.issueRefund(request, requestedBy = authentication.name)) }}@PreAuthorize (RBAC) is left in place as the coarse first gate — you must have some customer-service role at all to reach this endpoint — with RefundAuthorizationPolicy (ABAC) providing the finer-grained, amount-dependent decision underneath. Layering the two, rather than choosing one exclusively, matches each check to the granularity it’s actually good at.
Token exchange for service-to-service calls, replacing naive raw-token forwarding — the direct answer to Section 1’s second problem:
package `in`.o612.eng.northwind.order.internal
import org.springframework.web.client.RestClientimport java.util.UUID
/** Exchanges the incoming user token for a new, narrower token identifying * order-service as the caller, acting on behalf of the original user, with * only the scope this specific downstream call needs. */class TokenExchangeClient(private val keycloakTokenEndpoint: RestClient) {
fun exchangeForInventoryServiceCall(originalToken: String): String = keycloakTokenEndpoint.post() .uri("/realms/northwind/protocol/openid-connect/token") .body(mapOf( "grant_type" to "urn:ietf:params:oauth:grant-type:token-exchange", "subject_token" to originalToken, "requested_token_type" to "urn:ietf:params:oauth:token-type:access_token", "audience" to "inventory-service", "scope" to "reservation:write", // narrower than whatever scope the original token carried )) .retrieve().body(TokenExchangeResponse::class.java)?.accessToken ?: error("Token exchange failed")}
data class TokenExchangeResponse(val accessToken: String)fun reserveStock(orderId: UUID, items: List<ReservationItemDto>, originalToken: String): ReservationOutcome { val exchangedToken = tokenExchangeClient.exchangeForInventoryServiceCall(originalToken) return restClient.post().uri("/api/v1/reservations") .header("Authorization", "Bearer $exchangedToken") .body(ReservationRequestDto(orderId, items)) .exchange { _, response -> /* ... unchanged from Chapter 2 */ }}inventory-service now receives a token that says “this is order-service, acting on behalf of customer X, scoped only to reservation:write” — narrower and more honest than forwarding the customer’s original, potentially broadly-scoped token unchanged, and it gives inventory-service exactly the information it needs for both authorization (does this scope permit this action) and audit logging (which user’s action ultimately triggered this call).
6. Step-by-Step Flow
- Client action. A customer-service rep attempts to issue a $150 refund — above their unilateral approval limit.
- API request. The gateway validates the JWT’s cryptographic validity and forwards the request with its verified claims intact.
- Service behavior.
payment-service’s@PreAuthorize(RBAC) passes — the rep does have a valid customer-service role, clearing the coarse first gate. - Database interaction. Not yet reached — the ABAC policy check happens before any refund is actually processed or persisted.
- Inter-service communication. None needed for this specific flow — refund issuance is self-contained within
payment-service. - Error or failure handling.
RefundAuthorizationPolicydenies the request with a specific, actionable reason (exceeds the rep’s approval limit) rather than a generic403— letting the client-facing UI tell the rep exactly what to do next (escalate to a manager) rather than leaving them guessing. - Observability signals. Track authorization denial rate and reason, broken down by policy rule — a rising rate of “exceeds approval limit” denials might prompt a business conversation about whether the threshold itself needs revisiting, a genuinely useful signal beyond pure security monitoring.
- Final response. The rep receives a clear
403with the specific reason, and — in a complete implementation — a path to escalate to a manager, whose higher role would pass the sameRefundAuthorizationPolicycheck for the identical request.
7. Production Concerns
- Timeouts, retries, idempotency. Token exchange (Section 5) adds a real network call to Keycloak on the hot path of every service-to-service call needing a scoped token — cache exchanged tokens for their (short) validity window rather than exchanging fresh on every single call, and apply Chapter 16’s resilience patterns to the exchange call itself, since Keycloak becoming unavailable shouldn’t silently break every downstream call across the platform.
- Data consistency. Not directly relevant — this pattern concerns identity and authorization, not data consistency.
- API versioning. Authorization rules (RBAC roles, ABAC policies) should be versioned and reviewed with the same rigor as any API contract — a change to
repApprovalLimit, for instance, is a business-rule change with real financial implications, not a casual configuration tweak. - Authentication, authorization, and service-to-service trust (this chapter’s core subject): the combination of Chapter 21’s mesh-level service identity verification and this chapter’s user-identity/scope verification is what constitutes genuine defense in depth — neither layer alone is sufficient, and both should be independently auditable.
- Logging, metrics, tracing, audit trails. Every authorization decision — allowed or denied, and why — should be logged with the acting user’s identity, the resource, and the specific policy rule that decided it; this is often a direct compliance requirement, not just good practice, and Section 6’s specific denial reason is exactly the kind of detail an audit log needs.
- Kubernetes deployment, secrets, configuration. Keycloak’s own admin credentials and the token-exchange client’s credentials need to be stored as Kubernetes
Secrets (Chapter 25) with tightly scoped access — Keycloak itself is now one of Northwind’s highest-value targets, since compromising it compromises the entire authentication and authorization system. - Testing strategy. Test RBAC and ABAC rules exhaustively and explicitly for every role/context combination that matters (as Section 5’s policy naturally suggests: admin, manager, rep-under-limit, rep-over-limit, no-role) — authorization logic is exactly the kind of code where an untested edge case becomes a real security incident, not just a bug.
- Migration strategy. Introduce RBAC first for any endpoint with no authorization at all (closing the most acute gaps, like Section 1’s refund endpoint), then layer ABAC only where a genuine, identified business rule requires context-dependent decisions — don’t build ABAC infrastructure speculatively ahead of an actual rule that needs it.
8. Common Mistakes
- Treating authentication as sufficient authorization. Assuming that because the gateway validates a JWT, every endpoint is protected, when in fact nothing checks what that valid token’s holder is actually permitted to do — exactly Section 1’s opening gap. Fix: add explicit authorization checks (RBAC, ABAC, or both as appropriate) at every endpoint, never relying on authentication alone.
- Forwarding the customer’s raw token unchanged to every downstream service. This conflates “the user this request is for” with “which service is actually calling,” and typically over-scopes what each downstream service can do with the token it receives. Fix: use token exchange to issue narrower, service-specific, appropriately-scoped tokens for each hop, as Section 5 demonstrates.
- Defaulting to ABAC for every authorization decision. Building a full attribute-based policy engine for simple role checks (
hasRole('admin')) adds unnecessary complexity where RBAC alone suffices. Fix: use RBAC for straightforward role-based gates, reserving ABAC specifically for decisions that genuinely depend on request context, as Section 5’s layered approach does. - No caching of exchanged tokens. Performing a full token-exchange round trip to Keycloak on every single service-to-service call, rather than caching the exchanged token for its validity window, adds unnecessary latency and load to the identity provider on every hot-path request. Fix: cache exchanged tokens appropriately, refreshing only as they approach expiration.
- Insufficient audit logging of authorization decisions. Logging only successful requests, with no record of denied attempts or which specific policy rule made each decision, leaves a real gap for security investigations and compliance audits. Fix: log every authorization decision, allowed or denied, with enough detail (as Section 7 describes) to reconstruct exactly why.
- Assuming mesh-level service authorization (Chapter 21) covers user-level authorization. Believing that because
order-serviceis permitted by the mesh’sAuthorizationPolicyto callinventory-service, the request is fully authorized, ignoring that the specific user on whose behalf the call is being made might not be permitted to perform this specific action. Fix: treat service-identity authorization (the mesh) and user-identity authorization (this chapter) as two independent, both-required layers, never one substituting for the other.
9. Decision Guide
| Problem signal | Use this pattern? | Why | Alternative |
|---|---|---|---|
| Endpoint has authentication but no authorization check | Yes (RBAC at minimum) | Authentication alone leaves every valid user able to attempt every action | — |
| A business rule genuinely depends on request-specific context (amount, ownership) | Yes (ABAC), layered on RBAC | RBAC alone can’t express context-dependent thresholds | — |
| Service-to-service call currently forwards the raw user token unchanged | Yes (token exchange) | Narrows scope and clarifies which service is actually the caller | — |
| Simple, context-independent role check (only admins can access this) | RBAC alone; no ABAC needed | ABAC’s added complexity has no corresponding benefit for a rule this simple | Plain @PreAuthorize role check |
| Purely internal service-to-service call with no user context to propagate | No (OIDC/token exchange) | Nothing to authorize at the user level; mesh-level service identity (Chapter 21) suffices | Mesh-level AuthorizationPolicy alone |
10. Hands-On Exercise
Extend it: implement an ABAC rule for order-service’s order-cancellation endpoint — a customer can cancel only their own orders, and only while the order is PENDING or AWAITING_PAYMENT (Chapter 9’s event-sourced status), while a customer-service rep can cancel any customer’s order in those same states. Design the policy check and justify why this needs ABAC rather than RBAC alone.
Simulate a failure: attempt to call payment-service’s refund endpoint using a customer’s own token (role: customer, not any staff role), and confirm the @PreAuthorize check rejects it before RefundAuthorizationPolicy is ever consulted — verifying the two layers correctly compose in the right order.
Decision question, with justification required: Northwind’s fraud-detection team wants read-only access to payment-service’s transaction history for their own analysis, but should never be able to issue or approve refunds. Design the role and/or scope structure that grants this access precisely, and justify why a coarse “give them the admin role, it’s easier” approach violates the principle of least privilege this chapter has assumed throughout.
11. Key Takeaways
- Authentication (is this token valid) and authorization (is this token’s holder allowed to do this specific thing) are distinct checks — Northwind’s gateway-only JWT validation from Chapter 4 provided the first without the second, leaving a real security gap.
- RBAC suits simple, context-independent role checks; ABAC is needed specifically for decisions that depend on request context (amounts, ownership, situational factors) that no fixed role can express — layer them rather than choosing one exclusively.
- Token exchange, rather than forwarding a raw user token unchanged to every downstream call, narrows each hop’s scope and clarifies which service is actually the caller acting on whose behalf — both a security improvement and an audit-trail improvement.
- Mesh-level service-identity authorization (Chapter 21) and this chapter’s user-identity authorization are independent, both-required layers of defense in depth — neither substitutes for the other.
- Log every authorization decision, allowed or denied, with enough detail to reconstruct why — this is frequently a direct compliance requirement, and it’s the data that makes a security investigation tractable rather than guesswork.
- Cache exchanged tokens for their validity window rather than performing a full exchange round trip on every call — an uncached exchange adds real, unnecessary latency and load to the identity provider on every hot-path request.
- Introduce authorization incrementally, closing the most acute gaps (endpoints with no authorization at all) first, and reserve ABAC’s added complexity for business rules that have genuinely been identified as context-dependent — not built speculatively ahead of an actual need.