Gateway Behavior
The Gateway is the network entry point for MCP tool calls. It does not expose a resource API — every inbound path is proxied to a configured upstream. The Gateway runs on port 8081 (fixed). TLS 1.2+ is enforced in production; plaintext HTTP is available in development via INSECURE_HTTP=true.
Inbound request authentication
Section titled “Inbound request authentication”The Gateway applies five checks in order. The first failure halts the request.
1. Bearer token extraction
Section titled “1. Bearer token extraction”Reads Authorization: Bearer <token>. Returns 401 if the header is missing, not in Bearer format, or exceeds 4 096 bytes.
2. JWT expiry preflight
Section titled “2. JWT expiry preflight”Peeks the exp claim without signature verification. Rejects the token with 401 CredentialExpired if it would expire within 35 seconds. This prevents issuing an upstream token that arrives stale.
3. Signature verification
Section titled “3. Signature verification”Fetches the zone’s JWKS from {STS_URL}/.well-known/jwks.json?zone_id=<zone>. Verifies the ES256 signature using the key matching the kid header. JWKS responses are cached 5 minutes per zone. Returns 401 InvalidToken on failure.
4. JTI replay detection
Section titled “4. JTI replay detection”Checks whether the token’s jti claim has been seen. Tokens with "use": "ambient" are reusable; tokens with "use": "per_call" are single-use. Single-use JTIs are stored in Redis with a TTL equal to the token’s remaining lifetime. Returns 401 InvalidToken if a per-call token is replayed.
5. Session revocation check
Section titled “5. Session revocation check”Extracts the sid claim (or agent_session_id as fallback) and checks the in-memory revocation store. Returns 401 InvalidToken if the session is revoked.
Required inbound headers
Section titled “Required inbound headers”| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer <ambient-token> |
X-Caracal-Resource | Yes | Resource identifier used to look up the upstream binding |
Forbidden: Clients must not send X-Caracal-Client-ID. The Gateway strips it and returns 400 InvalidToken if present.
The X-Request-Id header is preserved if valid (alphanumeric, dots, hyphens, colons, 1–128 characters) or replaced with a generated UUIDv7.
Token exchange with the STS
Section titled “Token exchange with the STS”After authentication, the Gateway exchanges the caller’s ambient token for a per-resource mandate by calling the STS.
Request:
POST {STS_URL}/oauth/2/tokenContent-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchangesubject_token=<ambient-bearer>subject_token_type=urn:ietf:params:oauth:token-type:access_tokenzone_id=<zone-from-binding>application_id=<app-from-binding>resource=<resource-identifier>The Gateway uses the upstream binding (zone ID + application ID) stored in its database for the resource identified by X-Caracal-Resource. If no binding exists, it returns 403 AccessDenied without calling the STS.
Timeout: STS_TIMEOUT (default 5s). Returns 504 Gateway Timeout if exceeded.
Headers forwarded to the upstream
Section titled “Headers forwarded to the upstream”After a successful exchange, the Gateway sends the request to the upstream URL returned by the STS.
Authorization header:
auth_mode | Header sent |
|---|---|
caracal_jwt | Authorization: Bearer <sts-issued-mandate> |
provider_oauth | {auth_header}: {auth_scheme} {provider_token} |
provider_apikey | {auth_header}: {auth_scheme} {provider_token} |
When auth_mode is provider_oauth or provider_apikey, the Caracal mandate is exposed as a separate X-Caracal-Identity: <mandate> header for Caracal-aware upstream sidecars.
Additional forwarded headers:
| Header | Value |
|---|---|
X-Request-Id | Preserved or generated request ID |
Traceparent | W3C trace context derived from the request ID |
X-Forwarded-For | Client IP (replaced, never appended) |
X-Forwarded-Proto | https or http |
X-Forwarded-Host | Original Host header |
X-Caracal-Token-Expires-In | Seconds until the mandate expires (set on the response) |
Stripped before forwarding: X-Caracal-Client-ID, X-Caracal-Resource, X-Caracal-Upstream, X-Caracal-Identity (inbound), and all RFC 7230 hop-by-hop headers.
The upstream URL is constructed by joining the upstream base path with the inbound request path. Query strings are merged; upstream values take precedence on conflicts. Path traversal attempts (../) are rejected with 400 InvalidToken.
Upstream SSRF guard
Section titled “Upstream SSRF guard”Before forwarding, the Gateway validates the upstream URL returned by the STS:
- The host must not resolve to a loopback or private IP address (unless
ALLOW_PRIVATE_UPSTREAMS=true). - The host must appear in
UPSTREAM_HOST_ALLOWLISTif that variable is set.
Violations return 403 AccessDenied.
Error response table
Section titled “Error response table”| Condition | Status | Body |
|---|---|---|
Missing Authorization header | 401 | {"error": "InvalidToken"} |
| Bearer > 4 096 bytes | 401 | {"error": "InvalidToken"} |
| Malformed JWT | 401 | {"error": "InvalidToken"} |
| Token expires within 35 s | 401 | {"error": "CredentialExpired"} |
| JWT signature invalid | 401 | {"error": "InvalidToken"} |
| JTI replay (per-call token reused) | 401 | {"error": "InvalidToken"} |
| Session revoked | 401 | {"error": "InvalidToken"} |
X-Caracal-Client-ID present | 400 | {"error": "InvalidToken"} |
Missing X-Caracal-Resource | 400 | {"error": "InvalidToken"} |
| Path traversal detected | 400 | {"error": "InvalidToken"} |
| No binding for resource | 403 | {"error": "AccessDenied"} |
| SSRF guard violation | 403 | {"error": "AccessDenied"} |
| STS exchange returned 4xx | 401/403 | STS error forwarded |
| STS timeout | 504 | {"error": "GatewayTimeout"} |
Request body > MAX_REQUEST_BYTES | 413 | {"error": "RequestTooLarge"} |
| Upstream unreachable | 502 | {"error": "BadGateway"} |
| Upstream timeout | 504 | {"error": "GatewayTimeout"} |
| Client disconnected | 499 | — |
Response handling and revocation-aware streaming
Section titled “Response handling and revocation-aware streaming”The Gateway streams the upstream response body to the caller. Every 4 KB of streamed body, it checks the session revocation store. If the session is revoked mid-stream:
- The response body is truncated.
- The HTTP trailer
X-Caracal-Revoked: trueis set to signal the truncation.
This mechanism is a best-effort guard — clients should check for the trailer and retry if they receive a truncated response.
Revocation store
Section titled “Revocation store”The Gateway maintains an in-memory revocation cache populated from the caracal.sessions.revoke Redis stream. Stream consumer details:
| Parameter | Value |
|---|---|
| Stream | caracal.sessions.revoke |
| Consumer group | gateway-revocation |
| Consumer ID | gateway-{hostname}-{pid} |
| Batch size | 50 messages |
| Block duration | 1 second |
| Pending reclaim | XAUTOCLAIM with 30 s idle window |
Dead-letter messages (invalid HMAC signatures) are moved to caracal.sessions.revoke.dead.
In-memory entries are pruned every 30 minutes. Revocation entries have a 24-hour TTL (by which time any mandate referencing the session has expired).
Environment variables
Section titled “Environment variables”| Variable | Required | Default | Description |
|---|---|---|---|
STS_URL | Yes | — | STS base URL for token exchange and JWKS |
DATABASE_URL | Yes | — | Postgres for resource→binding lookup |
REDIS_URL | Production | — | Redis for JTI replay detection and revocation |
STREAMS_HMAC_KEY | Production | — | Hex-encoded HMAC key (≥32 bytes) for stream message verification |
TLS_CERT_FILE | Production | — | TLS certificate path |
TLS_KEY_FILE | Production | — | TLS private key path |
INSECURE_HTTP | No | false | Allow plaintext HTTP (development only) |
INSECURE_STS | No | false | Allow HTTP to STS (development only) |
UPSTREAM_HOST_ALLOWLIST | No | — | Comma-separated list of allowed upstream hosts |
ALLOW_PRIVATE_UPSTREAMS | No | false | Permit forwarding to private/loopback IPs |
MAX_REQUEST_BYTES | No | 10485760 | Maximum request body size (10 MiB) |
STS_TIMEOUT | No | 5s | Per-request STS timeout |
UPSTREAM_TIMEOUT | No | 30s | Per-request upstream timeout |
JTI_FAIL_OPEN | No | false | If true, allow traffic when Redis is unavailable; forbidden in production |
Metrics — GET /metrics
Section titled “Metrics — GET /metrics”{ "requests_total": 10000, "requests_allowed": 9850, "requests_denied": 150, "denials_missing_auth": 20, "denials_bad_bearer": 5, "denials_expiring": 8, "denials_bad_routing": 12, "denials_binding": 15, "denials_path_traversal": 1, "denials_signature": 3, "denials_jti_replay": 2, "denials_revoked": 4, "sts_exchange_errors": 10, "upstream_errors": 30, "bindings_loaded": 42, "revocations_active": 0}Health and readiness
Section titled “Health and readiness”| Method | Path | Description |
|---|---|---|
GET | /health | Liveness; always 200 |
GET | /ready | Readiness; 200 when Postgres, Redis, and STS are reachable; 503 otherwise |