0%

Per-Client Authorization Isolation at the Gateway: One IAM Platform for Multiple Applications

🌐 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:

  1. Route config declares the Client ID: Each protected route declares the OAuth2 Client it expects.
  2. Lua injects a request header: serverless-pre-function writes X-Expected-Client-Id before forward-auth runs.
  3. forward-auth forwards the header: request_headers must include X-Expected-Client-Id.
  4. The authorization service validates the binding: It reads the JWT client_id claim and compares it with X-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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"routes": [
{
"id": "app-a-api-route",
"uri": "/api/app-a/*",
"hosts": ["apps.example.com", "console.example.com"],
"priority": 91,
"upstream_id": "app-a-upstream",
"plugin_config_id": "protected-api-auth",
"plugins": {
"serverless-pre-function": {
"phase": "access",
"functions": [
"return function() ngx.req.set_header(\"X-Expected-Client-Id\", \"app-a-web\") end"
]
}
}
}
]
}

Execution order:

  1. serverless-pre-function in the access phase sets X-Expected-Client-Id
  2. forward-auth, through the reusable protected-api-auth config, sends that header and Authorization to the authorization service
  3. 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
2
3
4
5
6
7
8
9
10
11
12
{
"id": "protected-api-auth",
"plugins": {
"forward-auth": {
"uri": "http://127.0.0.1:9080/__internal/auth/gateway/verify?gateway_secret=${GATEWAY_AUTH_SECRET}",
"request_headers": ["Authorization", "X-Trace-Id", "X-Request-Id", "X-Expected-Client-Id"],
"upstream_headers": ["X-User-Id", "X-Sid", "X-User-Email", "X-User-Display-Name", "X-Client-Id", "X-Idp", "X-Idp-User-Id"],
"timeout": 5000,
"status_on_error": 503
}
}
}

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
2
3
4
5
6
7
8
{
"id": "auth-gateway-verify-internal",
"uri": "/__internal/auth/gateway/verify",
"hosts": ["127.0.0.1"],
"priority": 110,
"vars": [["arg_gateway_secret", "==", "${GATEWAY_AUTH_SECRET}"]],
"upstream_id": "auth-service-upstream"
}

Three layers of protection:

  • hosts: 127.0.0.1: Accept only loopback requests
  • gateway_secret variable 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
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"id": "account-profile-authenticated-route",
"uri": "/api/account/profile/*",
"priority": 95,
"plugin_config_id": "authenticated-api-auth",
"plugins": {
"serverless-pre-function": {
"phase": "access",
"functions": [
"return function() ngx.req.set_header(\"X-Auth-Mode\", \"authenticated\") end"
]
}
}
}

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 DENY wins over all ALLOW records
  • If there is no DENY, at least one ALLOW grants 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"routes": [
{
"id": "app-x-api-route",
"uri": "/api/app-x/*",
"hosts": ["apps.example.com"],
"priority": 91,
"upstream_id": "app-x-upstream",
"plugin_config_id": "protected-api-auth",
"plugins": {
"serverless-pre-function": {
"phase": "access",
"functions": [
"return function() ngx.req.set_header(\"X-Expected-Client-Id\", \"app-x-web\") end"
]
}
}
}
]
}

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:

  1. Declarative binding: Route config declares the expected Client ID instead of hardcoding route-to-Client mapping in the authorization service.
  2. Two-layer validation: The gateway layer enforces Client binding; the authorization service evaluates whether the user may enter the application.
  3. Tiered admission: access_level keeps low-risk applications away from the heaviest policy chain.
  4. 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.