Security in MCPG is layered. Each layer has a specific job; together they form the production-readiness floor. This article walks through each layer, what it does, and why it exists.
Layer 1: Identity
Every request resolves to a Caller before anything else happens. The five identity
plugins (api-key, basic, mtls, oidc, workload) all produce the same
Caller shape:
struct Caller {
subject: String, // "user:alice@acme.com" or "spiffe://acme.org/sa/cron"
audience: Option<String>,
attributes: HashMap<String, Value>,
auth_method: AuthMethod, // ApiKey | Basic | Mtls | Oidc | Workload
}
Why this matters: every downstream layer sees the same struct. Policy plugins don't
care whether the caller authenticated via OIDC or mTLS — they just see subject: "user:alice@acme.com" and decide.
Failure modes guarded against:
- SSRF on JWKS URLs (OIDC plugin uses an SSRF-safe HTTP client)
- DNS rebinding to internal hosts (gateway-wide DNS guard)
- Replay attacks (JWT
iat/expvalidated; bearer tokens checked against a TTL'd cache) - Header injection from upstream proxies (mTLS plugin requires trusted-proxy CIDR list; rejects header from anywhere else)
Layer 2: Policy
Policy plugins receive Caller + Request + Resource and return Decision:
enum Decision {
Permit,
PermitWithObligations(Vec<Obligation>),
Deny(DenyReason),
}
Three engines: Cedar, OPA, Casbin. They compose — chain them and every engine must
permit. Obligations stack: Cedar might require redacting email; OPA might require
redacting phone; both happen.
Why three engines: teams have different opinions about policy languages, and migration between engines is painful. We support all three so you can pick what your team already knows.
Per-tool policy is the killer feature. You can write:
permit (
principal,
action == Action::"github.delete_repo",
resource
)
when { context.approval_token != null };
…and chain a tool-gate-slack-approval plugin that intercepts before dispatch,
fires a Slack interactive message, blocks the call until a human clicks
"approve", then sets context.approval_token. No code changes needed.
Layer 3: Capabilities
Plugins declare what host capabilities they need:
{
"id": "redact-emails",
"required_capabilities": [
"cap.host.outbound_http",
"cap.host.metrics_emit"
]
}
The gateway refuses to start if a plugin requires a capability the operator hasn't
explicitly granted in plugins.capability_grants.<id>.
The well-known capability set:
cap.host.outbound_http— make HTTP calls to anywherecap.host.outbound_network— open TCP / UDP socketscap.host.tls_accept— terminate TLS (for binding plugins)cap.host.filesystem_read/cap.host.filesystem_writecap.host.environment— read env varscap.host.metrics_emit/cap.host.structured_loggingcap.host.secret_store— read from the secret resolvercap.host.http_route_override— register HTTP routes on the gateway listenercap.host.client_cert_acceptor— receive client certs from the listenercap.host.unix_socket_client— open AF_UNIX sockets
Capabilities are enforced by the loader. WASM plugins are sandboxed at the runtime level (no syscalls except those exposed by the host). Native plugins are constrained by Rust's lack of unsafe escape hatches in the SDK API surface — but we trust them more than WASM, so you should sign and pin native plugin sources.
Layer 4: Plugin signing
Every plugin artifact is Ed25519-signed. The gateway's plugin policy specifies a list of trusted public keys; loading rejects any artifact not signed by a trusted key.
plugins:
require_signed: true
trusted_keys:
- id: prod-signing-key
public_key: |
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA...
-----END PUBLIC KEY-----
Signing keys can live in:
- Local file — fine for dev
- AWS KMS / GCP KMS / Azure Key Vault —
mcpg-plugins sign --kms - Hardware Security Module — Enterprise BSL-1.1 module
A separate MCPGRevocationList CRD lets operators block specific artifact hashes
fleet-wide without redeploying. The gateway pulls the revocation list on startup and
on every config update.
Layer 5: Audit ledger
Every policy decision, every plugin lifecycle event, every operator action writes an audit row. The ledger is:
- Per-org chained — each row references the previous row's hash via BLAKE3
- Ed25519-signed — chain head signed periodically
- Retention-bounded — Community 30d, Pro 90d, Team 180d, Enterprise 7 years
- Tamper-evident — modifying any row breaks the chain; tampering is detectable
The audit chain is per-org because cross-tenant tampering would be trivially detectable across orgs (you'd have to forge multiple signatures simultaneously). The HSM signing module (Enterprise BSL-1.1) makes that economically infeasible.
Layer 6: Transport
Inbound transport is Streamable HTTP + SSE — the MCP spec's transport. TLS is handled by the gateway listener directly or terminated by an upstream proxy with mTLS headers re-injected.
Outbound transport varies by binding (HTTP, gRPC, NATS, etc.). The gateway pins TLS when configured to do so:
bindings:
- id: github
type: http
base_url: https://api.github.com
tls:
pin_cert_sha256: a8f7b3...
Pinning prevents a compromised CA from MITM'ing your upstream tools.
Layer 7: Secret resolution
Secrets never live in config files. The gateway resolves them at runtime via:
plugins:
- id: api-keys
config:
tokens:
- $secret://vault/kv/data/mcpg/api-keys/prod#token
The secret-vault plugin handles the URI scheme. Lease auto-renewal (for dynamic
DB credentials), native event watch (Vault 1.13+), and TTL-bounded caching are
all built in.
What's not yet a layer (Phase E)
A few things are on the roadmap, not in production:
- Source-side payload encryption — currently CP encrypts payloads at rest; Phase E will move encryption to the gateway, so the CP never sees plaintext.
- Gateway-side quota refusal — currently CP drops over-quota batches at ingest; Phase E adds pre-dispatch refusal.
- WASM cp-client plugin path — currently cp-client is in-process Rust under a Cargo feature; Phase E will let it ship as a plugin.
See docs/control-plane/FUTURE.md in the repo for full Phase E scope notes.