You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
| Gateway | Upstream MCP | HTTPS | Streamable HTTP with upstream OAuth tokens + signed `X-Gateway-Auth` JWT (see [Upstream Authentication](#upstream-authentication-gateway-jwt)) |
189
190
190
191
## Integration Patterns
191
192
@@ -440,6 +441,139 @@ The derived role then determines which tools are allowed:
440
441
-**Medium** trust tools: available to `medium` and `high` roles
441
442
-**High** trust tools: available to `high` role only
442
443
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)
|`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:
|`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:
if (typeof auth !=='string'||!auth.startsWith('Bearer ')) {
532
+
returnres.status(401).end();
533
+
}
534
+
535
+
const { payload } =awaitjwtVerify(
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:
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:
|`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
+
443
577
## Rate Limiting
444
578
445
579
The gateway includes built-in rate limiting to prevent abuse. No configuration is required on your end.
0 commit comments