Part 5 of the MCP Security series. Parts 1-2 argued the design, Part 3 built and broke it, Part 4 signed it. This closing piece is the blueprint: the whole platform in one animated schema, then each block reduced to its minimal contract - the fields and checks you cannot skip, each traced to an RFC or a tested invariant. Everything deeper is folded away; open a cut only when you want the wire bytes, the war story, or the vendor matrix behind the rule.
How to read this page: the diagram above is the system - every green node enforces, every red exit is a call dying, each block links to its section. Per section you get a minimal contract table - implement exactly that and you have the security property - and the cuts hold the rest. The one-sentence thesis: one login is the product; one token is the vulnerability; every block exists to keep those two apart.
The platform promise, in one line: the agent never holds a credential that opens more than one door. One SSO login → many short-lived audience-bound tokens → every call mediated, checked, brokered, isolated, audited.
TL;DR - the minimal design, in five minutes
If you read nothing else, build this. It is the smallest configuration where every claim in this article still holds, and each line names the block that explains it.
The thesis in one line: one login is the product; one token is the vulnerability; every block below exists to keep those two apart.
The nine things that make it defensible. Drop any one and a specific attack comes back - the parenthetical is the block that tells you which:
- Backends on an internal network, no host-published ports, egress deny-by-default. Build this first; it is the only item that reduces risk on its own. (B6)
- One visible authorization server. The agent sees exactly one issuer; a
401carries RFC 9728 metadata naming it. Your IdP and every backend stay behind the gateway. (B1) - Validate
iss→aud→exp→scopeon every request, and never forward the agent’s token downstream. Audience validation is unconditional; token passthrough is the one thing that unravels everything else. (B1) - One invocation chokepoint. Every path - REST, native MCP, portal - funnels into one function. A second code path is where the bypass lives. (B3)
- Deny-by-default authorization at two levels: roles for route classes, and the same resolver for discovery and invoke, so what you cannot list you cannot call. (B2)
- The agent never holds a credential. The gateway either exchanges its token for a backend-audienced one (RFC 8693) or decrypts a stored credential just-in-time and injects it server-side. Plaintext lives for one call. (B5)
- Policy fails closed. Engine unreachable, malformed input, or an empty bundle →
503, never allow. (B3) - Nothing runs unscanned, and nothing stays approved forever. A day-1 scan gates quarantine exit; a schedule re-checks it. The scan is input to a human approval, never a substitute - and quarantine is the state a second human moves it out of, against pinned evidence. (B4, B8)
- Synchronous audit, hashed arguments, append-only at the database, shipped off-box. A call that cannot be audited does not proceed. (B7)
The minimum stack: an IdP whose client registrations you control, a secrets store, a policy engine, a database whose grants you own, and a container runtime with private networking. Nothing exotic - but an env var is not a secrets store, and append-only is a database grant, not a convention.
What you can defer without lying about what you have: RFC 8693 exchange (stored credentials cover most real backends, and are first-class here, not a fallback), the taint floor in enforce mode (notify first, with a recorded date it stops being acceptable), signed provenance attestations, and HA. What you cannot defer is items 1 through 5 - they are structural, and retrofitting any of them costs more than building the rest.
And the honest one: if you have one agent, one backend, one user and no credential worth stealing, build none of this. The design earns its complexity where several identities meet several backends whose credentials have different blast radius.
Prerequisites - what has to be true before Block 1
Nothing below is exotic, but each item is load-bearing in a way that only shows up at the wrong moment. Read this as the bill of materials, the identity question that decides your integration count, and the four organisational facts that decide whether the build is possible at all.
The five components you must have somewhere. An identity provider you control the client registrations of - not merely “we have SSO”, but the ability to register a confidential client, add a scope, and read the token you get back. A place to root the credential key hierarchy, and a decision about its shape - the two options are not interchangeable. Either the platform reads a root secret and does its own crypto (the reference build: Vault with AppRole), or the root never leaves the store and the platform sends material in to be wrapped and unwrapped. The first discloses every stored credential the moment the platform is compromised; the second discloses only what was unwrapped while the attacker held access, and each unwrap is a record on someone else’s log. Both are defensible, they are not the same, and Block 5’s contract reads differently under each - so pick deliberately and write down which one you built. An env var is neither. A policy engine you can push signed bundles to. A database whose grants you own, because Block 7’s append-only property is a grant, not a setting. A container runtime with private networking - the isolation block is unimplementable if every service must publish a host port.
The one-IdP case: how it actually works. Start with the good news, because it is the case worth designing for when you can get it. If the backend your MCP server calls is registered in the same IdP the user logs into, one IdP and one integration is enough. Three hops, one human login.
Notation first, because the RFCs don’t give these tokens names: T_mcp and T_backend are my shorthand for two ordinary OAuth 2.1 access tokens (RFC 6749 §1.4, JWT-profiled by RFC 9068) that differ only in their aud claim. Nothing in any RFC calls them that. What is specified is every parameter below.
- User → gateway. Authorization code + PKCE (RFC 7636, mandatory in OAuth 2.1) against the client-facing AS, discovered from the
WWW-Authenticatechallenge via RFC 9728 protected-resource metadata. The client sendsresource=<gateway URI>(RFC 8707); the AS then audience-restricts the token it issues to that resource. How it does so is the AS’s business - 8707 does not mandate copying the URI verbatim intoaud- but in every IdP in the matrix the observable result isT_mcpwithaud= the gateway. The only login the human ever sees. - Gateway → AS. RFC 8693 token exchange, and this is the hop worth knowing parameter by parameter:
grant_type=urn:ietf:params:oauth:grant-type:token-exchange,subject_token=T_mcp,subject_token_type=urn:ietf:params:oauth:token-type:access_token, plusaudienceand/orresourcenaming the backend, and ascope. The first three are what the RFC requires; the last three are OPTIONAL in 8693 and non-negotiable here - naming a backend audience and requesting a scope no wider than the subject token’s is the whole point of the hop, and an AS that ignores both hands you back something as powerful as what you sent. The AS returnsaccess_token+issued_token_type(both REQUIRED) - that isT_backend. Client authentication at the token endpoint is the ordinary OAuth rule, not an 8693 invention; making the gateway a confidential client is this design’s choice, and it is what the agent cannot forge: it does not hold that client secret. RFC 8693 §4.1 also defines theactclaim for recording the delegation chain - optional, and only ZITADEL implements it visibly among the eleven. - MCP server → backend.
T_backendgoes on the outbound call. The backend validates it by JWKS (RFC 7517 key set,at+jwttyp per RFC 9068) or by introspection (RFC 7662,active: true), checksaudis itself, and authorizes onsub- the user - not on the gateway.
Two credentials, one issuer, one connector. The two-token split is not a workaround for a vendor gap - it is the security property (Block 5: the agent never holds a credential that opens more than one door). What one IdP buys you is that both tokens come from the same place: one trust relationship to configure, one revocation surface, one place the user’s identity lives end to end.
What that IdP must have, and how the eleven actually implement it. The eleven are the ones fact-checked for Block 1’s matrix - Keycloak, PingFederate, Auth0, Entra ID, Okta, AWS Cognito, Ory Hydra, Spring Authorization Server, Authlete, ZITADEL, authentik - and the full grid with sources is in that cut. Step 2 is load-bearing: no exchange, no chain. Everything else decides how much you emulate:
| RFC | What it does for this design | How it lands in practice |
|---|---|---|
| RFC 8693 token exchange | The whole one-IdP case rests here: mints T_backend from T_mcp without the agent | Native: Keycloak (26.2+), PingFederate, Auth0, Authlete, Spring AS (1.3+), ZITADEL (with act). Not 8693: Entra ID ships proprietary OBO instead, Okta ships XAA / ID-JAG, Cognito, authentik and Ory Hydra have nothing (hydra#1218, open since 2018). No exchange → every OAuth backend degrades to a stored credential |
RFC 8707 resource | Binds aud to one backend so T_backend can’t be replayed at another | Native: PingFederate, Authlete, Cognito (tier-gated). Emulated: Keycloak - no resource, slipped three releases, documented substitute is an audience mapper. Trap: Auth0 honours it but a stray audience parameter silently overrides it. Rejects it outright: Entra (AADSTS901002), ZITADEL, authentik |
| RFC 9068 / RFC 7662 | How the backend verifies what it was handed | Both: Keycloak (signed JWTs but no at+jwt typ), PingFederate, Auth0, Okta, Authlete. Introspection missing: Entra, Cognito - and Entra’s aud shape is unstable, so backends there validate against a moving target |
| Refresh rotation + reuse detection | Contains a stolen refresh token | Universal across all eleven; Okta and Keycloak do strict one-time reuse detection. Where it’s weak, shorten every lifetime and write the gap down |
The honest summary of the matrix: not one of the eleven supports the whole workflow cleanly. Keycloak - the worked example above - has exchange, introspection, JWKS and strict rotation, so all three hops run, but audience binding is emulated in the gateway rather than requested on the wire. That is the shape of every row: you pick which RFC you emulate, not whether you emulate one. Test each capability with a live positive and a live negative call before it appears in a design document - metadata that advertises a grant is a claim, not a fact, and Block 1’s metadata mirage is exactly this failure.
Where one IdP stops being enough, and what the second integration is. The one-IdP case has a hard boundary: the backend must be a client of your IdP. The moment it is not - GitHub, Jira Cloud, a vendor SaaS, a Postgres box, anything with its own identity plane or none at all - step 2 has nowhere to go. Your IdP cannot mint a token for an issuer that does not trust it, and no amount of configuration changes that. You need a second integration, and it takes one of two shapes:
- The backend’s own OAuth. Each user authorises the platform at that backend’s authorization server, once, and the resulting refresh token is stored per user, encrypted, and exchanged for a fresh access token at call time. A second consent screen the user will see, and a second token lifecycle to own.
- Stored credentials, per user or per service. Backends with no delegated flow at all - a database, an internal API with a static key, a legacy vendor. A per-user credential preserves who did what; a service account collapses attribution to the platform and has to be compensated with tighter policy and audit.
Both live in Block 5, both are typed at onboarding, and both are normal - most real estates have more backends in this category than in the first. Plan for two integrations by default; treat the single-IdP case as the reward for a homogeneous estate rather than the assumption the design rests on.
Four organisational prerequisites, and these are the ones that actually kill projects. A dual-control approver pool - Block 3’s quarantine gate and Block 4’s approval both assume a second human exists and answers; one person with two accounts is not dual control, it is a checkbox. Somewhere to send audit that is not the platform: a SIEM or log store on a different access boundary, or Block 7’s tamper story is only as good as the operator’s restraint. Named credential owners for every backend, because Block 5’s stored-credential path silently becomes a graveyard of unowned secrets otherwise - the rotation path has to belong to someone before the credential exists. And an onboarding process with a queue and an SLA, because a security gate whose median wait is a week is a gate engineers route around, and a routed-around gate is worse than no gate: it moves the traffic somewhere you cannot see it.
What you do not need, so you don’t over-scope: a service mesh (nice hardening, not identity - Part 3 demoted mTLS deliberately), a dedicated MCP registry product, Kubernetes (compose with internal networks satisfies every isolation contract here), or an LLM in the security path. The manifest audit optionally uses one; nothing else does, and nothing fails closed on its absence except the audit you explicitly required.
Block 1 · Authentication & identity
The property this block buys: every request carries a verified identity, and the agent only ever sees one authorization server - the enterprise IdP and every backend stay behind the gateway on the credential plane. An access token is a credential for a protected resource (RFC 6749 §1.4); RFC 9700 §4.10.2 requires resource servers to reject tokens not minted for them.
| Minimal contract - you need at least this | Why / source |
|---|---|
401 + WWW-Authenticate: Bearer resource_metadata="…" on unauthenticated MCP calls; Protected Resource Metadata with exactly one authorization_servers entry | RFC 9728; MCP auth spec (2026-07-28) |
OAuth 2.1 authorization-code + PKCE S256 for every interactive client; no client secrets in agent config | RFC 7636; OAuth 2.1 |
Validate on every request, in order: iss exact → aud contains this resource → exp/iat → scope; reject on any miss | RFC 9068 §4; MCP MUST-validate-audience |
Emit RFC 9207 iss + advertise authorization_response_iss_parameter_supported; validate client-side with strict string compare | RFC 9207 - SHOULD today, spec says MUST is coming |
| Multiple inbound auth methods (mTLS CN header, session JWT, IdP bearer, API key) in a fixed priority order, first match wins; a forgeable header trusted only with a constant-time-compared gateway shared secret | reference spec 01-authentication.md §1; fail-closed when the anchor is unconfigured |
| Static inbound API keys, if you keep them, are the weak leg: they are the anti-pattern gallery’s static-token bridge wearing a lanyard. Scope them to machine callers that genuinely cannot do OAuth, give them an owner, an expiry and a revocation path, and accept that revocation is only as fast as your cache TTL | see the identity-to-static-token bridge below - the defect isn’t the key, it’s the key that outlives the session |
Session tokens carry jti, checked against a revocation store on every request, fail-closed | INV-014 |
Bearer tokens in the Authorization header only - never query strings, never logs | RFC 6750; MCP spec |
Durable identity keyed on (iss, sub), never email | emails get renamed and reassigned; OIDC Core |
The full flow on the wire - discovery → PKCE → token → exchange → validation (7 HTTP messages)
Discovery - the 401 that teaches the client where authority lives:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata=
"https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://idp.example.com/"],
"scopes_supported": ["mcp.tools.list", "mcp.tools.invoke"],
"bearer_methods_supported": ["header"]
}
Authorization request - the resource parameter (RFC 8707) makes the token audience-bound; MCP clients MUST send it:
GET /oauth/v2/authorize?
response_type=code&client_id=<MCP_CLIENT>&
redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Fcallback&
scope=openid%20mcp.tools.list%20mcp.tools.invoke&
resource=https%3A%2F%2Fmcp.example.com%2Fmcp&
code_challenge=<S256_CHALLENGE>&code_challenge_method=S256&
state=<RANDOM_STATE>
Host: idp.example.com
Token response - short-lived access token, rotating refresh token:
{
"access_token": "<T_MCP>",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "<ROTATING_REFRESH_TOKEN>",
"scope": "mcp.tools.list mcp.tools.invoke"
}
The downstream exchange and backend validation live in Block 5 - they belong to the broker.
The anti-pattern gallery - five ways real deployments fake "one token"
The UserInfo oracle. “Validating” a token by calling OIDC UserInfo proves only that the token is acceptable at UserInfo - its own protected resource per OIDC Core §5.3. Accept any token UserInfo accepts and any token issued to any client of that IdP becomes a credential for your API - cross-client token substitution, the textbook confused deputy.
The identity-to-static-token bridge. A legacy API “supports SSO” by redeeming the OIDC code, reading the email, and minting its own long-lived static API token - which outlives the session, survives logout, and is revoked never. Bonus defect: account binding by mutable email instead of (iss, sub).
The double-redeem. Two components both redeem the user’s authorization code. Codes are single-use, bound to one client and redirect URI (RFC 6749 §4.1.2); with PKCE the second redeemer doesn’t even hold the verifier. One code, one redeemer - every further token comes from a new grant.
The passthrough. Forwarding the agent’s MCP token to a downstream API. The MCP best-practices doc names this the token-passthrough anti-pattern: audience validation breaks end to end and the audit trail says the agent did it.
The metadata mirage. Discovery advertises token exchange, introspection, revocation; the routes return 404. RFC 8414 metadata describes capabilities - nothing forces it to be true. Trust a grant only after a live positive and negative test.
Which IdP actually supports this - fact-checked vendor matrix + shortlist (11 IdPs, July 2026)
Fan-out research across vendor docs, independently fact-checked; two verdicts overturned, both corrections kept visible. âť“ = not verifiable from docs. Baseline: the current MCP spec (2026-07-28) marks DCR deprecated, makes CIMD the SHOULD, accepts RFC 8414 or OIDC discovery, puts the RFC 8707 MUST on clients, and adds RFC 9207.
| IdP | 8707 | 8693 | 7662 | 9068/JWT | DCR | Rotation | MCP-specific |
|---|---|---|---|---|---|---|---|
| Keycloak (26.4→26.7) | ❌ slipped 3 releases; milestoned 26.8.0 (#14355); audience-mapper is the documented substitute | ✅ since 26.2 | ✅ | ⚠️ signed JWTs, no at+jwt | ✅ | ✅ strict one-time | ⚠️ official page; CIMD experimental in 26.6 |
| PingFederate | âś… native | âś… | âś… | âś… | âś… anonymous | âś… | âś… PingGateway ships an MCP gateway product |
| Auth0 | âś… toggle - but audience silently overrides resource | âś… | âś… | âś… | âś… off by default | âś… | âś… Auth for MCP GA May 2026; PRM + CIMD |
| Entra ID | ❌ rejects resource (AADSTS901002) | ⚠️ proprietary OBO | ❌ | ⚠️ unstable aud shapes | ❌ (compliant - DCR deprecated) | ✅ | ⚠️ App Service publishes PRM |
| Okta | ❌ static per custom AS (paid add-on) | ✅ XAA / ID-JAG | ✅ | ✅ | ⚠️ pre-registration only for MCP | ✅ + reuse detect | ⚠️ OIE registration; heavy XAA investment |
| AWS Cognito | ✅ Oct 2025 (tier-gated) | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ |
| Ory Hydra | ⚠️ audience native | ❌ #1218 open+unplanned since 2018 - fact-check overturned an earlier ✅ | ✅ | ⚠️ | ⚠️ empty-URI bug | ✅ | ⚠️ community only |
| Spring Auth Server | ⚠️ community module only, “not officially endorsed”, all-clients-all-resources | âś… core, since 1.3 | âś… | âś… | ⚠️ same module | âś… | ⚠️ same module |
| Authlete | âś… end-to-end documented | âś… since 2.3 | âś… | âś… | âś… | âś… | âť“ |
| ZITADEL | ❌ “Currently not supported” | âś… v2.49, act delegation | âś… | âś… | âť“ | âś… | ❌ |
| authentik | ❌ | ❌ | ✅ | ✅ | ❌ (#8751) | ✅ | ❌ |
Newcomers - WorkOS (DCR + 8707 + CIMD, middleware mode; does not host your RFC 9728 metadata), Stytch, Scalekit, Descope; the fact-check also surfaced Curity (8693 + policy-controlled DCR), Casdoor (new MCP-auth profile, untested), Cloudflare workers-oauth-provider (CIMD + 9728, Workers-only). Google Cloud Identity Platform excluded - it issues OIDC ID tokens, not audience-scoped access tokens; scoring it would be a category error.
Shortlist. Self-hosted free: Keycloak (translate resource → audience-mapper in the gateway; treat the 26.8.0 milestone as unreliable). Commercial self-hosted: PingFederate, or Authlete as an AS engine if you’re building the gateway rather than buying one. SaaS: Auth0 (enable DCR explicitly; reject requests carrying audience), challenger WorkOS. Enterprise: your gateway as the AS, federating upstream to Entra/Okta - Entra’s real blockers are resource rejection, no introspection, unstable aud; discovery and DCR are compliant under the current spec. For cross-domain delegation watch Cross-App Access / ID-JAG (draft-ietf-oauth-identity-chaining).
Four build constraints: (1) audience validation on ingress is unconditional here - a token whose aud isn’t you is rejected, no policy knob, no opt-out. RFC 9068 puts that MUST on JWT-access-token resource servers; the MCP spec puts it on you regardless of token format. The knob is a narrower thing: whether you also reject clients that fail to send the RFC 8707 resource parameter on the way out. That MUST lands on clients, and old ones don’t honour it, so gate it per-client and log the laggards - but never let a missing resource request turn into an unvalidated aud on the way back; (2) CIMD first, DCR as deprecated fallback - with SSRF controls, the AS fetches an attacker-supplied URL by design; (3) implement RFC 9207 now - it is a SHOULD today and costs nothing to honour, and a mix-up attack you didn’t defend against reads the same in an incident report either way; (4) the two non-negotiables - audience validation on ingress, no token passthrough - sit on the gateway; no IdP choice relieves you of them.
Acceptance tests before you claim this block works
- Discovery: the MCP 401 carries the RFC 9728 metadata URL; metadata names the exact resource URI; every advertised IdP endpoint has a positive and a negative test.
- Code + PKCE: reused code fails; wrong verifier fails; wrong redirect fails.
- MCP token: wrong-audience token → 401; missing scope → 403; the MCP token appears in no downstream header, log, or tool result.
- Identity:
(iss, sub)mapping survives an email change; revokedjtirejected on the very next request; disabled user fails even with an active token.
Block 2 · RBAC & entitlement
The property this block buys: knowing who you are never implies what you may touch. Roles gate route classes; entitlement gates individual servers - and what you cannot discover, you cannot invoke.
| Minimal contract - you need at least this | Why / source |
|---|---|
| Roles are DB-authoritative; roles inside an external IdP token MUST NOT augment them outside dev | JWT-role-escalation guard - an IdP misconfig must not mint admins |
| Deny-by-default per route class; the public-path allowlist is explicit and byte-identical between the auth layer and the RBAC layer | a prefix wildcard is how protected routes leak |
| Entitlement: discovery == invoke. Server-linked tools invokable only via the same entitlement resolver that decided listability - no role exception, admin included | spec 03 §1 stage 4 - one resolver, two gates, or listing leaks capability |
| RBAC answers “who may hit this route class”; per-call argument policy stays in the policy engine (Block 3) | spec 03 §2.4 - RBAC = who you are; OPA = what this call does |
Details - separation rationale, escalation guard, components
The separation matters because the layers fail differently: RBAC is cheap, cacheable, evaluated before the body is parsed; argument-aware policy is per-call. Collapse them and you either slow every request or make authorization decisions on unparsed input.
The escalation guard exists because of a real failure shape: an enterprise IdP group-sync misconfiguration stamping admin into a token claim. If token roles were additive, that misconfig becomes an escalation the platform can’t see. Internal proxy-minted session JWTs MAY carry roles; external tokens never add any.
The entitlement rule closes the subtler gap: without it, an admin bypass on invoke means quarantined servers are callable by anyone with the right role - and “the same controls exist on both paths” is weaker than “both paths execute the same code” (Part 3, verbatim).
Components: gateway-internal RBAC tables (reference impl.), Casbin, OpenFGA / SpiceDB for relationship-based models. Keep the resolver in one module whichever you pick.
Block 3 · Security checks: quarantine · policy · content trust
The property this block buys: every call - REST or native MCP - funnels through one chokepoint where a fixed, fail-closed pipeline runs in a defined order. No second path, no equivalent copy.
| Minimal contract - you need at least this | Why / source |
|---|---|
| Single invocation chokepoint. Every entry path funnels to one function; no second code path to a backend | spec 03 §1 - Part 3’s lesson: the copy is where the bypass lives |
| Quarantine gate: onboarded servers’ tools quarantined until dual-control approval; credential mode typed against IdP mode, mismatch fails onboarding | Part 3 onboarding matrix; InvalidOnboardingConfig, fail-closed |
Policy engine, deny-by-default: one input document per call; no wildcard allow; engine unreachable / malformed / {"result": null} (startup race) → 503, not allow | spec 03 §2.2 - the empty-bundle race is a real fail-open |
| Policy bundles signed by default; grants pushed to the engine before DB commit, push failure → 503 + rollback | spec 03 §2.3-2.4 (INV-012) |
| Injection screen: exactly one canonical phrase list mirrored into every enforcement point, with a CI test that fails on divergence | spec 03 §3.1 - two lists always drift; the test is the control |
Taint floor: effective_integrity = min(rank of consumed sources); privileged call below the floor is denied; marker write before forwarding; store errors fail closed both ways | Part 4, Biba 1977 - write-before-forward is the whole control |
Reference-implementation status, so you can calibrate the last row: the taint floor ships off (
TAINT_FLOOR_ENABLED=false) and, when enabled, defaults toTAINT_FLOOR_MODE=notify- it audits the violation and allows the call.enforce(deny, JSON-RPC-32003) is one setting away and is what the contract above means;notifyis what you get out of the box, deliberately, because a floor you turn on blind will deny work you didn’t know was tainted. Runnotifywith an expiry recorded at the moment you enable it - a date, in the config, not a reminder in someone’s calendar - and treat reaching that date with the floor still in notify as an unenforced control in your own posture reporting, not as a configuration you chose. Without the date this is where taint enforcement goes to live permanently: detecting, permitting, and counted in the dashboard as a control that stops the thing it only watches. The contract is the destination; the default is the on-ramp; the date is what keeps it an on-ramp rather than a lay-by.
Details - OPA input contract, injection patterns, taint mechanics
OPA decision semantics. One input document per call - identity, roles, tool, arguments, anomaly score - expecting an explicit allow. Three failure shapes all deny: engine unreachable (503), malformed JSON (503), and the subtle one - {"result": null} / {} from a bundle that hasn’t loaded, which reads as “no policy said no” if absence is treated as allow. Anomaly scoring is advisory input only: scorer failure defaults to 0.0 and MUST NOT block - heuristics inform policy, they don’t become policy.
Injection screening. Categories: instruction overrides (“ignore previous instructions”), exfil verbs aimed at credentials, LLM template markers ([inst], ### system:). The single-source-of-truth rule matters more than the list’s cleverness. Screening is a tripwire, not a boundary: Hines et al., Defending Against Indirect Prompt Injection Attacks With Spotlighting (Microsoft, 2024), report reducing attack success “from greater than 50% to below 2% in our experiments” - real mitigation, not zero, and measured on their attack corpus and their models, which is not yours.
Taint mechanics. The marker is written before the low-trust result is forwarded, so there is no window where the agent acts on planted instructions before the platform knows it read them. Two modes - notify (default, audits) and enforce (denies, JSON-RPC -32003). Signed envelopes (Part 4) extend this across trust boundaries; inside one gateway they add nothing - which is why the envelope is not in this block’s minimal contract.
Block 4 · Server vetting & supply chain
The property this block buys: nothing runs behind the gateway that hasn’t been scanned, pinned, and approved - and nothing stays approved just because it passed once. Runtime mediation (Block 3) exists because scanners miss semantic capability; scanning exists because runtime controls shouldn’t be the first time you learn a server ships a credential stealer. Day 1 gates the door; the schedule keeps it gated.
| Minimal contract - you need at least this | Why / source |
|---|---|
| Day-1 scan gate: no server leaves quarantine without a scan verdict attached to the approval - SBOM at scan time, dependency-CVE scan, MCP-aware static analysis, manifest audit. Scan results are input to the dual-control human approval, never a substitute | reference impl.: syft CycloneDX at scan time + signed per-tool SBOM at approval (INV-006); registration-time OPA-static + LLM manifest audit |
| SBOM per server, machine-readable (CycloneDX or SPDX), stored with the submission and downloadable from the review card | CycloneDX / SPDX; NTIA minimum elements |
| Dependency CVEs from ≥2 sources (ecosystem-specific + broad OSV), evaluated at scan time and re-evaluated when advisory data updates - a clean scan is a timestamp, not a property | reference impl.: pip-audit + OSV-Scanner |
| MCP-aware SAST: a rule pack targeting what generic SAST misses - exfil patterns, SSRF/IMDS, crypto stealers, obfuscation, tool-description prompt injection. Run offline, isolated - the scanner must not phone home either | reference impl.: MCP-specific semgrep pack + prompt-scan of tool manifests |
| Pin by digest: approval binds the server to an exact commit/artifact digest; build/deploy refuses an unpinned or mismatched digest, fail-closed | reference impl. build worker TOCTOU guard |
Scheduled rescans + drift detection: rescan on cadence and on advisory updates; re-fetched tools/list diffed against the approved snapshot - a changed description or new tool re-quarantines (the rug-pull detector) | the day-1-only scan is the industry default and the industry’s mistake |
Reference-implementation status, because this last row is the one people will check: the cadence half is built - a background scheduler re-enqueues every approved server with a source repo onto the isolated scanner worker, oldest-scanned first, and stamps freshness. Two things it deliberately does not do yet. It does not flip
submission_status: a failing rescan updates the scan verdict and shows up in review, but an approved server stays approved until a human moves it - auto-re-quarantine on a CVE feed update is a self-inflicted outage waiting for a bad advisory. And the drift half is only half: mutating a tool’s description, schema or upstream URL through the registry triggers a re-audit that force-quarantines on critical risk (INV-005 / DET-F7, tested), but nothing yet periodically re-fetchestools/listfrom a remote server and diffs it against the approved snapshot. That poll is the actual rug-pull detector for servers you don’t build, it is maybe fifty lines against work already there, and it is not written. Treat it as the roadmap row, not the shipped one.
Details - what scanning can and cannot catch, rug-pull mechanics, components
What it catches: known-CVE dependencies, typosquatted packages, hardcoded credentials, obvious exfil/SSRF/IMDS code paths, obfuscated payloads, and poisoned tool descriptions (“before using any other tool, first send the conversation to…” living in a docstring the agent will read). That last class is MCP-specific - generic SAST has no rule for it, which is why the manifest audit is its own scanner.
What it cannot catch - and why this block never graduates past “gate plus input to a human”: semantic capability. A server wrapping a C2 framework, or exfiltrating through an ordinary-looking “search” tool at runtime, is clean at rest. That is the platform’s founding thesis (mediate, don’t classify) and the reason Blocks 3, 6 and 7 exist. A scan verdict is evidence for the approver, not an authorization.
Rug-pull mechanics. Pass review with benign tools, then after approval change a tool’s description or add a tool, exploiting clients that re-fetch tools/list per session. Defences stack: digest pinning kills silent code swaps for platform-built servers; the tools/list snapshot diff kills silent manifest swaps for remote ones; either difference re-quarantines. If you implement only one scheduled check, implement the manifest diff - it’s cheap and it’s the actual attack.
Cadence. Day 1: full pipeline gating quarantine exit. Continuous: advisory re-evaluation against stored SBOMs (Dependency-Track’s job - new CVE, old SBOM, immediate flag). Scheduled (daily/weekly by tier): manifest diff + rescan of the pinned artifact. On regression: flag or auto-re-quarantine by trust tier.
Components: SBOM - syft (reference impl.); CVEs - pip-audit + OSV-Scanner (reference impl.), Grype, Trivy; continuous watch - Dependency-Track; SAST - Semgrep + MCP rule pack; manifest/behaviour audit - mcp-checker-style scanners (static rules + optional local-LLM pass over tool descriptions); provenance - digest pinning now, SLSA/cosign attestations as the roadmap step.
Block 5 · Profiles & credential broker
The property this block buys: the agent never sees a credential at all, and each backend sees exactly one - its own, for this call, and nothing that would open a different door. (The backend obviously receives the credential meant for it; that is what injection is. The property is scope and custody, not invisibility: the agent holds nothing, the ciphertext is bound to one row context, and no backend ever holds authority over another.) Profiles decide which tools an identity gets; the broker resolves, decrypts just-in-time, injects server-side, and forgets.
| Minimal contract - you need at least this | Why / source |
|---|---|
| Named-profile default-deny: no assigned profile → no tools, not all tools; profile resolution failure → empty set | spec 12 Fix 1 - the absent-profile fallback is a silent allow-all |
Typed injection modes, resolution order tool → server default → none; empty string MUST NOT collapse to none; unknown mode fails closed | spec 02 §3 - enum parse failure = deny, not passthrough |
Crypto chain: Vault-held master secret (≥256-bit, absent-only seeding, never rotated in place) → per-identity HKDF-SHA256 KEK (fresh salt per blob) → AES-256-GCM with AAD binding ciphertext to row context (user_sub · service · tool_id · owner_type) | spec 02 §2 - AAD makes a stolen row useless in any other context |
Plaintext lifetime = one call: decrypt JIT, inject as a header into the single upstream request, zero buffers in finally | spec 02 §1 (CB-F004) |
OAuth backends: RFC 8693 exchange, gateway authenticating as itself; issued token carries only the backend audience, plus the act delegation claim where the IdP emits one (8693 makes act OPTIONAL, and most don’t - Block 7’s separate principal/client fields are the fallback, and the part you actually control); never returned to the agent under any code path | RFC 8693; MCP no-passthrough MUST |
| Non-IdP backends are first-class, not a fallback. A backend with its own auth - per-user API key or bearer token, service account, Basic - gets a stored credential: enrolled (user self-service) or uploaded (admin, dual-control), encrypted through the same single codec with row-binding context, injected server-side. The original IdP is not in this path, and the agent still never sees the value | spec 02 §3 - this is most real backends |
Resolution precedence fixed and tested: per-user row (owner_type='user', keyed by caller sub) wins over the shared service row; missing required row → ServiceCredentialMissingError, never a silent fall-through to another identity’s credential | spec 02 §3 - falling back across owner rows is a cross-user credential leak |
| Stored ≠immortal: every stored credential carries owner, service context, and a revocation/rotation path; expiry or revocation fails closed - never a silent downgrade to a stale copy or another row | the static-token bridge anti-pattern, prevented at the store this time |
Broker unavailable (no Vault token, master secret 404) → every credentialed tool fails closed; only injection_mode=none proceeds bare | spec 02 §2.1 - misconfig must not degrade to unauthenticated forwarding |
The RFC 8693 exchange on the wire + backend validation options
POST /oauth/v2/token HTTP/1.1
Host: idp.example.com
Authorization: Basic <GATEWAY_CLIENT_CREDENTIALS>
Content-Type: application/x-www-form-urlencoded
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&
subject_token=<T_MCP>&
subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&
requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&
resource=https%3A%2F%2Fapi.internal.example.com%2F&
scope=backend-a.read
{
"access_token": "<T_BACKEND_A>",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 600,
"scope": "backend-a.read"
}
The IdP’s policy on this endpoint is the security model: only the registered gateway may exchange; subject token active and MCP-audience; requested scope within the subject’s authorization; issued token carries only the backend audience; actor recorded (delegation, not impersonation).
Backend validation - introspection (RFC 7662) for opaque tokens, or local RFC 9068 JWT validation against the IdP JWKS (no round-trip, slower revocation - short lifetimes become mandatory). Either way reject wrong issuer, wrong audience, missing scope, expiry; map users by (iss, sub). Part 4’s boundary test: introspection three lines after the mint adds nothing; it pays at the backend boundary, where a token arrives from outside the process that minted it.
Zero-trust edges are not OAuth. A ZTA admission credential is a separate control on a separate boundary - two headers, two meanings, two lifecycles - or place the gateway on an approved service path. Deployment decision, not an OAuth shortcut.
When the backend has its own auth - per-user tokens, service accounts, API keys (the common case)
Most real backends don’t speak your IdP’s OAuth - they have their own auth: a per-user PAT, a service-account key, a static bearer token, HTTP Basic. The broker treats these as first-class citizens of the same machinery, and the security property survives unchanged: the credential lives encrypted in the platform, is decrypted for one call, and the agent never sees it.
Two enrollment paths, one codec. A service credential (one shared secret for the backend) is uploaded by an admin under dual control and linked to the tool’s registry row. A per-user credential (the user’s own PAT) is enrolled by the user through the portal, keyed to their (iss, sub). Both write through the single AES-256-GCM codec with the row-binding AAD (user_sub · service · tool_id · owner_type) - a service row decrypts only in service context, a user row only for that user, a stolen ciphertext is useless anywhere else.
Injection shapes (each fail-closed): Authorization: Bearer <stored> or a custom header (X-API-Key) via inject_header override; basic_auth - stored as structured {"username","secret"}, never a prebuilt header, Basic base64(…) built at injection time, and neither the pair nor its base64 may ever appear in logs, audit, or error text; service-account client-credentials against the backend’s IdP (which may be a different IdP entirely - Entra client-credentials is one typed mode). Missing service name → raise; missing row → ServiceCredentialMissingError; malformed payload → raise without a payload excerpt.
Mode resolution is tool-level → server default → none, strict enum parse: empty string or unknown mode fails closed. The onboarding matrix (Block 3) types the mode against the identity configuration at registration, so “per-user token mode without a user-credential source” is rejected before the server goes live, not discovered at first call.
Profiles answer a different question than RBAC: RBAC says “may this identity call invoke at all”; the profile says “which named set of tools does this identity get”. Both default-deny. Keep profile→tool resolution in one resolver reused for discovery and invoke (Block 2’s rule).
Exactly one ciphertext codec. The reference implementation once had two (admin writes vs broker reads) - every stored credential failed closed with InvalidTag at injection time. The fix was deletion, not a bridge. If you inherit two codecs, one is the bug.
Block 6 · Isolation
The property this block buys: a fully hostile backend MCP server gets nothing - no raw credential, no reachability except through the proxy, no lateral movement. Isolation is what makes every other block’s guarantee stick.
| Minimal contract - you need at least this | Why / source |
|---|---|
| Backends on an internal network with no inbound route except from the proxy; the proxy initiates every connection | platform thesis - mediate, don’t classify |
| No host-published ports for backends, policy engine, or secrets store - in every compose/manifest variant, including dev overrides | Part 3’s lesson: a dev override re-published OPA’s :8181 unauthenticated |
| Secrets store reachable only from the proxy; HTTPS outside development, rejected at config load otherwise | spec 02 §2.1 (CB-009) |
| Backend egress deny-by-default; upstream needs get an explicit named allow | exfil path: a “search” tool that quietly calls home |
| SSRF guard at invoke: re-resolve the backend URL at call time and pin the IP (closes register-then-rebind TOCTOU) | Part 3 onboarding hardening |
Details - compose/K8s/mesh mappings and the dev-override trap
Compose/Podman: internal: true networks for backends; only the edge publishes ports. Kubernetes: default-deny NetworkPolicy both directions, per-backend egress allows, no NodePort/LoadBalancer except the edge. Mesh: proxy↔backend mTLS is fine as hardening - but it is not the identity layer (Part 3 demoted it deliberately; don’t promote it back by accident).
The dev-override trap is how this fails in practice: the hardened file is correct, then a dev overlay or “temporary” debug port republishes an internal service on the host. The reference fix was a regression check that greps every compose variant for published ports on internal services and fails CI. Isolation you don’t continuously verify is a diagram, not a control.
Block 7 · Audit
The property this block buys: every decision every block above made is reconstructable afterwards - without the audit trail itself becoming the credential leak.
| Minimal contract - you need at least this | Why / source |
|---|---|
| Audit writes synchronous with the decision - a call that can’t be audited doesn’t proceed | INV-001; async audit loses exactly the events you need |
| Arguments stored as keyed hashes, never raw; no token, credential, or its base64 in any audit row, log line, or error message | redaction invariant - errors are the leak path everyone forgets |
Every record carries the human and the machine separately: principal (iss, sub) and principal type, the acting client, roles, session jti, tool, server, decision + reason codes, policy decision ID, correlation ID | delegation must read “gateway, on behalf of user” - not “the agent did it”. At the token layer this is RFC 8693’s act; at the audit layer it is a distinct principal/client pair, and one without the other is an unattributable event |
Taint events audited with their own semantics (tainted flag, notices, resulting floor); a notify-mode allow is flagged as an allow-with-notice, never as a plain allow | spec 12 Fix 7 - this was a real defect: the notify path once wrote outcome=allow with the notice buried in deny_reasons, which is both a lie and unqueryable |
| Structured JSON to stdout, SIEM-shippable; audit table append-only at the database, not by convention | INV-011: the app role is granted INSERT only, with an immutability trigger - the writer literally cannot UPDATE or DELETE its own history |
Details - what to alert on, and the error-message leak class
Alert-worthy out of the box: repeated wrong-audience 401s (token replayed across doors), scope-escalation attempts at the exchange endpoint, quarantine-gate denials for an approved-looking server, taint-floor denials (a low-trust read followed by a privileged call attempt is the injection kill-chain, mid-flight), and any 503 from the policy engine (fail-closed events are availability incidents and security signals).
What “tamper-evident” honestly means here, because this is the row everyone overclaims. Append-only at the database is real and enforced by grants, not by discipline. Everything above that is weaker than the word suggests. The archive bucket uses object-lock in GOVERNANCE mode, which a sufficiently privileged key can bypass - that is not WORM; COMPLIANCE mode is the production-correct setting and the reference build ships the lab one. There is no hash chain and no Merkle sequence over the event stream, so a rogue database superuser is out of scope for detection; the design defends against the application compromising its own history, not against the DBA. The transparency log is a stub. If your threat model includes the platform operator, you need an external append-only sink - ship to the SIEM synchronously and treat the SIEM copy, on someone else’s access boundary, as the authoritative one. Say which of these you have before anyone signs an audit finding against it.
The error-message leak class: exception text embedding the payload it failed to parse. The reference rule - malformed credential payloads raise without an excerpt - generalizes: never interpolate user- or credential-adjacent bytes into an exception string, because exception strings end up in logs, logs end up in the SIEM, and the SIEM has more readers than the vault.
Block 8 · Server onboarding & lifecycle
The property this block buys: a server is unreachable from the moment it is submitted until a second human approves it against pinned evidence, and one action makes it unreachable again. Block 4 produces the evidence; this block owns the states. It is last in the article and earliest in the lifecycle, because four other blocks read the row it writes: entitlement resolves against it (B2), the quarantine gate reads its state (B3), the broker reads its injection mode (B5), egress policy reads its declared allowances (B6). A server onboarded with no integrity level, no trust tier and no declared egress is a server those four treat as unconstrained - not because they are broken, but because they are configured by a row nobody filled in.
| Minimal contract - you need at least this | Why / source |
|---|---|
| Quarantine on entry, and no timer leaves it. A submitted server permits no operation to any principal, administrators included; an unreviewed submission stays unreachable indefinitely and never times out into approved. Permitted transitions are one table, not checks scattered across the workflow | timeouts that expire toward reachable are the default shape most workflow engines produce; exactly one state is reachable, and the check is an equality, not a set membership test |
| Everything submitted is untrusted input. Addresses fetched under the egress controls; names and descriptions screened as content that may carry instructions aimed at the agent or at the reviewer; analysis builds run under Block 6’s isolation with no platform credential | the scanner executes submitted code - a scanner with a registry credential and open egress is a better target than anything it scans |
| Registration refuses to complete with a gap. Injection mode and its credential source, content integrity level and per-operation required levels, trust tier, declared egress allowances, may-initiate flag - all set, or no registration. Mode-versus-source mismatch is rejected here, not discovered at first call | each missing field disables a different block silently; the mode mismatch surfaces later as a uniform “credential not provisioned” and gets investigated as a broker bug |
| The server holds no credential between requests. It needs none, or it is given one per call: the broker adds it server-side after the agent’s request arrives and clears it when that request returns. On the platform-built path this is enforced at launch - no backend credential in the image, the environment, a config file or a mounted volume. On the URL-supplied path it cannot be enforced, only declared, so it becomes a question the security review has to answer before approval, alongside the scan verdict | every access to a backend has to be authenticated as someone, and attributable to the caller who caused it. A server holding its own long-lived key satisfies neither: the backend sees the server, not the user, and the platform cannot scope, expire or revoke what it did not mint. Compromise it once and you hold the credential itself rather than one call’s worth of authority |
| Discovery during quarantine is mediated, not exempted. Enumerating a quarantined server’s surface goes through the same chokepoint under a verification principal no external caller can assume, scoped to defined verification operations, audited with its own reason code; results are evidence and never enter a session | the tempting fix is a verifier that talks to the backend directly. That is a second unaudited route to every backend, built by you, in the one component that talks to servers nobody has approved |
| Dual control, and record what was approved. The approver resolves to a different human than the submitter, machine principals resolved to their owners first. The record carries the pinned digest, the scan verdict identifier, the trust tier and the approved surface snapshot - not just approver and timestamp. Superseded evidence needs a new approval | one person with two accounts is not dual control. And “approved by A at time T” cannot answer the only question an incident asks: was the thing running the thing that was approved |
| Verification gates release, one code path for both shapes. Reachability, surface discovery, a bounded operation probe, protocol-contract validation - all through the mediated path, any failure keeps the server unreachable. The platform-built and URL-supplied paths share one function; where a check is genuinely impossible, its absence is recorded rather than branched around | the probe is what catches credential injection wired to the wrong mode and network policy that permits the handshake and blocks the body. Bound it to declared-safe operations, or the verifier is a general-purpose backdoor with a workflow wrapped around it |
| Re-quarantine is immediate; decommissioning is a state, not a delete. Re-quarantine invalidates caches rather than waiting for expiry; decommissioning revokes grants and credentials before stopping the workload, keeps the audit and approval history, and never reissues the identifier | if your chokepoint holds a five-minute cache, re-quarantine takes five minutes, and that is the number for the runbook. A stale grant naming payments-server becomes a live grant the moment someone registers that name again |
Reference-implementation status: release is enforced inline in the generic registry PATCH path. A bare admin cannot release a tool whose parent server is unapproved or whose scan failed, so the gate itself holds - but there is no dedicated release endpoint, no
released_by/released_at, and no distinct release audit event. Which means the “record what was approved” row above is a contract the reference build does not yet meet through that path: it records that a release happened, not the digest, verdict and snapshot it happened against. It is written down inopenspec/traceability.mdas an open item rather than smoothed over, because this is the row where a design document would normally claim a property the code does not have.
Details - the contradiction in the verification path, why credentials are an onboarding decision, and why identifiers are never reused
The apparent contradiction. Read “a quarantined server permits no operation to any principal” and “verification requires invoking an operation on a quarantined server” together and the block looks unbuildable. Every implementation reaches the same fork, and one branch is much worse than it looks.
The wrong branch is a bypass: a code path inside the verifier that reaches the backend directly, skipping enforcement, justified by the fact that verification is supposed to touch unapproved servers. You now have two routes to every backend, and the second one is unaudited, unpolicied, and lives in the component whose whole job is talking to code nobody has vetted.
The right branch is a named exception evaluated at the same chokepoint, with four properties: it runs under a verification principal that no external caller can obtain or assume - not an admin account, not a service account with a password someone holds; policy states it explicitly as this principal, on this server, in this state, for these operations, and everything else still denies; the call is audited with a reason code that distinguishes it from an ordinary invocation; and the results never enter a session, so a discovered surface is evidence for a reviewer rather than a capability an agent can now reach. If your policy language cannot express “principal X may do Y only while the server is in state Z”, fix that before you build verification - every workable engine can.
Why “the server holds no credential” is an onboarding row and not only a broker row. Block 5 describes the mechanism - exchange or just-in-time decrypt, injected server-side, plaintext alive for one call. But the broker can only inject into a server that was registered as needing injection. A server that authenticates itself, out of an environment variable someone set at deploy time, never reaches the broker at all: its outbound calls carry authority the platform did not mint, cannot scope to the calling user, cannot revoke by ending a session, and will not recognise in an audit trail. It is not a weaker version of the brokered path, it is off it. That decision is made once, at registration, and it is nearly irreversible in practice - by the time thirty servers are live with their own secrets, retrofitting the broker means renegotiating thirty credentials with their owners.
So onboarding types the mode strictly and refuses the gaps: an unknown or empty injection mode fails closed rather than collapsing to “no credential required” - none is a real mode for backends that need no authentication at all, and it has to be chosen rather than fallen into. A per-user mode declared against a server with no per-user credential source is rejected at registration rather than discovered on the first call, where it surfaces as a uniform “credential not provisioned” and gets debugged as a broker fault for a day. The corollary for the credentials the platform does store: they get an owner at registration too, because a stored credential with no named owner has no rotation path and quietly becomes permanent.
Where the review has to do the work the platform cannot. For a server you build, “holds no credential” is a launch-time fact. For a server supplied as a running URL, it is a claim by whoever operates it, and no scan or probe will tell you what is in that process’s environment. The honest handling is the same as everywhere else in this design: do not report a claim as a verified property. Make it an explicit question the approver answers on the record - who authenticates this server to its backend, as whom, and can we revoke it - and let the answer set the trust tier. A server that authenticates itself with a key you cannot see is not disqualified; it is a different risk, and the point is that the difference is written down at approval rather than assumed away.
Identifier reuse is the same bug class as principal identifier reuse in Block 1. Grants, stored credentials, approval records and surface snapshots all name a server by its identifier; decommission one and re-register the name, and the new server silently inherits every one of them. Use identifiers that are never reissued and keep the human-readable name as a separate, non-authoritative label.
Not on the diagram, deliberately. Onboarding is a lifecycle, not a hop on the call path, so it has no node in the schema above - the same reason vetting and audit sit off to the side. Its output is the registry row every node on that path reads.
The final design - what actually gets deployed
Eight blocks is the logical view. This is the physical one: what runs where, what may talk to what, and what order to build it in so that stopping halfway still leaves you safer than when you started.
Three planes and one edge
Every component belongs to exactly one plane, and the plane decides its reachability. This is the whole deployment model:
- Edge - the only thing with a published port. TLS terminates here; unauthenticated requests get the RFC 9728 401 and go no further.
- Agent plane - where a request is about an identity: the gateway’s ingress, RBAC, entitlement, the checks pipeline. Nothing here holds a long-lived secret.
- Credential plane - the broker, the secrets store, the IdP-facing token exchange. Reachable only from the gateway process, never from a backend, never from the host.
- Backend plane - MCP servers. No inbound route except from the proxy, egress deny-by-default. Assume every member is hostile; the design is only interesting if that assumption is load-bearing.
| Component | Plane | Published to host? | If you skip it |
|---|---|---|---|
| Edge / TLS terminator | Edge | yes - the only one | you have no boundary |
| Gateway proxy (ingress, RBAC, entitlement, chokepoint, broker) | Agent + credential | no | there is no platform |
| Policy engine | Agent (control) | no - this is Part 3’s actual incident | argument-level authorization becomes code |
| Secrets store | Credential | no | credentials live in env vars, which is where they leak |
| Database (registry, profiles, audit, ciphertext) | Control | no | - |
| Scanner worker (isolated, own egress net) | Control, sandboxed | no | Block 4 is a promise |
| Build worker | Control | no | digest pinning has no enforcer |
| Audit sink + archive (log store, object store, dashboards) | Observability | dashboards only, behind auth | Block 7 is a log file |
| MCP backends | Backend | no | isolation is a diagram |
The reference deployment runs eleven-plus services across eight internal: true networks precisely so that “no published port” is a property of the file rather than a habit - with a CI check that greps every compose variant, including dev overrides, and fails the build when an internal service republishes itself. That check exists because the dev override is how this fails, every time.
One call, end to end
The blueprint compresses to fifteen steps. Each maps to a block; each can only fail closed:
- Agent calls the MCP endpoint with no token → 401 + resource metadata (B1).
- Agent discovers the one authorization server, runs authorization-code + PKCE with
resource(B1). - Agent presents the access token. Gateway validates
iss→aud→exp→scope, resolves the principal on(iss, sub), checks the sessionjtiagainst the revocation store (B1). - RBAC: may this principal reach this route class at all? DB-authoritative roles only (B2).
- Entitlement: is this server visible to this principal? Same resolver that filtered
tools/list(B2) - discovery and invoke cannot disagree, because they are one function. - Every path - REST, native MCP, portal - arrives at the single invocation chokepoint (B3).
- Quarantine gate: is this tool approved, and is its onboarding configuration internally consistent? (B3 enforces the state, B8 set it)
- Injection screen over arguments, from the one canonical phrase list (B3).
- Taint check: does the caller’s effective integrity floor permit a privileged call? Marker written before anything forwards (B3).
- Policy engine: one input document, explicit allow required; unreachable, malformed, or empty-bundle → 503 (B3).
- Profile resolution: which named tool set does this identity have? No profile → no tools (B5).
- Broker: exchange (RFC 8693) or decrypt a stored credential just-in-time, bound by AAD to this exact row context (B5).
- Inject server-side into the single upstream request; re-resolve and pin the backend IP at call time; zero the buffers in
finally(B5, B6). - Backend validates the token it received on its own boundary and returns (B5).
- Audit writes synchronously, before the response leaves - principal and acting client separately, hashed arguments, decision and reason codes, taint state (B7).
Steps 3 through 10 are pure rejection logic and cost single-digit milliseconds. Step 12 is the only one holding plaintext, and it holds it for the duration of one HTTP request.
Build order
Build it in the order that leaves each stopping point defensible, not in the order that demos best:
- Isolation and the edge first (B6). Backends with no host ports, egress deny-by-default. This alone, with no other block, materially reduces risk - and retrofitting it once thirty servers exist is the migration nobody funds.
- Ingress authentication + audit (B1, B7). Identity and a record of it. You now know who did what, which is the precondition for every argument that follows.
- The single chokepoint (B3, structural half). One function every path funnels to - before you have anything interesting to put in it. Building the chokepoint after the checks is how the second code path is born.
- RBAC and entitlement (B2). Cheap, and closes the listing-leaks-capability gap.
- The broker (B5). The largest single lift, and the one that removes agent-held credentials. Start with stored credentials for real backends; add RFC 8693 exchange where the IdP actually supports it.
- Policy engine and injection screening (B3, remainder). Per-call argument policy, once the chokepoint is proven.
- Onboarding states and vetting (B8, B4). The state machine and its quarantine gate first, then the scans that feed the approval - the states are worth having with a human reading a scan verdict by eye; the scans are worth little without a state that holds the server unreachable until someone reads them. Late, because they gate onboarding: build them before there is anything to onboard and they gate nothing but your own progress.
- Taint floor in
notify(B3), promoted toenforcebefore the expiry you recorded when you switched it on.
Lab, not production - the honest delta
The reference implementation is a personal open-source build that runs on one machine. It is a faithful implementation of every contract above and it is not a production deployment, so here is the delta rather than a disclaimer:
| What the lab does | What production needs |
|---|---|
| Object-lock in GOVERNANCE mode on the audit archive | COMPLIANCE mode, and an external sink outside the operator’s control |
Taint floor off by default, notify when enabled | enforce, after a bounded tuning period with real traffic - bounded meaning a recorded date that alerts, not a judgement call |
| Rescan on cadence; failing rescans surface in review | An explicit auto-re-quarantine policy per trust tier, with the outage risk accepted deliberately |
| Registry-mutation rug-pull detection | Plus the scheduled remote tools/list snapshot diff - the unwritten fifty lines |
| Digest pinning at build/deploy | Signed provenance attestations (SLSA, cosign) |
| Single-node compose, one operator | HA for the gateway and policy engine; the chokepoint is a single point of failure by design, which means it is a single point of outage too |
| Lab IdP | Your enterprise IdP, with the four capabilities in Prerequisites actually tested |
| One approver | A real dual-control pool with an SLA |
None of these change a contract. They change who you can defend the deployment against.
When this is the wrong design
The bear case, stated plainly because a blueprint without one is marketing.
The chokepoint is the cost. Every call pays validation, policy evaluation and synchronous audit. If your workload is thousands of low-value tool calls per second, this architecture is the wrong shape - you want the enforcement at a coarser boundary and sampling instead of synchronous audit, and you should say so rather than quietly making audit async, which deletes exactly the events you built it for.
One agent, one backend, one user, no secrets worth stealing? Build none of this. The design earns its complexity at the point where multiple identities meet multiple backends with credentials of differing blast radius. Below that line it is ceremony.
The standards are moving under it. DCR went deprecated inside a single spec cycle; CIMD is young; RFC 9207 is a SHOULD that is expected to become a MUST; cross-app access (ID-JAG) may make part of the broker’s downstream story obsolete in the good way. Anything here traced to an RFC will age slowly; anything traced to the MCP spec should be re-read every release.
And the honest sunset condition: if IdPs ship native audience-bound, exchange-capable, MCP-aware authorization - and the vendor matrix suggests two or three are trying - then Blocks 1 and 5 shrink to configuration and this platform becomes a thin policy-and-audit layer over someone else’s identity plane. That would be a good outcome. Blocks 3, 4, 6 and 7 do not go away in that world, because no IdP will ever mediate a tool call, vet a server’s supply chain, or keep a hostile backend off your network. Those four are the durable half. Build them like they have to outlive the other three.
Close
Eight blocks, one promise: the agent never holds a credential that opens more than one door. Authentication makes identity real and keeps one issuer visible; RBAC and entitlement decide reach; the checks pipeline judges every call at one chokepoint; vetting scans what comes in on day 1 and keeps scanning it after; profiles and the broker mint and inject narrow, short-lived authority - exchanged tokens for OAuth backends, encrypted per-user and service-account stores for everything else; isolation makes the guarantees stick against a hostile backend; audit proves it all happened the way the diagram says; and onboarding holds every server unreachable until a second human approves it against pinned evidence, which is what makes the other seven configurable at all. Each block’s minimal contract is small - a dozen fields and checks traced to RFCs - and each is load-bearing: remove any one row and a specific, named attack comes back.
The reference implementation is open at github.com/webr0ck/mcp-security-platform, and two things in it matter more than the Python. openspec/specs/ is every contract above restated as normative requirements with pass/fail scenarios - eight capabilities, 94 requirements, 323 scenarios, vendor-neutral, in the OpenSpec format so a coding agent can build against it directly. That is the design as it should be. docs/spec/ is the same system as it actually ships, gaps and incident history included. Build from the first; check yourself honestly against the second, and expect the delta - the one I published above is mine.
I write about cyber security - detection, secure architecture, and the tools I build in the open - at purplehootie.com. Views my own.
Standards referenced
| Reference | Role in the design |
|---|---|
| RFC 6749 - OAuth 2.0 | Roles, authorization codes, access/refresh tokens |
| RFC 6750 - Bearer tokens | Header transport; no query-string tokens |
| RFC 7009 - Revocation | Token revocation - what logout and incident containment call |
| RFC 7591 - Dynamic Client Registration | Deprecated fallback behind CIMD |
| RFC 7617 - HTTP Basic | One typed injection mode, redaction-guarded |
| RFC 7517 - JSON Web Key Set | The key set backends validate JWTs against |
| RFC 7636 - PKCE | Code interception defence; single redeemer |
| RFC 7662 - Introspection | Opaque-token validation at the backend boundary |
| RFC 8414 - AS Metadata | Discovery; must be truthful |
| RFC 8693 - Token Exchange | The broker’s downstream minting grant |
| RFC 8707 - Resource Indicators | Audience binding per resource |
| RFC 9068 - JWT access tokens | Local validation alternative to introspection |
| RFC 9207 - Issuer Identification | Mix-up defence; SHOULD becoming MUST |
| RFC 9700 - OAuth Security BCP | Audience restriction, refresh rotation, replay |
| RFC 9728 - Protected Resource Metadata | The 401 that teaches the client where authority lives |
| OpenID Connect Core 1.0 | Authentication vs authorization; UserInfo scope |
| MCP Authorization spec (2026-07-28) | Audience MUSTs; CIMD; DCR deprecated; RFC 9207 |
| MCP Security Best Practices | Token passthrough and confused deputy |