Gateway Auth Patterns
Patterns demonstrated live in the APISIX gateway demo, using open-source components only — no proprietary gateway product required. Each one solves a specific problem that comes up when you're putting an API gateway in front of real services and real users.
The Architecture
The demo runs a full production-topology stack. Every component is real — no mocks, no stubs, and nothing here depends on a paid control plane. The same containers and policies run identically in production with a proper domain and TLS termination in front.
JWKS-Verified JWT Validation
Real cryptographic signature verification — not a substring match on the token
An earlier version of this demo "validated" JWTs by checking whether a hardcoded base64
fragment (the encoding of a string like "admin-user")
appeared anywhere in the raw token string. No signature check, no decode, no expiry check —
anyone could forge a token-shaped string containing that substring and get admin access.
That's not a JWT validation pattern, it's a bug that happened to look like one.
APISIX's native openid-connect plugin (built on lua-resty-openidc) fetches Keycloak's public signing keys via OIDC discovery once, caches them, and verifies every token's RS256 signature locally — no Keycloak round-trip per request, and no way to forge a token without the private key.
plugin_configs:
- id: 2 # bearer-only routes
plugins:
openid-connect:
client_id: api-client
client_secret: api-client-secret-2024
discovery: "http://keycloak:8080/auth/realms/demo-realm/.well-known/openid-configuration"
bearer_only: true
set_userinfo_header: true User-Context Header Injection
Backends receive identity as plain headers — no JWT parsing, no SDK, no auth code
After the openid-connect plugin verifies the token, it sets an X-Userinfo header
(base64 JSON of the verified claims). A serverless-post-function —
which runs last in the access phase, after verification has already happened — decodes that
and re-projects it into the plain headers the backend expects.
This means every backend service is auth-agnostic. It reads headers it trusts — because they were set by the gateway after real verification, and the backend network only accepts traffic from the gateway.
# demo-api/src/api/auth_utils.py
def get_user_context(request: Request) -> UserContext:
# Just reads the headers APISIX injected after verification
return UserContext(
id = request.headers.get("x-user-id"),
email = request.headers.get("x-user-email"),
tier = request.headers.get("x-user-tier", "basic"),
realm = request.headers.get("x-auth-realm", "unknown"),
) x-user-tier: admin, the model collapses. The gateway is the trust boundary — protect it at the infrastructure layer. OPA Policy Enforcement (Fail-Closed)
Fine-grained access control as code — decoupled from the gateway and the backend
Business rules — "premium users can access this endpoint, admins can access everything" — don't belong hardcoded into gateway config. They belong in policy code.
OPA evaluates Rego policies. APISIX's native opa plugin
calls it on every request to a protected path, passing the request context, and receives a
decision back. The policy logic lives in one place and can be updated without touching the gateway.
package gateway_authz
default allow := false
path := input.request.path
user_tier := input.request.headers["x-user-tier"]
allow if {
user_tier in {"premium", "admin"}
startswith(path, "/api/premium")
}
allow if {
user_tier == "admin"
startswith(path, "/api/admin")
} POST http://opa:8181/v1/data/gateway_authz
{
"input": {
"request": {
"path": "/api/premium/dashboard",
"method": "GET",
"headers": { "x-user-tier": "basic", ... }
}
}
}
// OPA responds:
{ "result": { "allow": false, "reason": "premium tier required", "status_code": 403 } } If OPA is unreachable or times out, the request is denied — not allowed through. Policy engine failure closes the gate, it doesn't open it.
Rate Limiting
APISIX's native limit-count plugin — keyed per caller, not per IP
IP-based rate limiting breaks behind shared NAT: 500 employees on one corporate egress IP all
share a bucket, so one user's burst throttles everyone. APISIX's
limit-count takes any variable as its
key — this demo keys on the caller's bearer credential
($http_authorization) so each user gets
their own quota regardless of network topology.
limit-count:
count: 60
time_window: 60
key_type: "var"
key: "http_authorization" # per-caller, not per-IP
rejected_code: 429
policy: "local" policy: local
counts per APISIX node. Why not key on the verified user_tier directly?
APISIX runs plugins in a fixed priority order — the function that decodes the tier claim runs after limit-count in the access phase, so that header isn't set yet when the
key is evaluated. The production answer is an APISIX consumer mapped from the JWT (stable per user) with
policy: redis for shared, restart-surviving counters. This is the
documented next step, not yet wired — see ENH-04 in the remediation plan. When the limit is exhausted, APISIX returns 429 and the backend never sees the request.
BFF Session Token Pattern
Browser holds an opaque reference — JWT and refresh token stay server-side
A JWT in the browser is a credential. If it leaks through an XSS vulnerability, a logging
mistake, or a compromised extension, the attacker has everything they need to impersonate
the user until the token expires — that's exactly what the gateway demo above does, and
says so plainly. The BFF (Backend for Frontend) pattern eliminates this by keeping
credentials server-side entirely, using this project's own session-service —
not a third-party proxy binary.
# Redis key: session:<64-char hex id>
# TTL: 8 hours
{
"access_token": "eyJhbGciOiJSUzI1NiIs...", # JWT — server-side only
"access_token_exp": 1774280274,
"refresh_token": "opaque-keycloak-token", # Never sent to browser
"user_id": "basic-user",
"user_email": "basic@example.com",
"user_tier": "basic",
"keycloak_sid": "edd5b376-5c96-4c12...", # Keycloak session link
"created_at": "2026-03-23T15:35:54Z",
"last_refreshed": "2026-03-23T15:35:54Z"
} httpOnly; Secure; SameSite=Strict cookie rather than a response body value.
The httpOnly flag means JavaScript cannot read it — XSS cannot steal it. Server-Side Token Refresh
Access tokens rotate silently — browser session never interrupts
JWTs expire. In the demo they expire every 2 minutes. Without a refresh mechanism, the user
would have to log in again every 2 minutes — that's exactly what happens in the gateway
pattern demo above, which has no refresh at all. With the BFF pattern, refresh is handled
entirely server-side — the browser's session_id
never changes, so there's nothing for the browser to do.
@app.get("/internal/resolve")
async def resolve(x_session_id: str = Header(...)):
session = _load_session(x_session_id)
# Auto-refresh if access token expires within 30 seconds
now_ts = datetime.utcnow().timestamp()
if session["access_token_exp"] - now_ts < 30:
kc = await _keycloak_refresh(session["refresh_token"])
if kc:
session["access_token"] = kc["access_token"]
session["access_token_exp"] = decode_exp(kc["access_token"])
session["refresh_token"] = kc["refresh_token"]
_save_session(x_session_id, session)
else:
r.delete(session_key(x_session_id))
return {"valid": False, "reason": "refresh_token_expired"}
return {"valid": True, "user_id": ..., "user_tier": ...} Instant Session Revocation
Logout takes effect on the next request — no JWT expiry window
This is the core problem with stateless JWTs: once issued, a JWT is valid until it expires,
no matter what the server "thinks." In the gateway demo above, logout just clears
localStorage client-side — the JWT itself is still perfectly valid until its
exp passes.
user_sessions:<user_id> → SET of session_ids,
then delete each session key. Keycloak also supports this natively via its Admin Console Sessions tab. Enterprise IDP Federation
Plug any upstream IDP into this stack without touching APISIX, OPA, or your backends
In the demo, Keycloak is the only IdP. In an enterprise, there's usually an existing identity system — Active Directory, Ping Identity, Okta, or something that predates modern protocols. The federation pattern lets you use that system without rewriting anything downstream.
Patterns at a Glance
| # | Pattern | Where it lives | What it eliminates |
|---|---|---|---|
| 1 | JWKS-verified JWT validation | APISIX openid-connect plugin | Fake/forgeable auth checks |
| 2 | Header injection | APISIX plugin chain | Auth code in every backend |
| 3 | OPA fail-closed enforcement | APISIX opa plugin + Rego | Access on policy engine failure |
| 4 | Rate limiting | APISIX limit-count plugin | Unbounded request bursts |
| 5 | BFF session token | session-service + Redis | JWT exposure in browser |
| 6 | Server-side token refresh | session-service | Client-side refresh complexity |
| 7 | Instant revocation | session-service + Redis | JWT expiry revocation gap |
| 8 | IDP federation | Keycloak SP config | Upstream IDP lock-in |
Log in as any user tier and fire real requests through the gateway — all patterns run on real containers, no mocks.