How to Stop an AI Agent That Lies About Its Own Spending
In September 2026, Mandiant described a failure in its AI Risk and Resilience report, drawing on data from Google's Threat Intelligence Group. An accounting agent entered a runaway loop where it fired more than 15,000 API calls, burning about $50,000 in cloud spend in under an hour, and yet no attacker had touched it. This failure points at a specific gap that we will discuss in this article. A token issued to an AI agent proves who issued it, but tt does not say who owns the spending it causes, or how much that owner has left to spend. To reproduce the failure mode described by Mandiant, we will be building a demo application with two products: Kinde, which is an identity provider that issues signed access tokens for machine callers and can attach custom data to them, and Convex, a backend platform with a reactive database that runs the server that checks every call. The map, in one screen In our demo, three agents will call the same Convex route. What changes is which claims their Kinde token carries, and whether the server checks those claims against its own ledger or against the request itself. One agent's token carries no owner and no spend limit. The server treats it as unattributed and uncapped. A second agent's token carries both claims. The server reads them from the verified token and checks them against a running total it keeps in Convex. A third agent reuses the second agent's own token. The server still verifies the signature, but its enforcement route reads the spend total from the request instead of the ledger. During the test, both agents made the same calls, one at a time, against a real Kinde tenant and a real Convex deployment, each call priced at $2.50. The unmetered agent made 8 calls. All 8 went through, for $20 spent, with no ceiling in sight. The metered agent made the same calls with a $10 limit set on its token. The first 4 went through, taking it to exactly $10. The server denied every call after that with a 402. The live dashboard mid-run: the unmetered agent's total climbing past $20, the metered agent capped at $10, and the naive control still reporting $0 claimed spend. Why doesn't a valid signature stop overspending? A JSON Web Token, or JWT, is a signed, verifiable claim. Kinde signs every access token it issues. Any server that trusts Kinde can check that signature without calling Kinde back. A valid signature goes on to answer one question: did Kinde issue this token, and has it expired? It does not answer the second question that we have for this test: has this agent already spent its budget? And that second question needs its own check, written into the server, on top of signature verification. const { payload } = await jwtVerify(token, getJwks(), { issuer: requiredEnv("KINDE_ISSUER"), audience: requiredEnv("KINDE_M2M_AUDIENCE"), }); const mode = modeForClientId(payload.azp as string | undefined); const owner = readProperty(payload.application_properties, "agent_owner") ?? "unattributed"; const spendLimitRaw = readProperty(payload.application_properties, "agent_spend_limit_usd"); jwtVerify throws if the signature is wrong or the token expired. Everything after that line is this build's own code, not Kinde's. It reads two claims off the verified token: who owns this call, and what that owner's limit is. How does Kinde attach an owner and a limit to a token? Kinde stores custom data on an M2M application as Properties. A Property has one of three types: single line text, multi line text, or boolean. There is no number type, so a spend limit gets stored as text and parsed with Number() on the server. A Property must be scoped to Applications to attach to an M2M app. Its Private toggle must be off, or it never appears in a token. From there, the application's Properties tab sets a value, and its Tokens tab, under Token customization, turns that Property on for the M2M token. The unmetered application's Properties tab: empty, so the server treats every call from it as unattributed and uncapped. The metered application's Token customization tab: both the owner and spend-limit properties switched on, so they ride along in every access token this app issues. A token that carries these claims does not carry them as plain values. Each one arrives wrapped: { "agent_owner": { "v": "finance-ops" }, "agent_spend_limit_usd": { "v": "10.00" } } The server has to read .v off each claim, not the claim itself. What happens when the server trusts the caller instead of the ledger? The metered agent's enforcement route reads its spend total from Convex. While, a second route in this build, built only to test the failure mode, reads the spend total from a claimedSpend query parameter on the request instead. Both routes check the same math against the same kind of token. Only the source of the number changes. // The metered agent's route, checked against the ledger Convex itself wrote allowed = currentTotal + args.costUsd <= args.spendLimitUsd; // The naive route, checked against what the caller claims allowed = args.claimedSpendUsd + args.costUsd <= args.spendLimitUsd; The test for the naive route fired 20 calls at it with the metered agent's own token, each one claiming $0 already spent. Each call cost $2.50. The ledger was reset to zero before the run, so the result could not be explained by leftover balance from an earlier session. The naive route trusts the number in the request. The metered route reads the number it wrote itself. All 20 calls returned 200. The claimed spend stayed $0.00 the whole time. Real spend, tracked in the background by the same ledger the metered route uses, reached $50.00 against the agent's own $10.00 limit. A clean result here could lie two ways. If the route never wrote to the ledger at all, a $0 claimed-spend result would only mean nothing got recorded, not that the check passed. If the test forgot to reset the ledger before the run, an early denial could look like the limit working when it was really an old balance carried over. This build hit exactly that second bug once. An early version of the metered agent's own proof script reused a ledger balance left over from a previous run, and the agent looked capped from call one, before any of the fix under test had run. Resetting the ledger before every run fixed it. Does concurrency break the count? The metered agent's ledger check runs inside a Convex mutation. Convex queues mutations and runs them one at a time, even when many calls arrive at once. That queuing should keep the total correct under concurrent load. It says nothing about which specific calls get to be part of that total. The concurrency test settled that directly. It fired 20 calls at the real, ledger-enforced route at the same instant, using the same $10 limit and $2.50 cost as the sequential test. The math says exactly 4 calls should be allowed, since 4 times $2.50 is $10. Exactly 4 calls were allowed. The count was correct on every run. The 4 calls that won were not the first 4 to arrive. They were calls 20, 7, 5, and 19, in that order, based on the order Convex actually processed them in. If a system assumed a correct total also meant first-come-first-served fairness, this build would have proven that assumption wrong. Convex's mutation queue guarantees the total. It does not guarantee arrival order. Limitations This build ran on one Convex deployment. It did not test enforcement split across multiple deployments or regions. Kinde stores the spend limit as text, not a number. The server parses it before comparing it, and a malformed value would need its own check. However, this build assumes an operator sets the Property correctly. The mutation that resets a ledger before each proof run exists only for testing. No production request path in this build calls it. The naive route exists only inside this demo. Kinde did not cause it, and nothing about Kinde's tokens encourages it. This build's own server code chose to trust a claimed number instead of its own ledger, on purpose, to show what that choice costs. The concurrency test fired 20 calls at once. It did not test hundreds or thousands of simultaneous calls, or calls arriving across more than one server instance. Where this leaves things A signed token proves Kinde issued it. It does not prove the number attached to a call is true. The only number a server can trust is the one it wrote itself, in its own ledger, after its own check. Everything else is something the caller said. FAQ What is the difference between the unmetered and metered agent in this build? The unmetered agent's token carries no owner or spend-limit claim, so the server treats every call as unattributed and uncapped. The metered agent's token carries both claims, and the server checks them against a running total it keeps for that owner in Convex. Why does the naive agent's token still pass signature verification if it is lying about its spend? A JWT's signature only proves Kinde issued the token and it has not expired. It says nothing about the rest of the request. The naive route reads a claimed spend value from a query parameter the caller controls. Signature verification has no way to catch that, because checking a claimed dollar amount is not what a signature check does. Why store the spend limit as a Kinde Property instead of a custom claim somewhere else? A Kinde Property attaches directly to an M2M application. Token customization can include it in every token that application issues. No separate lookup is needed to find which agent owns which limit. The tradeoff is Kinde's Property types, which store text, not numbers, so the server parses the value before comparing it. What happens if two calls arrive at the same instant? Convex queues mutations and runs them one at a time. Both calls still get a correct answer: whichever one Convex processes first sees the lower ledger total and may be allowed, and the other sees the updated total and may be denied. The total stays correct. Which specific call wins is not guarante
Comments
No comments yet. Start the discussion.