The authorization problem

Agent systems route user intent through a stack: the user types or speaks, an LLM reasons over the message, the agent dispatches tools, the tools call backend systems. Authorization has to land somewhere in this stack.

One framing, the “last mile” framing common in enterprise IAM, treats this as authorization breaking at the boundary to legacy backends. The fix is a credential broker (often called a Vault) that preserves the user’s identity through the stack, evaluates policy (ABAC, PBAC) against their attributes, and mints short-lived credentials the backend trusts.

That solves the access-control plumbing problem. It does not solve the agent-authorization problem, because the LLM sits between what the user approved and what gets executed.

If Alice is allowed to delete tasks in project X, and the LLM is prompt-injected into calling delete_task(t-99) when Alice approved delete_task(t-42), an identity-preserving design says yes - Alice is allowed to delete tasks in project X, and t-99 is in project X. The drift is invisible to the policy engine, which sees only identity and request shape, not the user’s specific intent. The LLM carries Alice’s identity faithfully and mutates the action she approved.

Parameter-bound human-in-the-loop authorization with cryptographic delegation closes this gap. The human signs the specific action - not a session, not a role, not a policy attribute, the (command, args) bytes themselves. The signature drives an authorization server to mint a single-use, action-scoped credential. The resource server validates the credential’s bound (command, args) against the live request and refuses on mismatch. The LLM’s interpretation of intent doesn’t authorize anything; the human’s signature over the specific bytes does.

This post unpacks the four constraints that make the property hold:

  1. Parameter-Bound Intent. The signature covers canonical bytes of the exact command and args.
  2. Consent Atomicity. One signed payload mints at most one credential.
  3. Independent Consent Surface. The entity rendering the consent screen is in a different trust domain from the entity orchestrating the LLM.
  4. Destination Gating. The resource server refuses any request lacking a valid Vault-minted, parameter-bound credential.

The four are necessary and sufficient for the design’s goal: every change to data is approved by a named human, and the approval is verifiable from an audit log alone. Drop any one and the goal collapses in a specific, demonstrable way.

A2A and MCP

A2A and MCP are the protocols where this question surfaces today.

A2A (Agent-to-Agent) is how one agent service talks to another. Task-lifecycle SSE: open a task, stream events, eventually completed or failed. Designed for cross-vendor, cross-org agent interop.

MCP (Model Context Protocol) is how an LLM-host talks to its tool universe. JSON-RPC over stdio/HTTP. tools/list, tools/call, plus a relatively new primitive called elicitation for interactive clarification.

MCP and A2A sit at different layers. MCP is host-to-tool, one LLM host with many tools in a single session, while A2A is agent-to-agent, designed for cross-vendor, cross-org interop. The authorization question (who approved this destructive action, and what exactly did they approve) shows up in both, but they are not the same case.

The common case is a single agent exposing MCP tools. No A2A is involved: the agent and the human’s MCP host are the only parties, and the approval never leaves that one agent’s domain. The cross-agent case adds A2A when a destructive action is proposed by a remote sub-agent and the approval has to travel back to the human across agent boundaries.

The reference covers both, and the security argument is the same for each, because it lives in the core below rather than in the carrier.

The HITL check most bridges perform looks like this:

def call_tool(name, args):
    if tool_requires_approval(name):
        ok = ask_human("approve {name}?")
        if not ok:
            return rejected
    return execute(name, args)

The human is asked, consents, and the tool runs.

What the human approved, on inspection, is a tool name. The arguments are not part of the approval surface. Between “approve delete_task” and “execute delete_task(task_id=...)” the LLM picks the arguments, and the LLM is the component the HITL gate exists to constrain. The check enforces a property weaker than the one it appears to enforce.

The finer-grained property to enforce is: the human approves a specific action with specific arguments, and only that action with those arguments runs. OAuth has a vocabulary for this. RFC 9396 (OAuth 2.0 Rich Authorization Requests) proposes a structured authorization_details payload that describes exactly what the resulting credential authorizes:

{
  "authorization_details": [{
    "type": "tasktracker_task_action",
    "command": "delete-task",
    "args": {"task_id": "t-42"}
  }]
}

The human’s signature binds to that payload. The credential the authorization server mints inherits the binding. The resource server, before executing, checks that the live request matches the payload. This is parameter-bound HITL: the bytes the human approved are the bytes that run, and only those bytes can run.

A2A and MCP both leave room for this shape - neither forbids it. The work is in wiring the elicitation surface to RAR-shaped payloads, choosing a signing custodian for the human’s key, and building the verification layers that make the property hold.

The drift attack

A concrete illustration of the gap is a sequence that boolean consent cannot reject:

T=0:  LLM proposes: delete_task(task_id="t-42")
T=1:  Bridge emits elicitation. Human sees "Delete task t-42 (Q2 launch checklist)?"
T=2:  Human approves.
T=3:  LLM, holding the approval boolean, calls: delete_task(task_id="t-99")
T=4:  Bridge executes delete_task(task_id="t-99"). The wrong task is deleted.

In a boolean-consent design, step 4 succeeds. The approval was for delete_task generically. The argument slot accepts whatever the LLM provides.

In a parameter-bound design, step 4 fails. The signed payload covers task_id="t-42". The live request carries task_id="t-99". The resource server raises CredentialDrift and refuses.

The drift attack is the prompt-injection attack at the authorization boundary: a hostile instruction in the LLM’s context window mutates arguments between approval and execution. Boolean consent stays silent where parameter binding rejects.

A cosmetic counterpart to the drift attack lives at the consent surface itself. Suppose the bridge renders “Delete temp file” on the consent page but builds canonical bytes for delete-task(task_id="t-42"), where t-42 happens to be the production database. The command and arguments agree byte-for-byte between display and signing; only the human-readable summary differs. Pure argument binding catches the t-42-vs-t-99 case but does not, on its own, catch this.

The fix extends the same property to the description. The reference treats binding_message as a required field of the canonical-bytes contract alongside (command, args, rar_type, exp, approver_id). A bridge that renders one summary on the consent page and signs different bytes fails Vault verification, because the canonical bytes the Vault recomputes no longer match the bytes the human signed. “The credential is bound to the parameters the human approved” extends to “and to the summary the human read.”

Scope enforcement comes first

Parameter binding answers “what action runs.” A separate question runs alongside it: which caller is allowed to attempt the action at all. The two are orthogonal: a caller’s bearer-token scopes should constrain which tools they can attempt, independently of whether the tool needs human approval.

A common mistake when adding HITL to an authorization design is to make the HITL gate the only scope check. A tasks.read bearer attempting create_task would then succeed - the tool is not HITL-gated, so nothing stops it. The bearer’s scope was consulted at the auth boundary and never again.

The reference enforces required scopes at the dispatcher, before HITL routing. ToolSpec.required_scopes declares the scopes a caller must hold to attempt the tool. Dispatcher.execute consults the caller’s CallerIdentity.scopes and refuses with Unauthorized if any required scope is missing. The check runs regardless of whether the tool is HITL-gated, so a read-scoped caller can neither execute non-HITL writes nor reach the HITL gate for destructive tools.

Scope failure and approval failure are distinct outcomes in the dispatcher’s return type, so the audit log can tell “this caller was not allowed to attempt this tool” apart from “this caller could have attempted it but did not produce a valid signed payload.” Both are refusals; only the second is a HITL event.

The security core and the carrier

A working bridge has a core and a carrier, and conflating them is a common mistake. The carrier is the visible part, a protocol translator with a consent UI. The security lives in the less-visible core, the signature-and-verification path.

The core: cryptographic delegation. The human signs the specific authorization_details payload. The signature drives an authorization server (a “Vault” - Keycloak, Authlete, HashiCorp Vault, or a bespoke OAuth AS) to mint a single-use, action-scoped credential. The resource server validates the credential against the live request. This is where the security comes from. It is stronger than boolean consent and more brittle under sloppy implementation: the human’s key has to live somewhere appropriate, the canonical-bytes form has to be stable across signers, and the verification path has to be hardened against a known set of JWT-handling mistakes.

The carrier: how the signed approval reaches the human and back. This is data plumbing, and it has two shapes depending on how far the approval has to travel. For a single agent exposing MCP tools, the agent emits a URL-mode elicitation/create on a HITL-gated tools/call and resumes on retry once the human has approved, with no second agent and no A2A. For a destructive action proposed by a remote agent, a translation layer maps A2A’s auth_required event onto the MCP elicitation and routes the human’s response back to the paused task. Either shape preserves byte-identity (the bridge does not mutate authorization_details in flight) and routing fidelity (the response reaches the right paused action). The MCP spec (2025-11-25) reserves URL mode for sensitive consent.

The carrier produces a UI that says “approve this action?” The core is what makes that approval mean something. A carrier on its own is just a prompt, with no defence against the drift attack below. The core is what makes drift impossible, and it does not care which carrier delivered the signature.

Three enforcement layers

The core has three enforcement layers, each independent of the others.

Layer 1: Vault verifies the human signature before minting. The Vault holds the human’s verification key (in production a WebAuthn-bound public key the human controls; in a demo, a shared HMAC). The Vault computes canonical bytes from the proposed authorization_details, verifies the signature, and only then mints. A wrong signature produces no credential.

Layer 2: The bridge cannot alter the minted credential. This is structural. The bridge’s path from “Vault returns credential” to “RS consumes credential” is a pass-through. No transformation, no re-signing. The credential the Vault produced is the credential the RS sees.

Layer 3: Resource server validates independently against the live request. The RS uses its own verification key, its own state, its own iss/aud/exp checks. It compares the credential’s authorization_details against the actual (command, args) of the request and refuses on mismatch, regardless of what Layers 1 and 2 concluded.

A drift attack has to defeat all three. Layer 1 stops a fabricated signature from ever producing a credential. Layer 2 leaves no surface to swap a different action into a valid credential. Layer 3 catches the case where the credential and the request diverge for any reason, including a future refactor that breaks Layer 2’s structural promise.

Asymmetry in the layers

Layers 2 and 3 are mutually independent: a bug in either does not compromise the other. Layer 1 is the trust root for the human-signature property. The RS does not see the human’s signing key - the signature is verified at the Vault and not carried forward in the JWT’s claims. A Vault bug that mints without verifying the human signature would not be caught at the RS. Layer 3 will validate the resulting JWT correctly - it has no way to know the human did not actually sign.

This asymmetry is intentional, and Layer 1 rests on the explicit assumption that Vault compromise is out of scope. Layers 2 and 3 protect what happens after mint; Layer 1 protects whether mint should have happened at all.

A “three independent layers” claim without that caveat is overclaiming. The accurate statement is that the three layers compose into a defence with a known seam, and the seam sits at the trust root by design.

The credential layer’s single-use enforcement is tracked per-jti at consume. A credential cannot be consumed twice - the second consume raises CredentialReplay. Every HITL-aware design publishes this property.

A second single-use property is rarely published and almost never enforced: per-signed-payload at mint. If an attacker captures a signed payload (a leaked WebSocket frame, a compromised relay, a hostile bridge holding the bytes in flight), they can present it to the Vault repeatedly. Each presentation produces a distinct credential with a new jti and a new exp, bound to the same (command, args). The credential layer flags each one as fresh because each one is.

The reference closes both by having the Vault track canonical-bytes hashes of signed payloads accepted at mint (_consumed_signatures) and raise SignatureReplay on the second presentation, so one human signature exchanges for at most one credential. The check runs after structural validation (signature, rar_type, exp) so an invalid payload cannot poison the set; it sits under the same lock as the consumed-jti record so concurrent presentations cannot both succeed. About thirty lines including the unit test.

A production deployment can also add RS-side per-action idempotency, where the resource server treats (command, args) within a TTL window as already-executed. RS-side idempotency is action-shape-specific and worth adding for cases where retry-on-failure has to be safe; Vault-side mint-replay tracking is action-agnostic and is the cleaner reading of “single-use.” A system that cares about either threat usually wants both. The first sibling post (Consent atomicity: the trap most agent-authorization designs fall into) walks through the per-payload vs. per-credential distinction in more detail.

Parameter binding closes the drift attack, and consent atomicity closes the multi-mint attack. A third attack lives at the consent surface itself, and the bridge cannot close it from inside: the bridge is the entity the constraint is constraining.

If the bridge controls the pixels the human looks at while approving, it can lie about what they are approving. It renders “Read my emails” while building canonical bytes for “Delete database,” and the user approves the benign description while the signer signs the malicious bytes against a Vault that verifies and mints honestly. Parameter binding holds the LLM honest about the run-time arguments. It does not hold the bridge honest about the consent-time display.

The reference’s binding-message defence (the human-readable summary is part of the canonical bytes) catches a render-vs-sign drift forensically: a bridge that renders one summary and signs different bytes produces a signature the Vault rejects, because the canonical bytes the Vault recomputes no longer match the bytes the human signed. That defence is sufficient for an in-process configuration where the bridge’s display and the signer’s input come from the same immutable record. It is not sufficient for the production case it most matters in: WebAuthn or Passkey at the user, with the bridge shipping JavaScript to the user’s browser. The JS computes the canonical bytes the WebAuthn challenge signs. A hostile bridge composes a binding_message that matches the malicious bytes, ships HTML that displays the benign description, and the user’s signer signs honestly.

The architectural fix is independent consent surface: the entity that displays the proposed action to the human is in a different trust domain from the entity orchestrating the LLM. A separate authorization-server-hosted consent page parses and renders the raw (command, args) itself, independent of any HTML the bridge supplies. The user signs what the AS displays, not what the bridge displays. This is the standard FAPI 2.0 (Financial-grade API) deployment shape and the form constraint 3 takes in production. The reference’s demo bundles the consent server on the bridge process for self-containedness and calls out the production separation explicitly in SECURITY.md and the architecture document.

The constraint is not “the demo is vulnerable” - the demo’s frozen ProposedAction (immutable across display and signing via frozen=True + MappingProxyType) makes the attack structurally impossible inside one process. The constraint is “the demo’s deployment shape is not a production shape.” Constraints 1, 2, and 4 of the four-constraint frame are properties of code that the reference enforces; constraint 3 is a property of deployment topology that production must satisfy on top.

The multi-domain path: principal-to-principal trust across sub-agents

Everything so far is the single-domain case: one agent, one human’s MCP host, the approval staying inside that agent’s domain. The multi-domain case is when a destructive action is proposed by a separate agent and the human’s approval has to cross agent boundaries to reach it. This is where A2A enters, and it is the only place it is needed.

Bearer tokens were designed for HTTP. They carry the answer to “is this caller allowed” but not the answer to “did this human approve this action.” Across an agent chain those answers diverge fast.

A common deployment shape: a user-facing agent (call it A) calls a remote sub-agent (B) to do part of a job. The simple plumbing is for A to forward its bearer token to B. B then acts with A’s identity, and the audit log says “A did this.” That is correct for the access-control question and wrong for the consent question. If B is prompt-injected into proposing a destructive action, the human approved nothing about B’s behaviour. The token A holds was issued against A’s approved actions, not B’s.

The fix is principal-to-principal: B does not borrow A’s credentials. When B hits a destructive action that requires HITL, B emits its own auth_required event. A2A’s task-lifecycle SSE shape is the carrier - A2A is designed for an action to pause mid-execution and surface back up the calling chain. The event propagates through A back to the human’s MCP host. The human approves B’s specific action with B’s specific arguments, signs canonical bytes naming B’s command, and the Vault mints a credential bound to B. The token A holds never satisfies anything B is doing; the token B receives never satisfies anything A is doing. Cryptographic intent flows from the human, through the chain, to the leaf action, without ever being borrowable.

The audit log then records, for every destructive action, which human signed for which specific action at which specific sub-agent. A compromised intermediate agent cannot escalate by handing off a credential. A2A is the connective tissue that carries the human’s signed intent across agent boundaries without inventing a new propagation protocol. The safety still comes from the Vault, not from A2A: the Vault binds the human’s signature to B’s exact action and does not know or care how deep the chain is. A different cross-agent transport would carry the same intent. What it cannot change is where the security lives.

This is also why bearer-passing is the failure mode worth naming explicitly. A swarm of sub-agents sharing one session token is a swarm with one undifferentiated actor field in the audit log. “Agent swarm did this” is not an answer to “which human approved this specific change.”

Two tiers behind one contract

Orthogonal to the three enforcement layers is a deployment-shape decision: where the Vault lives and what algorithm signs the credential.

Tier 1 is an in-process verifier. The bridge process holds the shared HMAC secret, computes canonical bytes, verifies the signature, and produces a credential in memory. No network round-trip; no separate authorization server. Tier 1 closes the LLM-side threats (prompt injection, parameter drift, replay-at-consume) and is deployable today without any new infrastructure. It does not close agent-process compromise: an attacker inside the bridge process holds the verification secret and can mint.

Tier 2 is an OAuth-shape JWT mint with a separate resource server. The Vault verifies the signature and mints a JWT. The RS verifies the JWT independently, checks iss/aud/exp, binds authorization_details to the live request, and tracks its own consumed-jti state. With a WebAuthn-bound user key (so the bridge process never holds the human’s signing key) and a separate AS process (so RS compromise doesn’t yield mint capability), agent-process compromise stops giving the attacker the ability to fabricate signatures for new actions. The attacker may still re-mint within TTL for previously approved actions; new actions still require a fresh human signature.

Both tiers satisfy the same two-method Vault Protocol: mint(signed_payload) → credential and consume(credential, command, args) → outcome. Code that calls the Vault is identical across tiers. The dispatcher does not know which tier it holds. Graduating from Tier 1 to Tier 2 is a configuration change rather than a refactor.

This separation matters because the typical objection to parameter-bound HITL is the perceived setup cost of an OAuth AS. Tier 1 removes that cost. The day agent-process compromise becomes the threat to address, Tier 2 is available behind the same call sites.

What this reference does not provide

A reference implementation is most useful when its limits are stated in the same register as its features. The accompanying repo names the following:

HS256 in the demo. The reference uses a symmetric secret between Vault and RS for self-containedness. The production swap is RS256 or ES256, with the AS holding a private key and the RS holding the corresponding public key. In the HS256 demo, RS compromise yields mint capability - an HS256-specific weakness, not an architectural one. The module docstring at the top of bridge/vault/oauth.py describes the swap.

Demo-mode key co-location. bridge.consent.demo_signer runs server-side because the demo cannot launch a separate user-key custodian. A bridge compromise in the demo is therefore equivalent to a human-key compromise. The production path moves signing to the human’s MCP host (WebAuthn / Passkey) and removes the user signing key from the bridge process entirely. The demo signer module is the seam to replace.

In-memory consumed-jti state. OAuthVault._consumed and JwtResourceServer._consumed are in-process Python sets. A restart inside the JWT TTL discards the record. A production deployment needs a durable, TTL-aware store (sqlite, Redis). The reference doesn’t bundle one because the shape of the consumed-jti property is the demonstration, not the storage substrate.

Independent consent surface in a separate trust domain. Discussed above. The demo’s URL-mode consent server runs on the bridge process for self-containedness. In a production WebAuthn deployment, the consent UI must move to an authorization server in a separate trust domain so the user signs what the AS displays rather than what the bridge displays. The reference’s frozen ProposedAction is sufficient for the in-process demo; constraint 3 is a deployment-shape requirement production must satisfy on top.

Untrusted MCP host. The bridge trusts the host to route elicitation responses faithfully. The reference’s elicitation_id carries a process-local HMAC tag, so a host that doesn’t hold the tag secret cannot fabricate IDs to route a resume to a different paused task. That mitigation is partial: multi-replica or cross-host deployments need either a shared tag secret or a different carrier (a signed elicitation token). The reference targets cooperative-host deployments (Claude Desktop, IDE, a custom orchestrator the human controls), where the partial mitigation is sufficient.

All of these appear in the README, the architecture doc, SECURITY.md, and the relevant module docstrings, where someone evaluating the design can find them before building on it.

Parameter-bound HITL with RFC 9396 payload shapes and three independent verification layers is mature in the OAuth world. Nothing in A2A or MCP forecloses it. The accompanying reference is about 2000 lines of Python including tests, runs without infrastructure, and demonstrates the core plus both carrier shapes: the single-domain MCP path runs end to end in code, and the multi-domain A2A path is the same core behind a cross-agent carrier.

The next companion piece, “How the reference implementation resolves this”, walks through the core and the carriers in code.

Repo: a2a-mcp-bridge-reference. Apache-2.0.


Corrections welcome.