Skip to content

Commit e7b9140

Browse files
dshoen619claudezeevmoney
authored
Document gateway JWT for upstream MCP server authentication (#623)
* Document gateway JWT for upstream MCP server authentication Add a new "Upstream Authentication (Gateway JWT)" section to the MCP Gateway architecture page covering the X-Gateway-Auth header, JWKS endpoint, JWT claims, verification snippet, and key rotation. Update the System Architecture Overview and Data Flow table to show the signed JWT attached to upstream requests and the optional JWKS verification path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update gateway JWT docs: signing key cached in Redis across replicas Correct the earlier "ephemeral per-pod" framing now that the signing key is cached in Redis (encrypted at rest) and shared across all gateway replicas. Note that keys survive restarts, the JWKS endpoint is consistent across replicas, and keys rotate on roughly a 90-day cadence so users can reason about JWKS cache lifetimes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address Zeev's review on gateway JWT docs Fixes 8 inaccuracies vs. the implementation in agent-security main: - Sequence diagram now shows shared-key load (not ephemeral generation) - aud row documents canonicalization (lowercase host, strip trailing slashes) - iat/nbf rows show 30s leeway; adds clock-tolerance note for verifiers - Drops misleading "enables replay detection" from jti row; adds opt-in replay-protection guidance in the verifier section - Verifier snippet now guards missing/non-string headers and the Bearer prefix, and sets clockTolerance: 30 - Key rotation callout: encryption-at-rest is conditional on vault; 90-day cadence is a warning, not automatic rotation - Configuration table adds GATEWAY_JWT_REQUIRE_VAULT and TTL bounds Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address PR review comments on gateway JWT docs - Data Flow diagram edge now mentions the signed X-Gateway-Auth JWT, matching the table row (Copilot, Zeev) - Sequence-diagram claim list includes nbf (Zeev) - Reworded "JWT signing (per-request)" note to reflect lazy caching/re-sign-near-expiry (Copilot) - Use {...} placeholders instead of angle brackets in the sequence diagram so they don't render as HTML (Copilot) - Use ASCII hyphen instead of Unicode minus in the iat/nbf claim rows (Copilot) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Zeev Manilovich <zeevmoney@users.noreply.github.com>
1 parent a838852 commit e7b9140

1 file changed

Lines changed: 137 additions & 3 deletions

File tree

docs/permit-mcp-gateway/architecture.mdx

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ flowchart LR
114114
GW -- "permit.check() per tool call" --> PermitPDP
115115
CS -->|"Sync consent → roles/relations"| PermitAPI
116116
117-
GW -- "Proxy Streamable HTTP" --> Upstream
117+
GW -- "Proxy MCP +<br/>X-Gateway-Auth JWT" --> Upstream
118+
Upstream -.->|"Verify JWT (optional)<br/>GET /.well-known/gateway-jwks.json"| GW
118119
119120
Upstream["Upstream MCP Server"]
120121
```
@@ -169,7 +170,7 @@ flowchart TD
169170
GW -->|"JWKS fetch (cached)"| CS
170171
GW -->|"permit.check()"| PERMIT
171172
CS -->|"Policy sync on consent"| PERMIT
172-
GW -->|"Streamable HTTP +<br/>upstream OAuth tokens"| MCP
173+
GW -->|"Streamable HTTP + upstream OAuth tokens<br/>+ X-Gateway-Auth JWT"| MCP
173174
```
174175

175176
</ZoomableDiagram>
@@ -185,7 +186,7 @@ flowchart TD
185186
| Gateway | Consent Service (JWKS) | HTTP (port 3000) | JWT signature verification |
186187
| Gateway | Permit.io | HTTPS | Authorization checks (Cloud PDP) |
187188
| Consent Service | Permit.io | HTTPS | Policy sync during consent |
188-
| Gateway | Upstream MCP | HTTPS | Streamable HTTP with upstream OAuth tokens |
189+
| Gateway | Upstream MCP | HTTPS | Streamable HTTP with upstream OAuth tokens + signed `X-Gateway-Auth` JWT (see [Upstream Authentication](#upstream-authentication-gateway-jwt)) |
189190

190191
## Integration Patterns
191192

@@ -440,6 +441,139 @@ The derived role then determines which tools are allowed:
440441
- **Medium** trust tools: available to `medium` and `high` roles
441442
- **High** trust tools: available to `high` role only
442443

444+
## Upstream Authentication (Gateway JWT)
445+
446+
When the gateway proxies a request to an upstream MCP server, it attaches a short-lived, gateway-signed JWT in an `X-Gateway-Auth` header alongside the upstream OAuth token. This lets upstream servers verify that a request came through the gateway — preventing agents from bypassing the gateway's authN/authZ/consent layer by connecting directly to the upstream URL.
447+
448+
The feature is **always on** and requires no configuration on the gateway. Upstream servers opt in to verification — those that don't simply ignore the extra header, so behavior is unchanged for servers that don't need this guarantee.
449+
450+
### How it works
451+
452+
The gateway maintains an Ed25519 signing key that is cached in Redis and shared across all gateway replicas. The public key is exposed at `GET /.well-known/gateway-jwks.json` (unauthenticated, cached by clients for 5 minutes). On each upstream request, the gateway signs a short-lived JWT with the authenticated user's identity, the upstream URL as audience, and the tenant subdomain. The JWT is cached and reused until it is within 30 seconds of expiry, then re-signed — avoiding per-request signing overhead while ensuring long-lived MCP sessions always send a valid token.
453+
454+
```mermaid
455+
sequenceDiagram
456+
participant Client as MCP Client
457+
participant GW as Gateway
458+
participant JWKS as Gateway JWKS<br/>/.well-known/gateway-jwks.json
459+
participant Up as Upstream MCP Server
460+
461+
Note over GW: Startup: load shared Ed25519 key<br/>from Redis (or generate + store if none exists)
462+
463+
Client->>GW: POST /mcp (tool call)
464+
GW->>GW: Auth + Permit check
465+
466+
rect rgb(240, 248, 255)
467+
Note over GW: JWT signing (lazy — cached, re-signed near expiry)
468+
GW->>GW: Is cached JWT still valid?
469+
alt JWT expired or near expiry (within 30s)
470+
GW->>GW: Sign fresh JWT<br/>{iss, sub, aud, exp, iat, nbf, jti, tenant}
471+
GW->>GW: Cache JWT
472+
else JWT still valid
473+
GW->>GW: Reuse cached JWT
474+
end
475+
end
476+
477+
GW->>Up: POST (MCP request)<br/>Authorization: Bearer {upstream_oauth_token}<br/>X-Gateway-Auth: Bearer {gateway_jwt}
478+
479+
opt Upstream wants to verify
480+
Up->>JWKS: GET /.well-known/gateway-jwks.json
481+
JWKS-->>Up: {keys: [{kty: "OKP", crv: "Ed25519", ...}]}
482+
Up->>Up: Verify JWT signature + claims
483+
end
484+
485+
Up-->>GW: MCP response
486+
GW-->>Client: MCP response
487+
```
488+
489+
### JWT claims
490+
491+
| Claim | Value | Purpose |
492+
| -------- | ------------------------------------------------------------------------------------------------ | --------------------------------- |
493+
| `iss` | `agent-security-gateway` | Identifies the issuer |
494+
| `sub` | Authenticated user ID | Who the request is being made for |
495+
| `aud` | Canonicalized upstream URL — host lowercased, all trailing slashes (including root `/`) stripped | Intended recipient |
496+
| `exp` | `now + TTL` (default 5 min) | Token expiry |
497+
| `iat` | `now - 30s` (clock-skew leeway) | When the token was issued |
498+
| `nbf` | `now - 30s` (clock-skew leeway) | Not valid before this time |
499+
| `jti` | Unique UUID | Unique token ID |
500+
| `tenant` | Host subdomain | Which tenant the request belongs to |
501+
502+
:::note Clock-skew leeway
503+
`iat` and `nbf` are intentionally backdated 30 seconds so upstream verifiers with small clock drift don't reject otherwise-valid tokens. Configure your JWT library with a clock tolerance of at least 30 seconds (e.g. `clockTolerance: 30` in `jose`).
504+
:::
505+
506+
:::note Audience canonicalization
507+
The `aud` claim is the canonicalized form of your upstream URL — host lowercased, all trailing slashes (including root `/`) stripped. Configure your verifier's `audience` option with the canonicalized form, e.g. `https://MCP.Example.com/` becomes `https://mcp.example.com`, and `https://your-server.example.com/v1/` becomes `https://your-server.example.com/v1`.
508+
:::
509+
510+
### Two auth headers coexist
511+
512+
The gateway sends two distinct headers on upstream requests — they serve different purposes and don't conflict:
513+
514+
| Header | Who issues it | What it proves |
515+
| ------------------- | -------------- | ----------------------------------------------- |
516+
| `Authorization: Bearer <token>` | The upstream's OAuth provider | The user authorized this request with the upstream service |
517+
| `X-Gateway-Auth: Bearer <jwt>` | The gateway | The request originated from this gateway instance |
518+
519+
### Verifying on the upstream side
520+
521+
Upstream servers that want to reject direct (non-gateway) traffic fetch the gateway's public key from the JWKS endpoint and verify the `X-Gateway-Auth` header on every request. Using a standard JWT library (which handles JWKS caching and key rotation automatically) the verifier is short — under 10 lines of meaningful code:
522+
523+
```js
524+
import { createRemoteJWKSet, jwtVerify } from 'jose';
525+
526+
const JWKS = createRemoteJWKSet(
527+
new URL('https://<tenant>.agent.security/.well-known/gateway-jwks.json')
528+
);
529+
530+
const auth = req.headers['x-gateway-auth'];
531+
if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) {
532+
return res.status(401).end();
533+
}
534+
535+
const { payload } = await jwtVerify(
536+
auth.slice(7).trim(),
537+
JWKS,
538+
{
539+
issuer: 'agent-security-gateway',
540+
audience: 'https://your-mcp-server.example.com', // canonical: lowercase host, no trailing slash
541+
algorithms: ['EdDSA'],
542+
clockTolerance: 30,
543+
}
544+
);
545+
```
546+
547+
Reject any request without a valid `X-Gateway-Auth` header. The `aud` claim is the **canonicalized form** of your upstream URL — host lowercased, all trailing slashes (including the root `/`) stripped — so configure `audience` with the canonicalized form (e.g. `https://MCP.Example.com/` becomes `https://mcp.example.com`).
548+
549+
:::note Replay protection (opt-in)
550+
The `jti` claim is unique per token but `jose.jwtVerify` does not deduplicate it. If you need replay protection, track recently-seen `jti` values for at least the JWT TTL plus your clock-skew window — e.g. a Redis `SET` with `TTL = GATEWAY_JWT_TTL_SECONDS + 30` and reject any `jti` already in the set.
551+
:::
552+
553+
:::info Key rotation
554+
The signing key is stored in Redis and shared across all gateway replicas, so JWTs remain valid across pod restarts and the JWKS endpoint returns the same key regardless of which replica serves it. When the token vault is enabled (`VAULT_ENABLED=true` + `AWS_KMS_KEY_ID`), the key is encrypted at rest using AES-256-GCM with AWS KMS envelope encryption; without vault it is stored as plaintext JSON (the gateway logs a warning). Set `GATEWAY_JWT_REQUIRE_VAULT=true` in production to refuse the plaintext fallback.
555+
556+
Rotation is **manual** — the gateway logs a warning and emits a `gateway_jwt_signing_key_age_seconds` metric after 90 days, but does not rotate on its own. To rotate, delete the Redis key and trigger a rolling restart:
557+
558+
```bash
559+
redis-cli DEL gateway:jwt:signing_key
560+
# then: kubectl rollout restart deployment/gateway
561+
```
562+
563+
The `kid` header on each JWT identifies which key signed it — upstream servers should rely on their JWT library's JWKS caching, which automatically re-fetches the JWKS on unknown `kid`, making rotation transparent to verifiers.
564+
:::
565+
566+
### Configuration
567+
568+
There is nothing to configure on the gateway to enable this feature. Two optional tunables are available:
569+
570+
| Env var | Default | Description |
571+
| --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
572+
| `GATEWAY_JWT_TTL_SECONDS` | `300` | JWT lifetime in seconds. Must be between `60` and `3600`; values outside that range fail config validation at startup. |
573+
| `GATEWAY_JWT_REQUIRE_VAULT` | `false` | Refuse to start if the token vault is not configured. Prevents plaintext private-key storage in Redis. Recommended `true` in production. |
574+
575+
The JWKS URL for each host is available in the platform under **Settings > Upstream Authentication (JWT)**, along with a sample verification snippet.
576+
443577
## Rate Limiting
444578

445579
The gateway includes built-in rate limiting to prevent abuse. No configuration is required on your end.

0 commit comments

Comments
 (0)