← Back to blog
8 min read

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.

securityauthorizationcryptographyagents

Implementation status (v0.9.6). This post describes the approval-grant model end to end. As of v0.9.6, Treeship's verify pass enforces three of the four properties below statelessly: binding (the nonce ↔ approval link), scope (the actor / action / subject / expiry constraints signed into the grant), and package-local replay (a second action claiming the same nonce inside one verified package fails). The fourth property — cross-package / cross-machine single-use — requires verifier-side state and is on the roadmap: a local Approval Use Journal in v0.10, Hub-backed checkpoints in v0.11+. Verify reports the replay-check posture honestly today (replay check: package-local only -- no global ledger consulted) instead of claiming a guarantee that hasn't been consulted.

Bookmark this post; the body below describes the destination, and we'll update it as the journal layers land.

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.

Why most approval flows are vulnerable

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:

None of these require sophisticated attacks. They require the normal messiness of distributed systems running autonomous agents.

The approval model

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.

treeship attest approval \
  --approver human://approver \
  --description "authorize stripe charge $450 to acme-corp invoice INV-2847" \
  --expires 2026-03-26T17:00:00Z \
  --scope '{"allowed_actions":["stripe.charge.create"],"max_amount":450,"max_actions":1}'

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:

treeship attest action \
  --actor agent://payments \
  --action stripe.charge.create \
  --approval-nonce nce_7f8e9d0a \
  --meta '{"amount":450,"vendor":"acme-corp","invoice":"INV-2847"}'

The verifier then enforces:

action.approvalNonce == approval.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.

Seeing the difference in practice

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-observed
treeship 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.

The audit answer

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.