The Replay Attack Your Authorization System Doesn't Prevent
Most authorization systems for AI agents share a subtle flaw: approvals can be reused. An agent that captures an approval token can replay it. Here's the attack and how approval-based authorization prevents it by construction.
Implementation status (updated 2026-08-18, v0.24). All four properties
below now hold on a single device or workspace. Binding and scope are enforced
statelessly at verify time, as this post described. The fourth property —
single-use beyond one package — no longer waits on the roadmap: the local
Approval Use Journal shipped and is stable, and it reserves a use
before the action is signed. A second action claiming a spent nonce is
refused at attest time, not merely flagged at verify time:
✗ could not reserve approval use in journal: approval grant art_… would exceed max_uses (1/1)
Inspect the ledger with treeship approval uses, treeship approval status,
and treeship approval journal verify. What remains open is distributed
single-use — coordinating the journal across machines and teams via
Hub-backed checkpoints. The original note below this line said that layer was
coming in v0.10/v0.11; the device-local half of it has been shipping for many
releases.
Imagine your agent needs human authorization before executing a payment. You've built an approval flow: the human clicks Approve in a UI, a token is generated and passed to the agent, the agent includes the token in its action record, and downstream systems can verify the token was present.
This is better than no authorization. But it has a vulnerability that's easy to miss.
The token, once generated, can be reused. If your agent runs the payment flow again with the same token -- whether by bug, by design, or by an attacker who captured the token -- the downstream verifier sees a valid token and passes it. The human approved once. The action executed twice, or ten times, or for a different amount than the human ever saw.
This is the replay attack. And it's endemic to approval systems that treat authorization as a boolean flag rather than a cryptographic binding.
The typical agent authorization flow looks like this:
human approves → token generated → token passed to agent → agent acts → token logged
The token is evidence that approval happened. But it's not bound to a specific action, a specific scope, or a specific execution. It's a bearer credential. Anyone who has it can use it.
For low-stakes actions this might be acceptable. For anything consequential -- payments, data mutations, external communications, code deployments -- bearer credentials for authorization are a serious risk.
The vulnerabilities are:
Replay: same token used for multiple actions
Scope creep: token used for a different action than the human approved
Theft: intercepted token used by an attacker
Forwarding: token passed between agents, each using it to authorize their own actions
None of these require sophisticated attacks. They require the normal messiness of distributed systems running autonomous agents.
An approval is not a token. It's a cryptographic commitment from an authorizing human to a specific action by a specific agent within a specific scope.
This produces an ApprovalStatement signed with the approver's Ed25519 key. The statement contains a random nonce -- a one-time value that appears nowhere else.
When the agent takes the authorized action, it includes that nonce:
This check is in the Rust core. It cannot be bypassed. And because the nonce is tied to exactly one approval and exactly one action, every vulnerability in the bearer token model is closed:
Replay -- the nonce should appear in exactly one consumption. As of v0.9.6, Treeship enforces this within a single verified package (a second action claiming the same nonce in one package fails). The local Approval Use Journal landing in v0.10 extends enforcement to all consumptions on a given device or workspace; Hub-backed checkpoints in v0.11+ extend it to distributed single-use across machines and teams. Verify reports today's posture honestly rather than overclaiming.
Scope creep -- the verifier checks the scope fields. A charge for $600 fails when the approval specifies max_amount 450. A different action type fails when the approval specifies allowed_actions.
Theft -- a stolen nonce authorizes exactly the action the human intended, for the amount they specified, with the vendor they named. The attacker can't use it for anything else.
Forwarding -- each agent in a chain needs its own approval for its own actions. A nonce passed between agents doesn't authorize the second agent's actions because it's bound to the first agent's actor URI.
Here's what an approval-style authorization looks like versus approval-style:
# Bearer-token style -- broad, reusable# "approve payment workflows today"# Token: bearer_abc123# Any action can claim this token# Approval-grant style -- specific, scoped, replay-observedtreeship attest approval \ --approver human://approver \ --description "stripe charge $450 to acme-corp INV-2847" \ --allowed-actor agent://payments \ --allowed-action stripe.charge.create \ --allowed-subject vendor://acme-corp \ --max-uses 1 \ --expires 2026-03-26T17:00:00Z# Nonce: nce_7f8e9d0a# Only agent://payments calling stripe.charge.create against# vendor://acme-corp can consume this nonce. max-uses is signed# into the grant for ledger enforcement (v0.10 / v0.11+).
The approval is not less convenient for the human -- it's one approval action that produces one nonce. The difference is precision: the human is approving a specific thing, not a broad category.
When something goes wrong -- or when nothing has gone wrong but compliance needs to prove it -- approval-based authorization produces a different quality of evidence.
treeship verify art_charge --full# ✓ approval binding nonce matched a signed approval# ✓ approval scope actor / action / subject matched approval scope# ⚠ replay check package-local only -- no global ledger consulted
This isn't "a valid authorization token was present." It's a specific named human, approving a specific action against a specific subject by a specific actor, with cryptographic proof the binding holds and the scope was respected.
Replay defense in v0.9.6 is package-local — and verify says so plainly rather than claiming a guarantee it can't deliver. The local Approval Use Journal in v0.10 extends that to every action seen on a device; Hub checkpoints in v0.11+ extend it across machines. The cryptographic structure is in place; the enforcement layers are landing in order.
That's the kind of authorization AI agents taking consequential actions actually need — and the kind whose limits a system should be honest about today.