“Single-use tokens” is a property most authorization designs claim. OAuth DPoP (Demonstrating Proof-of-Possession) claims it. RFC 9396’s RAR-shaped credentials claim it. Every HITL-aware agent bridge claims it. The wording is usually some variation of “the credential is consumed once and cannot be replayed.”
The claim is true at one layer and false at another. The boundary between the two is rarely drawn. It sits at the moment a human’s signature becomes a machine credential, and skipping it turns one human approval into N executions.
Three artefacts, not one
A parameter-bound HITL flow has three artefacts:
- The signed payload: what the human signed. “I approve deleting task t-42,” plus an expiry.
- The minted credential: what the authorization server returns after verifying the signature. Usually a short-lived JWT with a unique
jti. - The executed action: what the resource server does when it sees the credential.
Single-use enforcement typically lives at the credential layer: track consumed jtis, refuse the second consume. That is where the docs say “single-use,” where the test lives, and where the property is true.
The signed payload, however, is usually not single-use.
If an attacker captures the signed payload (a leaked WebSocket frame, a compromised relay, a misbehaving client, a hostile bridge holding the bytes in flight), they can present it to the Vault repeatedly. Each presentation produces a fresh credential with a new jti and a new exp, bound to the same (command, args). The credential layer flags each one as unused, because each one is. The audit log records distinct events.
The human signed “delete task t-42” once. The system honours that signature N times within the signed-payload TTL. None of those executions are replays at the credential layer, because they aren’t credential replays. They are mint replays.
Where the window lives
A2A’s task-lifecycle SSE shape and MCP’s elicitation flow both move signed payloads over channels the bridge sees. The bridge holds the bytes for at least as long as it takes to round-trip to the Vault. Anything between “human clicks approve” and “credential is consumed at the RS” is a window in which the signed payload exists and is interceptable. A typical signed-payload TTL (time-to-live) is five minutes, which is a long time for a value that grants unbounded re-mint capability.
For a read-only action the window is harmless. For an action like “delete task” or “transfer $200 to [email protected],” where double-execution changes state, it isn’t.
The failure mode is quiet. The credential layer reports correct single-use enforcement. The audit log shows distinct, well-formed events. The developer’s _consumed.add(jti) test passes. The property the developer believed they had is the one they don’t.
Two checks, not one
The distinction is visible in code. The first check is almost universal:
if jti in self._consumed:
raise CredentialReplay
The second is almost never present:
if signature_hash in self._consumed_signatures:
raise SignatureReplay
The first is “single-use credential.” The second is “single-use approval.” They are not the same property, and the first does not imply the second. The second closes what the FAPI 2.0 and IETF agentic-auth literature has started calling consent atomicity: one signed approval, at most one credential, at most one execution.
Closing it costs about thirty lines
The Vault-side fix is small. A set of canonical-bytes hashes keyed by the signed payload. A check at mint, before the JWT is constructed:
canonical = canonical_authorization_bytes(...)
expected = hmac.new(user_signing_secret, canonical, sha256).hexdigest()
if not hmac.compare_digest(expected, signed.signature):
raise SignatureMismatch(...)
# ... existing rar_type and exp checks ...
signature_hash = sha256(canonical).hexdigest()
with self._lock:
if signature_hash in self._consumed_signatures:
raise SignatureReplay(...)
self._consumed_signatures.add(signature_hash)
# ... construct and return the JWT ...
The check runs after structural validation (signature, rar_type, exp), so an invalid payload cannot poison the set. The lock holds across check-and-add so concurrent presentations cannot both succeed. In production the set is a TTL-aware durable store (sqlite, Redis) rather than an in-process Python set, but the shape is the same.
The RS-side alternative is per-action idempotency: the resource server treats (command, args) as the idempotency key over a window matching the signed-payload TTL, and a second delete t-42 within five minutes becomes a no-op. The cost of that fix is action-shape-specific: the RS has to know that two delete t-42 calls are the same intent, but that two transfer $200 to alice calls issued seconds apart may be distinct intents. Vault-side mint-replay tracking is action-agnostic, which is why it’s the cleaner reading of “single-use.”
A production system that cares about either threat usually wants both layers. The Vault closes capture-and-replay across the wire; the RS closes buggy clients that retry.
What “single-use” should mean in a published design
The property to publish: the credential layer’s single-use guarantee and the signed-payload layer’s single-use guarantee are two distinct claims. A design that wants both has to enforce both.
A reference implementation that ships only the credential-layer guarantee should say so in the README, not in a footnote to a threat model. A design that claims “single-use” without specifying which layer it means is ambiguous in a way that matters. The accompanying reference now enforces both, with SignatureReplay covering mint and CredentialReplay covering consume.
Bridges that enforce per-execution consent should say so. Bridges that enforce per-action-shape consent should say that, and own the gap.
The full design, with consent atomicity sitting alongside the other three constraints of agent-delegation security (parameter-bound intent, independent consent surface, destination gating), is in the longer post. The reference implementation is at a2a-mcp-bridge-reference. Apache-2.0.