Drafted with AI help, human-reviewed by The Agent Loop.
Short version: Someone on my last post proposed the fix I wanted: the agent signs a concrete proposal (call X, with args Y, spend limit Z), and the gate authorizes that signed commitment instead of whatever the model says next. It's the right architecture. It also has three unanswered questions that showed up in the same thread: how long does a signed proposal live, what happens when you rotate the key, and what do you do with proposals that got signed and never submitted. Signed but unexpired is a bearer token with a story attached. Give an approval a lifetime in minutes, make it single-use, and make revocation actually kill what is outstanding.
For skimmers
- Standards already answer the lifetime question: DPoP proofs should be accepted for seconds to minutes, logout tokens ≤2 minutes, access tokens about an hour (GCP, Anthropic, Entra's 60–90 min default)
- An approval that never expires outlives the context that justified it, and outlives the key
-
jtiexists in JWT exactly "to prevent the token from being replayed"; DPoP servers must remember each one for the acceptance window - Duplicate side effects are the failure you actually feel: Stripe keeps idempotency keys 24 hours, A2A assumes duplicate deliveries, a double-fired cancel order came up in my comments
- Revocation that isn't a key rotation is a suggestion: Cloudflare's Thanksgiving incident turned on one token + three service accounts they "failed to rotate"
- AP2 puts numbers on it: example mandates expire in 3,600 seconds, receipts narrow the remaining scope after every action
- Orphaned proposals (signed, never submitted) are the hole nobody designs for: expire them like everything else
The question my commenters asked
Two people pushed back on my MCP approval-gate post, and the second one is the reason I'm writing this.
The first drew the line I missed: enforcement isn't only about where the check runs, server-side or host dialog; it's about what the check runs against. If the gate evaluates the model's next utterance, you've put a deterministic gate around a non-deterministic source. The fix is to have the agent commit to a concrete action (this call, these arguments, this ceiling), sign it, and authorize that. The model suggests; infrastructure decides.
I conceded the point in the thread and asked the two questions it raises. How long does a signed proposal live? And when you rotate or revoke an agent's key, do already-approved proposals die with it?
The second commenter answered with an incident before I could: their agent had both a read-orders and a cancel-order tool, the host dialog timing was off, and the cancel fired twice: both calls reached the server before either could be stopped. They also had signed proposals surface in postmortems that nobody had thought to expire.
That is the part worth a post, because both failures are the same shape. The gate did its job. The proposal was valid when it was written. Nothing ever said when it stopped being valid.
An approval is a claim about a moment
Here is the property I was missing. A signature says: this was true, and this identity vouched for it, at time T. It doesn't say anything about T+1. The context that made the action reasonable (the user's intent, the session, the account balance, the key's own validity) is all invisible to whoever checks the signature later. They see a valid signature over a valid payload and they let it through.
So an unexpired approval is a bearer token with a story attached. The story is what you tell yourself about why it was signed. The token is what the server actually checks.
Every serious stack answers this the same way, with a lifetime:
-
DPoP (RFC 9449) says a server must only accept a proof for a limited time after creation ("preferably only for a relatively brief period on the order of seconds or minutes") and must remember each proof's
jtifor exactly that window, because a single-use check "provides a very strong protection against DPoP proof replay." - OIDC back-channel logout asks for tokens that expire at most two minutes ahead, for the same reason: a captured logout token shouldn't outlive its usefulness.
- Access tokens cluster around an hour. Google Cloud's agent identity tokens default to one hour and are blunt about the trade: they "can't be introspected or revoked, and remain valid until expiry." Anthropic's API keys ship with 3-hour, 1-day, 7-day and 30-day expirations, fixed at creation. Entra randomizes between 60 and 90 minutes by default.
- Assumed-role sessions get 15 minutes to 12 hours from AWS STS, default an hour, and role chaining drops you to an hour because each hop is a new trust decision.
- MCP's authorization spec says authorization servers should issue short-lived access tokens, must rotate refresh tokens for public clients, and must return 401 on anything expired.
The numbers differ; the instinct doesn't. Nobody serious ships an approval that lives forever. Which is a fair test to apply to your own gate: what is the exp on the thing your agent got approved to do?
If the answer is "it's a session" or "until the user closes the tab," you have made the model's patience the lifetime of an authorization.
Single use, or not an approval
Lifetime solves the stale case. It doesn't solve the duplicate one: my commenter's double-fired cancel.
HTTP has a word for this. RFC 9110: don't auto-retry a non-idempotent request unless you know the original never applied, and proxies must not auto-retry them at all. The de-facto implementation of the client side is an idempotency key: Stripe keeps the key for at least 24 hours, replays the original response (including a 500) for the same key and same parameters, returns idempotency_error if the parameters differ, and 409 on concurrent use. After 24 hours the key is pruned and reuse starts a new request. Even the standardization attempt agrees on the horizon: the IETF Idempotency-Key header draft expired without becoming an RFC.
For a signed proposal the mechanism already exists and it's named: jti. RFC 7519 defines the claim to "prevent the token from being replayed," and DPoP turns it into a rule: store the jti for the acceptance window and decline repeats. Two minutes of server-side memory converts "valid signature" into "valid signature, used once, and you'd notice a second try."
The A2A protocol assumes duplicates as a normal condition rather than a bug: push-notification deletion must be idempotent, and clients should process notifications idempotently because duplicate deliveries may occur. That's the honest baseline for anything an agent drives over a network. Your gate should behave as if the message will arrive twice, because one day the host dialog timing will be off and it will.
Where you can't dedupe upstream, dedupe at the database. The case study worth reading (its author explicitly labels the numbers constructed, and I checked: I couldn't find a company-published double-charge postmortem to cite instead) is the classic SELECT-then-INSERT race: eleven duplicate charges at gaps of 47–338 milliseconds, fixed by a UNIQUE constraint on the idempotency key rather than a SELECT that looked clean.
The Ethereum version of this is the one people remember. After the DAO fork, Ethereum and Ethereum Classic shared transaction formats, so a signature that was valid on one chain was valid on the other: withdrawals on one side minted the same value on the other. The fix (EIP-155) put the chain ID inside the signed hash, so context becomes part of what the signature covers. It's the cleanest illustration of the general rule: if two runs of your agent can produce the same signed bytes, you have built a replay. Signature malleability has been exploited the same way, with an attacker re-submitting a request as long as the old one had not yet expired.
Revocation that lands
Rotation is where "we approved it" turns into "we approved it, and we meant it." The OAuth answer is RFC 7009: a revocation endpoint invalidates the token and, for a grant, the other tokens issued under that grant. The BCP (RFC 9700, final in January 2025) goes further and says tokens should be sender-constrained, meaning bound to a key or channel (mTLS, DPoP) so that a stolen token is not enough to use it, and that refresh tokens for public clients must be either sender-constrained or rotated, because reuse of a rotated token reveals the breach.
I read three incident reports this week. They are all the same story: a credential that should have been dead wasn't.
- Cloudflare, Thanksgiving 2023: one access token plus three service accounts left over from the Okta breach. Their phrase for it: "that we failed to rotate." Remediation meant rotating more than 5,000 credentials and reimaging 4,893 systems.
- GitHub, April 2022: stolen OAuth tokens from two integrators were used to enumerate orgs and selectively clone private repositories; GitHub revoked its own affected tokens immediately and asked both integrators to revoke all user tokens for their apps.
- CircleCI, January 2023: a malware-stolen SSO session led to customer GitHub token theft; the response was to rotate every GitHub OAuth token the company had.
Nobody's incident report says "our signed-but-unused approvals outlived their usefulness." They say "we failed to rotate." The design requirement writes itself: when the key dies, the proposals die with it. Otherwise they were never really tied to the key, and you have bearer tokens wearing a signature costume.
For agent-shaped systems the check is concrete. Can you answer, right now, for every outstanding approval: who signed it, what exactly does it authorize, when does it expire, and does it still work after a rotation? If any of those four needs a query you haven't written, the approval is longer-lived than you think.
The orphan nobody designs for
Back to the postmortem detail: proposals that got signed and were never submitted. They're the awkward artifact of any commit-then-authorize design: the agent signs, the next turn goes somewhere else, and the signed bytes sit there.
They're only dangerous if they're still live. A proposal with a five-minute expiry and a jti that's consumed on first presentation is inert the moment nobody uses it: it expires into a log line. A proposal with no expiry is an approver's worst case, because it can be presented later by anyone who can reach the endpoint, and the gate has no way to distinguish it from something the agent meant to do now.
The pattern that solves this is already in AP2, Google's agent payments protocol, and it's worth stealing even if you never touch payments. An open mandate carries the agent's key; each action returns a signed receipt that narrows the remaining scope, "often preventing future presentations entirely." Example mandates in the spec carry iat → exp of 3,600 seconds. The agent isn't holding a permission. It's holding one that shrinks every time it's used, and that has a clock on it. The security doc is even direct about posture: all LLMs and agents must be considered potential attackers, and receipts must be integrity-protected from the agent's own LLM.
Macaroons are the older, general version: chained caveats that attenuate and confine when, where, by whom, and for what purpose a credential may be used. The insight survives every implementation: an approval that gets narrower and older is strictly safer than one that doesn't.
What I'm doing about it
agent signs: {call X, args Y, limit Z, exp = now + 5m, jti = <id>}
|
v
gate: verify signature -> verify exp -> jti not already seen?
| |
| seen -> REJECT (replay)
v
execute exactly once
|
v
receipt narrows remaining scope; key rotation kills
everything outstanding that wasn't executed
-
Put an
expin minutes on every approval. Use the DPoP intuition (seconds to minutes for anything that causes a side effect) and an hour as the ceiling for read-ish scope. If your stack defaults to "never," override it; Anthropic and OpenAI both let org policy forbid the "Never" preset for exactly this reason. -
Give it a
jti, and remember it. Server-side set of used IDs with a TTL equal to the acceptance window. This is the two-minute piece of memory that turns a valid signature into a single-use approval. - Bind the signature to the context. Sign the audience and the nonce as well as the payload; that's the EIP-155 lesson. A signature that means the same thing in two environments is a replay waiting for a chain fork.
- Forward an idempotency key to anything downstream that can double-charge. Your dedupe layer is only as strong as the deepest service that doesn't check it.
- Make rotation a proposal killer. Key retired → outstanding proposals invalidated. If your format can't express that, the proposals are bearer tokens; shorten them until they can't do damage inside their lifetime.
- Design for the orphan. Signed, unsubmitted proposals expire on their own. AP2-style receipts that narrow scope after each action are the stronger version: the approval cannot be reused even within its window.
Nothing here is new engineering. It's the token discipline every API platform already applies to its own credentials, pointed at the artifact your agent now mints instead of a session cookie.
FAQ
How long should an agent approval live?
Short enough that the context that justified it can't change underneath it. For anything with a side effect: seconds to minutes (DPoP's guidance), or a single request with an idempotency key. For read scope, about an hour: that's where GCP agent tokens, Anthropic's shortest preset, and Entra's 60–90 minute default all land.
What is jti and why does a signed token need one?
It's the unique identifier claim in JWT. RFC 7519 defines it specifically to prevent replay. Storing it for the acceptance window and rejecting repeats is what makes an approval single-use instead of copyable.
What happens to already-approved actions when I rotate a key?
They should die. RFC 7009 revokes tokens for a grant; the security BCP pushes sender-constraining so a stolen token is unusable anyway. If your outstanding proposals survive a rotation, they were never bound to the key. They're bearer tokens.
What's an idempotency key and how long does it live?
A client-generated unique ID attached to a non-idempotent request so retries return the original response instead of executing twice. Stripe keeps them at least 24 hours; reuse after pruning starts a new request. The IETF header draft expired, so implementations differ. Check yours.
How do I stop a replayed tool call from running twice?
Three layers: short expiry so a stale copy can't be used, a single-use jti (or idempotency key) so a fresh copy of a used approval is rejected, and a unique constraint at the store so two concurrent executions can't both commit.
Sources
- RFC 9700, Best Current Practice for OAuth 2.0 Security (Jan 2025)
- RFC 7519, JSON Web Token (
exp,jti) - RFC 9449, DPoP: proof-of-possession, acceptance window
- RFC 9110, HTTP Semantics, §9.2.2 idempotent requests
- RFC 7009, OAuth 2.0 Token Revocation
- MCP Authorization specification (2025-06-18)
- OIDC Back-Channel Logout 1.0
- Google Cloud, token types (agent identity, ID token lifetimes)
- Anthropic, API key expiration
- Microsoft Entra, configurable token lifetimes
- AWS STS AssumeRole, DurationSeconds
- GitHub, personal access token expiration
- Stripe, idempotent requests
- A2A Protocol 1.0, idempotency, key rotation
- AP2, security and privacy considerations · agent authorization · payment mandate examples
- Macaroons: cookies with contextual caveats (NDSS 2014)
- Cloudflare, Thanksgiving 2023 security incident
- GitHub, alert on stolen OAuth user tokens (2022)
- CircleCI, January 2023 incident report
- Heroku, April 2022 incident review
- EIP-155, simple replay attack protection · Cyfrin, replay attacks in Ethereum
- Lukretium, double-charge case study (author labels the numbers constructed)
- IETF Idempotency-Key header draft (expired, no RFC)
Related on The Agent Loop
- Why your MCP approval gate never fires (and what to do instead)
- Your agent's tests pass. That's the problem.
- Can an AI agent have a bank account in 2026?
Over to you: what lifetime does the approval in your gate actually have right now, and if you had to guess, would a copy of it still work after a key rotation? Reply below, I read every one.
Top comments (0)