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
(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
| 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_exchangepropagates 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_KEYenvironment 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:
| 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:
| 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.