External Access

PRECINCT was designed for internal agents with SPIFFE identities. But production systems need to expose agents to external clients: partner APIs, web frontends, mobile apps, third-party tools. External access gives these callers a standards-based path through the full enforcement chain without weakening any security guarantees.

The Problem

Internal agents authenticate via mTLS with SPIFFE SVIDs. Every request carries a cryptographically verified identity. But external callers do not have SPIFFE sidecars. They live outside the trust domain. Without a secure bridge, you face two bad options:

  • Expose the MCP server directly and lose all governance: no identity verification, no policy enforcement, no DLP, no audit trail.
  • Build a custom proxy that re-implements authentication, authorization, and audit. Expensive, fragile, and likely incomplete.

PRECINCT provides a third option: external callers authenticate via standard OAuth 2.0 or token exchange, the gateway maps them into the internal identity model, and the full 13-layer middleware chain applies to every request. Same policies, same DLP scanning, same audit logging. The upstream MCP server never sees the difference.

How It Works

sequenceDiagram participant Client as External Client participant AS as Authorization Server
(Auth0, Keycloak, Okta) participant GW as PRECINCT Gateway participant OPA as OPA Policy participant MCP as MCP Server Client->>AS: Request token (client_credentials, auth code, etc.) AS-->>Client: JWT with sub, iss, aud, scope Client->>GW: MCP request + Authorization: Bearer JWT GW->>GW: Validate JWT (signature, issuer, audience, expiry) GW->>GW: Map sub to spiffe://domain/external/subject GW->>GW: Extract and validate scopes GW->>OPA: Full policy evaluation (identity + tool + scopes) OPA-->>GW: allow / deny GW->>GW: DLP scan, rate limit, audit log Note over GW: Strip Authorization header GW->>MCP: Forward with internal identity headers MCP-->>GW: Response GW-->>Client: Response

The critical design point: the gateway is an OAuth 2.0 Resource Server (RFC 6750), not an Authorization Server. It validates tokens but never issues them. Token issuance, user sessions, and login flows are handled by your existing identity provider. PRECINCT does not replace it.

OAuth 2.0 Resource Server

The primary authentication path for external clients. The gateway validates JWT bearer tokens against a configured JWKS endpoint from your Authorization Server.

What the Gateway Validates

JWT validation checks
Check Claim Failure
Signature JWKS key matching 401 (supports RS256, ES256, PS256, EdDSA, and more)
Issuer iss 401 if it does not match configured issuer
Audience aud 401 if it does not match configured audience
Expiration exp, nbf, iat 401 if expired or not yet valid (with configurable clock skew)
Subject sub 401 if missing (required for identity mapping)
Scopes scope or scp 401 if required baseline scopes are missing

Identity Mapping

After validation, the JWT sub claim is mapped to a SPIFFE ID in the external/ namespace:

spiffe://poc.local/external/<subject>

This mapped identity flows through the entire middleware chain. OPA policies can grant or restrict access based on spiffe://*/external/* patterns, applying the same policy framework used for internal agents. External callers are first-class citizens in the identity model, not a special case.

Introspection Fallback

For Authorization Servers that issue opaque tokens instead of JWTs, the gateway supports RFC 7662 token introspection as a fallback. If a bearer token is structurally not a JWT, the gateway calls your AS introspection endpoint to validate it. If the token is a JWT but fails validation (wrong issuer, expired, bad signature), it is rejected immediately without falling back to introspection.

Configuration

# config/oauth-resource-server.yaml
oauth_resource_server:
  issuer: "https://your-as.example.com"
  audience: "gateway"
  jwks_url: "https://your-as.example.com/.well-known/jwks.json"
  required_scopes:
    - "mcp:tools"
  clock_skew_seconds: 30
  cache_ttl_seconds: 60

Token Exchange

For tools and services that do not have an OAuth Authorization Server, the gateway provides a token exchange endpoint. A caller presents a pre-shared credential (e.g., an API key), and the gateway issues a short-lived JWT bound to a SPIFFE identity.

Token exchange is designed for internal integrations and development environments. For production external access, the OAuth 2.0 Resource Server path is recommended.

Flow

# 1. Exchange credential for a token
TOKEN=$(curl -s -X POST http://gateway:9090/v1/auth/token-exchange \
  -H "Content-Type: application/json" \
  -d '{
    "credential_type": "api_key",
    "credential": "your-api-key-here",
    "requested_ttl": "5m"
  }' | jq -r '.token')

# 2. Use the token for MCP requests
curl -s http://gateway:9090/ \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}'

Security Properties

  • Credentials are never stored in plaintext. The configuration file stores bcrypt hashes. The gateway compares incoming credentials against these hashes.
  • Tokens are short-lived. Default TTL is 15 minutes, maximum 1 hour. Each token has a cryptographically random jti (token ID).
  • Tokens carry an auth method marker. precinct_auth_method: token_exchange propagates through the middleware chain, allowing OPA policies to distinguish how the caller authenticated.
  • Tokens are HMAC-SHA256 signed. The signing key is provided via the TOKEN_EXCHANGE_SIGNING_KEY environment variable.

Discovery (RFC 9470)

Before authenticating, external clients can discover the gateway's OAuth requirements via the protected resource metadata endpoint, following RFC 9470.

curl -s http://gateway:9090/.well-known/oauth-protected-resource | jq .
{
  "resource": "gateway",
  "authorization_servers": ["https://your-as.example.com"],
  "scopes_supported": ["mcp:tools"],
  "mcp_endpoint": "/"
}

Clients use the response to configure their token requests: authorization_servers[0] tells them where to get tokens, resource is the audience parameter, scopes_supported lists the baseline scopes, and mcp_endpoint is the JSON-RPC path.

If OAuth is not configured on the gateway, this endpoint returns 404, accurately signaling that the resource does not advertise OAuth requirements.

Scope Enforcement

Scopes are enforced at two layers, providing defense in depth:

OAuth scope enforcement layers
Layer Scope Required For Enforced By
Gateway mcp:tools All requests (baseline) JWT validation middleware
OPA mcp:tools:call Any tools/call invocation OPA policy rule
OPA mcp:tool:<name> Per-tool access OPA per-tool scope rule

The scope hierarchy is intentional. A token with only mcp:tools can list available tools but cannot invoke them. Adding mcp:tools:call enables invocation, but only for tools whose per-tool scope is also present. This follows the principle of least privilege: request only the scopes your client needs.

OPA Grant Example

# config/opa/tool_grants.yaml
- spiffe_pattern: "spiffe://poc.local/external/*"
  description: "External OAuth users -- minimal tools, deny-by-default"
  allowed_tools:
    - tavily_search
  max_data_classification: public
  required_scopes:
    tavily_search:
      - "mcp:tool:tavily_search"

This grant allows external callers to use tavily_search only if their token includes the mcp:tool:tavily_search scope. All other tools are denied. The max_data_classification: public restriction ensures external callers cannot access internal or confidential data regardless of tool grants.

Security Boundaries

Token Non-Passthrough

The gateway strips the Authorization header before forwarding requests to the upstream MCP server. The upstream never sees the bearer token. Instead, it receives pre-authenticated, pre-authorized requests with internal identity headers (X-Precinct-Auth-Method, X-Precinct-Principal-Level). This is a deliberate security boundary: the upstream MCP server does not need to understand OAuth at all.

Principal Levels

External callers are assigned the lowest principal level (level 4). This means they cannot perform destructive operations, messaging, or administrative actions regardless of their tool grants. These actions require higher-privilege identities (agent or owner level) that can only be obtained through SPIFFE-authenticated paths.

Rate Limiting

External clients are subject to per-identity rate limiting applied to their mapped SPIFFE ID (spiffe://domain/external/subject). Each external user has an independent token bucket (default: 600 requests per minute, burst 100).

Public Edge Routes

For production deployments, only three routes are exposed on the public listener:

Public edge route allowlist
Path Method Purpose
/ POST MCP JSON-RPC endpoint
/health GET Health check (no auth required)
/.well-known/oauth-protected-resource GET OAuth discovery

All administrative endpoints (/admin/*, /v1/auth/token-exchange, /data/dereference) are excluded from the public ingress and accessible only via the internal mTLS listener.