Skip to content

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.


The Gateway applies five checks in order. The first failure halts the request.

Reads Authorization: Bearer <token>. Returns 401 if the header is missing, not in Bearer format, or exceeds 4 096 bytes.

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.

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.

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.

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.


HeaderRequiredDescription
AuthorizationYesBearer <ambient-token>
X-Caracal-ResourceYesResource 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.


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/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<ambient-bearer>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
zone_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.


After a successful exchange, the Gateway sends the request to the upstream URL returned by the STS.

Authorization header:

auth_modeHeader sent
caracal_jwtAuthorization: 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:

HeaderValue
X-Request-IdPreserved or generated request ID
TraceparentW3C trace context derived from the request ID
X-Forwarded-ForClient IP (replaced, never appended)
X-Forwarded-Protohttps or http
X-Forwarded-HostOriginal Host header
X-Caracal-Token-Expires-InSeconds 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.


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_ALLOWLIST if that variable is set.

Violations return 403 AccessDenied.


ConditionStatusBody
Missing Authorization header401{"error": "InvalidToken"}
Bearer > 4 096 bytes401{"error": "InvalidToken"}
Malformed JWT401{"error": "InvalidToken"}
Token expires within 35 s401{"error": "CredentialExpired"}
JWT signature invalid401{"error": "InvalidToken"}
JTI replay (per-call token reused)401{"error": "InvalidToken"}
Session revoked401{"error": "InvalidToken"}
X-Caracal-Client-ID present400{"error": "InvalidToken"}
Missing X-Caracal-Resource400{"error": "InvalidToken"}
Path traversal detected400{"error": "InvalidToken"}
No binding for resource403{"error": "AccessDenied"}
SSRF guard violation403{"error": "AccessDenied"}
STS exchange returned 4xx401/403STS error forwarded
STS timeout504{"error": "GatewayTimeout"}
Request body > MAX_REQUEST_BYTES413{"error": "RequestTooLarge"}
Upstream unreachable502{"error": "BadGateway"}
Upstream timeout504{"error": "GatewayTimeout"}
Client disconnected499

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: true is 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.


The Gateway maintains an in-memory revocation cache populated from the caracal.sessions.revoke Redis stream. Stream consumer details:

ParameterValue
Streamcaracal.sessions.revoke
Consumer groupgateway-revocation
Consumer IDgateway-{hostname}-{pid}
Batch size50 messages
Block duration1 second
Pending reclaimXAUTOCLAIM 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).


VariableRequiredDefaultDescription
STS_URLYesSTS base URL for token exchange and JWKS
DATABASE_URLYesPostgres for resource→binding lookup
REDIS_URLProductionRedis for JTI replay detection and revocation
STREAMS_HMAC_KEYProductionHex-encoded HMAC key (≥32 bytes) for stream message verification
TLS_CERT_FILEProductionTLS certificate path
TLS_KEY_FILEProductionTLS private key path
INSECURE_HTTPNofalseAllow plaintext HTTP (development only)
INSECURE_STSNofalseAllow HTTP to STS (development only)
UPSTREAM_HOST_ALLOWLISTNoComma-separated list of allowed upstream hosts
ALLOW_PRIVATE_UPSTREAMSNofalsePermit forwarding to private/loopback IPs
MAX_REQUEST_BYTESNo10485760Maximum request body size (10 MiB)
STS_TIMEOUTNo5sPer-request STS timeout
UPSTREAM_TIMEOUTNo30sPer-request upstream timeout
JTI_FAIL_OPENNofalseIf true, allow traffic when Redis is unavailable; forbidden in production

{
"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
}

MethodPathDescription
GET/healthLiveness; always 200
GET/readyReadiness; 200 when Postgres, Redis, and STS are reachable; 503 otherwise