AuthGeek Demo
← Back to live demo
Architecture Reference

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.

Apache APISIX
The enforcement point. Its native openid-connect plugin verifies JWTs against Keycloak's live JWKS, injects headers, calls OPA, applies rate limits. Fully open source.
Keycloak
Issues JWTs (RS256). Stores users, realms, protocol mappers for custom claims (user_tier, department). Can federate to upstream IDPs via OIDC/SAML.
OPA (Open Policy Agent)
Evaluates Rego policies for fine-grained access control. APISIX calls it on a per-request basis for protected paths. Fail-closed.
Session Service (BFF)
Brokers login — calls Keycloak ROPC internally, stores JWT + refresh token in Redis, returns an opaque session_id to the browser.
Redis
Backs the session store. Maps session_id → { access_token, refresh_token, user_id, user_tier }. TTL-based expiry, instant delete on logout.
Demo API (FastAPI)
The backend. Has zero auth code for the gateway pattern's routes — trusts the headers APISIX injects after real verification.
1

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.

APISIX declarative config — docker/apisix/apisix-oidc.yaml
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
What the plugin checks
RS256 signature (via JWKS), exp claim, iss claim
What it does NOT do
String-match the token. Trust an unverified payload.
Failure behavior
401 — rejected before OPA or the backend ever see it
Production note: When Keycloak rotates its signing key, the plugin needs to re-fetch JWKS — verify your APISIX version's cache TTL behavior. The BFF session pattern removes this concern from the gateway path entirely, since the session service is the only thing that ever validates a Keycloak token.

2

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.

Headers injected on every authenticated request
x-user-id
JWT sub claim (Keycloak UUID)
x-user-email
User's email address
x-user-tier
basic / premium / admin
x-auth-realm
demo-realm
x-auth-method
gateway-oidc / bearer-oidc / sidecar
Backend reads headers — no auth code required
# 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"),
    )
Production note: The header trust model requires that backends are only reachable through the gateway. Enforce this at the network level (private subnet, security groups, or mTLS between gateway and backends). If a caller can reach the backend directly and forge x-user-tier: admin, the model collapses. The gateway is the trust boundary — protect it at the infrastructure layer.

3

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.

Rego policy — docker/opa/policies/gateway_authz.rego
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")
}
What APISIX actually sends OPA
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 } }
Fail-closed — the critical detail

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.

Production note — sidecar alternative: This pattern calls a shared, centrally-deployed OPA — one enforcement point, one blast radius if it misbehaves. See the sidecar pattern demo for the alternative: OPA co-located with each service instead.

4

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.

docker/apisix/apisix-oidc.yaml
limit-count:
  count: 60
  time_window: 60
  key_type: "var"
  key: "http_authorization"   # per-caller, not per-IP
  rejected_code: 429
  policy: "local"
The honest limitation: keying on the bearer token is genuinely per-caller and survives NAT, but the key rotates when the access token refreshes, and 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.


5

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.

Two separate flows
Auth flow — happens once at login
1. Browser → POST /session-api/login { username, password }
2. Session Service → Keycloak ROPC → gets access_token + refresh_token
3. Session Service → Redis: SET session:<id> { access_token, refresh_token, user_id, user_tier }
4. Session Service → Browser: { session_id: "4964bad811b0f011..." }
// 64 hex chars. No claims. No expiry. No user information.
API flow — every subsequent request
1. Browser → Caddy: X-Session-ID: 4964bad811b0f011...
2. Caddy → Session Service: GET /internal/resolve (X-Session-ID header)
3. Session Service → Redis lookup → returns user_id, user_tier, user_email
4. Backend receives resolved identity — never sees a JWT
What Redis stores — what the browser never sees
# 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"
}
Production note: In production, set the session_id as an httpOnly; Secure; SameSite=Strict cookie rather than a response body value. The httpOnly flag means JavaScript cannot read it — XSS cannot steal it.

6

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.

session-service/src/main.py — resolve endpoint auto-refreshes
@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": ...}
Gateway pattern (this demo, above)
• Raw JWT in localStorage
• No refresh mechanism at all
• Token expires → re-login required
• State is fully client-side
BFF pattern (session-service)
• Session service detects expiry on resolve
• Calls Keycloak grant_type=refresh_token
• Redis updated — browser session_id unchanged
• Browser never involved in the exchange

7

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.

How the BFF session pattern solves this
1. Browser calls DELETE /session-api/logout with X-Session-ID header
2. Session service calls Keycloak logout endpoint with refresh_token → refresh token revoked
3. Session service calls Redis DEL session:<id> → session key deleted immediately
4. Next request with that session_id → resolve fails → session not found → 401
Production note — forced logout at scale: To force-logout all sessions for a user (compromised account, offboarding), maintain a secondary Redis index: user_sessions:<user_id> → SET of session_ids, then delete each session key. Keycloak also supports this natively via its Admin Console Sessions tab.

8

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.

What changes when you add an upstream IDP
configure
Keycloak Admin Console
Add Identity Provider → OpenID Connect or SAML → configure upstream IDP endpoints
configure
Attribute mappers
Map upstream claims (e.g. AD groups) to Keycloak user attributes (e.g. user_tier)
configure
Protocol mappers
Already configured — they run after any auth and add custom claims to the JWT
unchanged
APISIX config
No change
unchanged
OPA policies
No change
unchanged
Session service
No change
unchanged
Backend APIs
No change
Production note — attribute mapping is the hard part: Getting the right claims into the JWT depends on the upstream IDP exposing the right attributes. The JWT shape that APISIX and OPA see is always the same — Keycloak is the normalization layer. Budget time for attribute mapping; it's usually where enterprise integrations take longest.

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
See it all working live

Log in as any user tier and fire real requests through the gateway — all patterns run on real containers, no mocks.

Open Live Demo →