Drawbridge

MCP Auth Gateway · architecture

What this is

Drawbridge is one MCP gateway that lets an AI agent use a modern API and a legacy SAP-style system through a single endpoint — without the agent ever holding a backend credential, and without a machine being able to commit a high-risk write on its own.

Compromising the gateway does not let it execute a high-risk write, because the gateway cannot produce an approver signature.

Three signing keys sit in three separate trust domains, and the approver’s key is hardware-backed, human-held and non-exportable. It is not derivable from either server key.

Where it runs

Three Cloudflare Workers in one account. No database, no customer data, no third-party calls.

Deployed components
Component Address Responsibility
Gateway Worker drawbridge.inspired-sandbox.com MCP endpoint POST /mcp; the OAuth authorization server (/oauth/authorize, /oauth/token, /.well-known/oauth-authorization-server, /.well-known/jwks.json, /.well-known/oauth-protected-resource); the /demo/* API; and these static pages. A Durable Object named EventLog holds all demo state.
Upstream ERP Worker drawbridge-upstream-erp.super-extreme.workers.dev An independent OAuth-protected MCP resource server holding fake ERP data. It verifies the gateway’s machine token itself; it does not trust the gateway by network position.
Legacy SAP mock Worker drawbridge-sap-mock.super-extreme.workers.dev A fake SAP OData service: HTTP Basic authentication plus the classic CSRF fetch-then-write ritual, including the 403 that a stale token produces.

System and data-flow map

Every arrow crosses a trust boundary, and every arrow carries a different credential. The agent’s token is never forwarded to a backend.

Figure 1 — components, credentials and direction of travel
Drawbridge system and data-flow map An MCP client on the left connects to the Gateway Worker in the centre over two paths: the OAuth authorization server, and the MCP endpoint using Token B. A human approver, also on the left, sends a WebAuthn assertion to the gateway's adapter. On the right, the gateway calls the Upstream ERP Worker with a separate client-credentials token, and the Legacy SAP Worker with injected HTTP Basic credentials and a CSRF token. Gateway Worker drawbridge.inspired-sandbox.com OAuth authorization server /oauth/authorize · /oauth/token · jwks.json MCP endpoint POST /mcp — Token B only, iss + aud pinned Policy decision point read / low / HIGH → allow · deny · approval_required Ledger reserved → executing → committed (→ ambiguous) Adapter — the last hop before a backend re-verifies the approval independently, enforces the cross-binding matrix, consumes it once EventLog — Durable Object auth events · proposals · ledger entries passkey public keys · short-lived OAuth codes Static pages the live dashboard and this page Signing keys are Worker secrets — never in the repository. MCP client Claude, or MCP Inspector Holds a short-lived, purpose- limited user token. Never sees an ERP or SAP credential. Human approver Platform passkey (Touch ID) Hardware-backed. The private key never leaves the device and is not derivable from any server key. Upstream ERP Worker OAuth-protected MCP resource server. Verifies the token against published JWKS. Legacy SAP Worker OData service, HTTP Basic. Requires the CSRF token and the session cookie together, or answers 403. 1 — Client OAuth auth code + PKCE (S256) 2 — tools/call Bearer Token B, ES256 Token B0 is rejected here 3 — Human approval WebAuthn assertion over the exact statement displayed 4 — Upstream OAuth client-credentials aud = upstream ERP user token not forwarded 5 — Legacy SAP HTTP Basic injected CSRF fetch, then write 403 → exactly one retry
  • Client OAuth — user to gateway
  • Upstream OAuth — gateway to ERP
  • Legacy SAP — gateway to SAP
  • Human approval — passkey signature

In plain language

The agent logs in like a normal app user and gets a pass that works only for this task. It hands that pass to the gateway — and that is as far as it goes. When the gateway needs the modern system it collects its own separate pass for that system. When it needs the old SAP system it uses a username and password the agent never sees. And if the request would change data in SAP, nothing happens until a person approves it with their fingerprint.

  1. The client completes OAuth 2.1 with PKCE against the gateway’s own authorization server, which issues the token chain.
  2. Tool calls arrive at /mcp carrying Token B. It is verified statelessly with ES256, and both issuer and audience are pinned.
  3. For a high-risk write the human signs a statement with a passkey; the assertion goes to the adapter, not to the backend.
  4. Reads of the modern system use a machine token minted for the ERP’s audience alone, which the ERP verifies for itself.
  5. SAP writes run through the adapter, which injects the Basic credentials and performs SAP’s CSRF ritual.

The three auth lanes

Three callers, three credential types, three verification paths. Audiences are never crossed.

Lane A · user → gateway

Client OAuth

OAuth 2.1 authorization code with PKCE (S256), then a delegated token chain:

  • Token A — the user, delegated.
  • Token B0 — bootstrap, scope task:initiate and nothing else.
  • Task Envelope — signed; names which tools and scopes this task may use.
  • Token B — operational; aud is urn:drawbridge:edge:demo, carries task_envelope_digest, and its act chain names the runtime.

/mcp accepts Token B only. B0 is rejected at every ordinary tool. A bad or expired token returns 401 with a WWW-Authenticate header carrying resource_metadata; if the verifier itself is unavailable the answer is 503 with retry-after, never a 401.

Lane B · gateway → ERP

Upstream OAuth

The gateway mints a separate machine token with the client-credentials grant, audienced to the upstream Worker.

The upstream Worker verifies that token independently against the gateway’s published JWKS, with issuer and audience pinned. It does not trust the caller because of where the request came from.

The user’s token is never forwarded upstream. Audiences are never crossed.

Lane C · gateway → SAP

Legacy SAP

The adapter injects HTTP Basic credentials the agent never sees, then performs SAP’s CSRF ritual:

  • A GET carrying x-csrf-token: Fetch returns a token plus a SAP_SESSIONID cookie.
  • The POST must carry both, or SAP answers 403 CSRF token validation failed.
  • One retry on 403 — re-fetch the token, write once more, then stop.

The high-risk approval sequence

Reads flow straight through. sap_create_order is classified HIGH RISK, so the agent may only propose — it can never dispatch.

Figure 2 — from tool call to committed write, with the human in the middle
High-risk approval sequence A sequence across six participants: MCP client, gateway, ledger, human approver, adapter and legacy SAP. The client proposes a write; the policy decision point classifies it high risk; the ledger reserves it; the human signs the approval statement with a passkey; the adapter independently re-verifies the assertion, enforces the cross-binding matrix and consumes the approval once; only then does the SAP write run and the ledger commit. MCP client the agent Gateway MCP + policy Ledger durable state Human approver passkey Adapter the last hop Legacy SAP OData 1 — tools/call sap_create_order Bearer Token B 2 — policy decision HIGH RISK → approval_required 3 — reserve · state = reserved identity + canonical_payload_hash SHA-256 over RFC 8785 (JCS) payload 4 — proposal_id returned · nothing written 5 — the dashboard shows the write-effective fields signing base = SHA-256("rimini-approval-v1" ‖ 0x00 ‖ JCS(statement)) that signing base IS the WebAuthn challenge — domain-separated, so it cannot be replayed 6 — assertion signed on the device user verification and user presence both required 7 — Approval Envelope + self-contained execution proof statement + assertion travel together 8 — re-verify, independently recompute the signing base; check challenge, origin, RP ID, UV + UP, ES256 9 — bind, then consume cross-binding matrix must match, then consume durably — one dispatch, ever 10 — CSRF fetch, then the write Basic auth injected · 403 → one retry 11 — executing → committed · a timeout becomes ambiguous, never a blind retry
  • Agent request
  • Approval path — human in the loop
  • Backend write
  • Return / state update

In plain language

The agent asks to create an order. The gateway decides that this changes data, so it refuses to just do it. Instead it writes down exactly what was asked, in a form that cannot be edited afterwards, and shows those exact words to a person. The person approves with their fingerprint, and that fingerprint signs those exact words. The last component before SAP then checks the signature itself — it does not take anyone’s word for it — confirms the approval matches this operation and no other, and tears it up so it can never be used twice. Only then does the order reach SAP.

  1. Canonicalise. canonical_payload_hash is SHA-256 over the JCS-canonicalised payload (RFC 8785), so the same request always hashes the same way.
  2. Reserve. The ledger records the operation against the identity and that hash as its fingerprint. State: reserved.
  3. Persist a proposal carrying the write-effective fields for human review.
  4. Build the statement. The server derives the signing base: SHA-256("rimini-approval-v1" ‖ 0x00 ‖ JCS(statement)). The prefix domain-separates it, so a signature can never be replayed across object types.
  5. Sign. That signing base is the WebAuthn challenge. The human signs exactly what is displayed, with user verification and user presence both required.
  6. Package. The edge assembles an Approval Envelope and a self-contained Approval Execution Proof — statement plus assertion.
  7. Re-verify at the last hop. The adapter recomputes the signing base itself and never trusts a supplied digest. It verifies the assertion (challenge, origin allow-list, RP ID hash, UV and UP flags, ES256 signature against the registered key), evaluates the approval policy, and enforces the cross-binding matrix: tenant, requester, entry_id, native_operation, payload hash, request_id and client_operation_id must all be equal. An approval for one operation cannot be reused for another. It then consumes the approval durably before any backend call — one dispatch attempt, ever.
  8. Write. Only now does the SAP CSRF write run. The ledger goes executing then committed. A timeout becomes ambiguous, which is never blind-retried.

Three trust domains, three keys

The keys are deliberately independent. That independence is the design.

Signing keys
Key Identifier Signs Held by
AS signing key kid drawbridge-as-1 The user token chain. Its public half is served at /.well-known/jwks.json. Gateway Worker secret
Edge authorization key kid drawbridge-edge-1 Per-operation Authorization Envelopes and Identity Bindings. Never published. Gateway Worker secret
Approver passkey WebAuthn platform authenticator The approval statement, at the moment of approval. The human’s device — hardware-backed, non-exportable

Where the data goes

Storage

Everything lives in one Durable Object

  • Auth events — the dashboard feed.
  • Proposals awaiting human approval.
  • Ledger entries and their state.
  • Registered passkey public keys — public keys only. The private key never leaves the user’s device.
  • Short-lived OAuth codes.

Server signing keys live as Cloudflare Worker secrets and are never in the repository. The SAP credentials sit in Worker config for the demo. There is no real customer data anywhere — SAPDEMO / legacy123 are fictional.

Idempotency

The same request never runs twice

The same operation identity with the same payload fingerprint returns the recorded state — it does not execute again.

The same identity with a different payload is a hard error, never a second attempt. A timeout resolves to ambiguous, which is surfaced rather than retried, because a blind retry is how a duplicate order gets created.

Implementation

Stack

  • TypeScript on Cloudflare Workers, a bun workspaces monorepo.
  • @modelcontextprotocol/server 2.0 — the modern stateless era (2026-07-28) plus a legacy initialize era router on the same URL.
  • Zod schemas on every tool: inputSchema and outputSchema.
  • jose and Web Crypto for ES256 / JWS.
  • WebAuthn platform authenticator (Touch ID) for approvals.
  • A Durable Object for durable demo state.
  • vitest — 305 tests. Deployed with wrangler.

What this demo does not implement

These are deliberate omissions for a demo, not oversights. A production deployment would need them; naming them is part of being straight about what you are looking at.

  • Single approver. No multi-approver separation of duties.
  • No approver-registry checkpoints and no anti-rollback protection on that registry.
  • No runtime-signed tool-invocation envelope.
  • No sender-bound tokens — neither DPoP nor mTLS.
  • No OpenTelemetry pipeline. The event feed is the demo’s observability.
  • No disaster-recovery write-locks.
  • No catalogue or discovery pipeline.