BetaMCPG is in public beta. Join the waitlist for managed cloud + early-access features.
MCPG
beta
All articles
2026-04-22Security8 min

The MCPG security model

How identity, policy, capabilities, signing, and audit fit together — and why each piece exists.

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:

rust
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/exp validated; 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:

rust
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:

cedar
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:

json
{
  "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 anywhere
  • cap.host.outbound_network — open TCP / UDP sockets
  • cap.host.tls_accept — terminate TLS (for binding plugins)
  • cap.host.filesystem_read / cap.host.filesystem_write
  • cap.host.environment — read env vars
  • cap.host.metrics_emit / cap.host.structured_logging
  • cap.host.secret_store — read from the secret resolver
  • cap.host.http_route_override — register HTTP routes on the gateway listener
  • cap.host.client_cert_acceptor — receive client certs from the listener
  • cap.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.

yaml
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 Vaultmcpg-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:

yaml
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:

yaml
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.