🌐 Language: English Version | 中文版
Context: This design assumes an OAuth2/OIDC-based IAM platform and an API gateway, such as APISIX, using the forward-auth plugin for centralized authorization. For the surrounding routing and browser-session design, see Microservice Gateway Routing and HMAC Cookie + Redis SID Dual-Layer Sessions.
TL;DR
This article focuses on one specific problem in a shared IAM platform: preventing a token issued for one application from being used against another application. The core mechanism is to declare the expected OAuth2 Client at the APISIX route level, pass it to forward-auth through X-Expected-Client-Id, and let the authorization service compare it with the client_id claim in the JWT.
This mechanism answers only one question: which client does this token belong to? It does not replace user admission policy, organization entitlement, session revocation, or application-level permissions. Application names, Client IDs, and route prefixes in this article are neutral examples and do not represent any real deployment.
1. The Problem: Token Overreach in a Shared IAM Platform
When multiple internal applications share one IAM platform, the naive model is straightforward: a user logs in, receives a JWT, and every application accepts that token. The problem is:
Can a token issued for Application A be used to access Application B?
In OAuth2, tokens are issued for a specific Client. If the gateway only checks whether a token is valid, but does not verify which Client the token belongs to, any request carrying a valid token can pass through to any backend service. That may look harmless inside an enterprise network, but it creates real risks:
- Access policy bypass: Application B may have its own admission policy, but the gateway only checks “is the token valid.”
- Cross-application data exposure: Browser-side code or manual API calls can reuse a token against another application’s API.
- Broken audit boundaries: The gateway cannot reliably distinguish which application client initiated a request.
The core gap is simple: the gateway performs authentication, but not client binding.
2. Architecture Decision: Inject the Expected Client ID at the Gateway
2.1 Options Considered
| Option | Approach | Problem |
|---|---|---|
| Per-application authorization | Each backend validates the token’s client_id |
Authorization logic spreads across services, and policy changes require repeated code changes |
| Gateway validates only token liveness | forward-auth checks JWT signature and expiration | Does not prevent cross-application token reuse |
| Gateway injects expected Client ID | Route config declares which Client is allowed for this route; the authorization service validates the binding | Centralized control, and adding a new application does not require changes to the authorization service |
The third option is the chosen design. The route configuration is declarative: adding a new application requires a new gateway route config, not a code change in the authorization service.
2.2 Mechanism Overview
sequenceDiagram
participant Client as Browser / Client
participant GW as APISIX Gateway
participant Auth as Authorization Service
participant Backend as Business Backend
Client->>GW: Request /api/app-a/tasks with Bearer token
Note over GW: 1. Route matches Application A
Note over GW: 2. Lua injects X-Expected-Client-Id: app-a-web
GW->>Auth: forward-auth (Authorization + X-Expected-Client-Id)
Note over Auth: 3. Decode JWT and read client_id
Note over Auth: 4. Compare JWT.client_id with X-Expected-Client-Id
alt Client ID matches and admission passes
Auth-->>GW: 200 OK + X-User-Id, X-Sid, etc.
GW->>Backend: Forward request with identity headers
Backend-->>Client: Return business data
else Client ID mismatch
Auth-->>GW: 401 Unauthorized
GW-->>Client: 401 Unauthorized
end
Key design points:
- Route config declares the Client ID: Each protected route declares the OAuth2 Client it expects.
- Lua injects a request header:
serverless-pre-functionwritesX-Expected-Client-Idbefore forward-auth runs. - forward-auth forwards the header:
request_headersmust includeX-Expected-Client-Id. - The authorization service validates the binding: It reads the JWT
client_idclaim and compares it withX-Expected-Client-Id.
3. APISIX Route Configuration
3.1 Route Config Structure
Each application owns an independent gateway route config that declares the route, upstream, and authentication behavior. Application A is used here as a neutral example:
1 | { |
Execution order:
serverless-pre-functionin theaccessphase setsX-Expected-Client-Idforward-auth, through the reusableprotected-api-authconfig, sends that header andAuthorizationto the authorization service- After authorization succeeds, the gateway forwards the request to the backend
3.2 Reusable forward-auth Config
The reusable plugin config looks like this:
1 | { |
X-Expected-Client-Id in request_headers is the critical part. Without it, the forward-auth subrequest will not carry the expected Client ID. If a route does not set this header, the authorization service should reject the request instead of falling back to a permissive mode.
3.3 Protecting the Internal Authorization Route
The forward-auth subrequest targets an APISIX internal route on 127.0.0.1:9080. This route must not be reachable from the public edge:
1 | { |
Three layers of protection:
hosts: 127.0.0.1: Accept only loopback requestsgateway_secretvariable check: Require a shared secret in the query string- High priority: Ensure the internal route wins over business routes
4. Two Authorization Modes
Not every endpoint needs strict Client binding. A profile endpoint such as /me may be valid from any logged-in application. This leads to two modes.
4.1 Protected Mode
Protected mode is the default for business APIs and enforces Client binding.
- Lua sets
X-Expected-Client-Id - forward-auth calls
/__internal/auth/gateway/verify - The authorization service checks
JWT.client_id == X-Expected-Client-Id; mismatch returns 401
4.2 Authenticated Mode
Authenticated mode is for shared user-info endpoints. It requires a valid login state, but does not enforce a specific Client binding.
- Lua sets
X-Auth-Mode: authenticated - forward-auth calls
/__internal/auth/gateway/session - The authorization service skips Client ID matching and validates only token liveness, user state, and active session state
1 | { |
Both modes ultimately reach the same internal authorization capability. X-Auth-Mode selects the behavior:
| Mode | X-Expected-Client-Id | X-Auth-Mode | Validation |
|---|---|---|---|
| Protected | Required, must match JWT client_id |
Not set | Client binding + full admission policy |
| Authenticated | Not required | authenticated |
Login state only |
4.3 Route Priority
Route priority keeps exact routes from being captured by broader API routes:
| Priority | Route Type | Example |
|---|---|---|
| 110 | Internal authorization route | /__internal/auth/gateway/verify |
| 100 | Public callback / webhook | /callback/app-a/* |
| 95-96 | Authenticated API | /api/account/profile/* |
| 91 | Protected API | /api/app-a/*, /api/app-b/* |
| 80-83 | Static UI / SPA | /ui/app-a/* |
| 69-70 | SPA root and fallback | /* |
The profile route has higher priority than business API routes, so “read my own profile” goes through Authenticated mode instead of being captured by a Protected route.
5. Authorization Service: Policy Decision Chain
The gateway handles the question “does this token belong to this Client.” The authorization service then evaluates the admission policy:
flowchart TD
A[Receive forward-auth request] --> B{X-Auth-Mode?}
B -->|authenticated| C[Skip Client check
validate active session]
B -->|entitled/default| D{X-Expected-Client-Id present?}
D -->|missing| E[500 config error]
D -->|present| F{JWT.client_id
== Expected?}
F -->|mismatch| G[401 Client mismatch]
F -->|match| H[Check Client status]
H -->|disabled| I[403 Client disabled]
H -->|active| J{Access Level?}
J -->|PUBLIC| K[Allow]
J -->|AUTHENTICATED| L[Check user state
+ token version]
J -->|PRIVATE| M[Full admission policy]
M --> N{User state?}
N -->|disabled/left| O[403]
N -->|active| P{Token Version
matches?}
P -->|mismatch| Q[401 stale token
force re-auth]
P -->|match| R{Security Stamp
matches?}
R -->|mismatch| S[401 security stamp changed]
R -->|match| T{Platform admin?}
T -->|yes| U[Allow]
T -->|no| V{Application admin?}
V -->|yes| U
V -->|no| W{User entitlement}
W -->|explicit DENY| X[403]
W -->|explicit ALLOW| U
W -->|none| Y{Organization entitlement}
Y -->|any DENY| X
Y -->|any ALLOW| U
Y -->|none| Z[403 default deny]
5.1 Three Access Levels
Each OAuth2 Client has an access_level that determines how strict admission should be:
| Access Level | Value | Meaning | Suitable For |
|---|---|---|---|
| PRIVATE | 1 | Full entitlement policy chain required | Core business apps and admin applications |
| AUTHENTICATED | 2 | Logged-in user with valid state is enough | Shared internal tools and documentation sites |
| PUBLIC | 3 | Client binding passes, then allow | Low-risk endpoints that still need Client identity |
This lets the authorization service short-circuit lower-risk Clients instead of forcing every application through the heaviest policy chain.
5.2 Entitlement Chain for PRIVATE Clients
For PRIVATE applications, admission follows a deterministic order:
1. Platform role bypass
Platform administrators and global application administrators are allowed directly. This is an operational escape path for emergencies.
2. Application administrator
Application-admin configuration records which users administer each Client. Application admins have full access to their own application.
3. User-level entitlement
User-level entitlement records explicit ALLOW or DENY decisions for one user and one application. If a user-level record exists, it wins.
4. Organization-level entitlement
The system resolves all organization units a user belongs to, including ancestors, and evaluates organization-level entitlement:
- Any
DENYwins over allALLOWrecords - If there is no
DENY, at least oneALLOWgrants access - User-level records take precedence over organization-level records
5. Default deny
If nothing matches, deny access.
5.3 Version Consistency Checks
Token Version and Security Stamp are independent revocation signals:
| Check | Trigger | Effect |
|---|---|---|
| Token Version | Admin changes roles or access policy | Version increments, and active tokens become stale |
| Security Stamp | User changes password or account is disabled | Stamp changes, forcing re-authentication |
These two signals are discussed in more detail in HMAC Cookie + Redis SID Dual-Layer Sessions.
6. Client ID Mapping for Multiple Applications
In the examples, Client ID formats are intentionally generic:
| Application | Client ID | Format | Route Prefix |
|---|---|---|---|
| Admin Console | admin-console |
Readable name | /api/admin/* |
| Workbench | workbench-web |
Readable name | /api/workbench/* |
| Application A | app-a-web |
App + endpoint type | /api/app-a/* |
| Application B | app-b-service |
App + endpoint type | /api/app-b/* |
| Application C | app-c-web-r7k3 |
App + endpoint type + suffix | /api/app-c/* |
Several formats are useful:
- Readable name, such as
admin-console: easy to recognize during operations - Application + endpoint type, such as
app-a-web: distinguishes Web, mobile, and service Clients for the same application - Random suffix, such as
app-c-web-r7k3: reduces naming collision and enumeration risk, but is not a security boundary
The Client ID format does not change the isolation mechanism. The gateway and authorization service perform exact matching. Token forgery prevention still depends on JWT signatures, issuer/audience/client_id validation, client secret, PKCE, and related OAuth2 controls.
7. Onboarding a New Application
With this model, adding a new application has three steps.
Step 1: Register an OAuth2 Client
Create a Client through the management API, set access_level, and configure the appropriate client authentication method. The platform generates or records the Client ID.
Step 2: Add Gateway Route Config
Add a route config that declares the route and injects the expected Client ID:
1 | { |
Step 3: Configure Entitlements
Use the management interface to assign access to users or organizations.
The gateway and authorization service remain generic infrastructure. Business applications only declare their routing and access policy.
8. Key Takeaways
The Per-Client authorization isolation mechanism is built on four decisions:
- Declarative binding: Route config declares the expected Client ID instead of hardcoding route-to-Client mapping in the authorization service.
- Two-layer validation: The gateway layer enforces Client binding; the authorization service evaluates whether the user may enter the application.
- Tiered admission:
access_levelkeeps low-risk applications away from the heaviest policy chain. - Mode separation: Protected mode secures business APIs, while Authenticated mode supports shared user-info endpoints.
The trade-off is clear: Lua header injection and the forward-auth subrequest add latency, typically a small internal loopback cost. In return, the system gains centralized control and cross-application token isolation.