Build1 publisher2 min readPublished
A $10 limit stored as text on a Kinde token cut the agent off after four calls
A dev.to build reproduces the runaway spend Mandiant reported in September by pointing three agents at the same Convex route and changing only where the server looks for the running total. Each call costs $2.50.
The Engineer · Build desk

What happened
- Mandiant's September 2026 AI Risk and Resilience report describes an accounting agent that looped through more than 15,000 API calls and about $50,000 of cloud spend in under an hour, with no attacker involved.
- A dev.to build points three agents at one Convex route, varying only which claims their Kinde token carries and whether the server checks those claims against its own ledger or against the request.
- The unmetered agent made eight calls priced at $2.50 each, all eight went through for $20 of spend, and the server left it uncapped.
- The metered agent's token carried a $10 limit, its first four calls took the running total to exactly $10, and the server answered every call after that with a 402.
- A third agent reused the metered agent's token, its signature still verified, and the route it hit read the spend total from a claimedSpend query parameter on the request.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision The identity provider supplies only the first of two checks, signature verification, so any team adopting agent tokens writes and owns the second, budget authority.
- exposure A route that takes the running total from its caller lets whoever holds a valid token set their own ceiling, and the signature check will keep passing.
- cost A Property left Private stays out of the token, and the server's default for a missing limit is uncapped spend charged to an unattributed owner.
- constraint Anyone porting this pattern has to show the ledger's read and increment serialise, because the test drove calls sequentially while the incident it models ran at over four calls a second.
The split sits on one line. `jwtVerify` gets the token, the JWKS, and an issuer and audience read out of the environment, and it throws when the signature is wrong or the token has expired [11]. Everything after that line is the build's own code, not Kinde's [11]. It takes the mode off `payload.azp`, reads `agent_owner` from `payload.application_properties` with a `?? "unattributed"` fallback, and reads `agent_spend_limit_usd` the same way [12]. Kinde checks issuance and expiry [13].
Kinde keeps that custom data as Properties on the machine-to-machine application, and a Property is single line text, multi line text, or boolean [14]. The list stops there, so a dollar ceiling is stored as text and parsed with `Number()` on the server [14]. To ship at all, the Property has to be scoped to Applications to attach to an M2M app, and its Private toggle has to be off; otherwise the token ships without it [15]. What does arrive is wrapped, `{"agent_spend_limit_usd": {"v": "10.00"}}`, so the server reads `.v` and not the claim [16].
Missing claims fail open. A Property left Private issues the same token as an application whose Properties tab is empty [15][17], and the server treats calls from that application as unattributed and uncapped [5]. That fallback is written into the code as a string [12].
Four calls at $2.50 come to exactly $10.00 [1], so the denials start when recorded spend equals the limit, without ever passing it. Where that total comes from is the only difference between the enforcement route and the control route, which reads it from a `claimedSpend` query parameter on the request [9]. Mid-run, the control was still reporting $0 claimed spend [10].
Two conditions decide whether the ceiling transfers to the incident it models. Mandiant's loop spent about $50,000 across more than 15,000 calls, roughly $3.33 a call [1][2], in under an hour. More than four calls a second, sustained [3]. The demo made its calls one at a time [6]. A per-call ledger only holds at that rate if the read and the increment are serialised, and a sequential run leaves that untested. The write-up does not report the latency the ledger check adds [18].
The second condition is ownership of the route. This cap binds because the server prices the call at $2.50 and writes the total itself [6]. Mandiant's agent burned cloud spend [1], and calls to someone else's API pass through a Convex route only if you put a gateway in front of them. The link from that incident to token claims is the dev.to post's own argument [2].
What to watch
- A number type for Kinde Properties would move the limit parse out of application code.
- A concurrency run against the same Convex ledger, at more than four calls a second, would show whether the read and increment serialise.
- Whether Mandiant's full report says what in the accounting agent's own path failed to stop the loop.