How to Stop a Leaked AI Agent Key From Still Working With Kinde Access Tokens A developer built two versions of an AI agent against identity provider Kinde to demonstrate how short-lived, rotating machine-to-machine access tokens limit the damage of leaked credentials, compared with a static agent that reuses one long-lived token. The experiment follows reports that exposed agent credentials were used to breach 395 organizations and a Hugging Face breach tied to an overlong-lived agent token. In the build, a rotating agent refreshes its token before expiry so any stolen copy stops working within minutes, while the static agent's token remains valid for its full configured lifetime. In September 2026, VentureBeat reported that AI agents had used exposed credentials to breach 395 organizations. The report made a simple point: identity systems still treat an agent's credential the way they treat a human's password. Nobody expects a human to type a password every few minutes, so nobody had built agent credentials to expire that fast either. And this was a gap the attackers saw and used. A separate breach at Hugging Face traced back to the same failure: an agent held a credential that outlived the task it was issued for. Most AI agents get one machine-to-machine M2M access token at start-up and keep it for the life of the process. If that token ends up in a log file, a support ticket, or a copied environment variable, it stays valid for however long its issuer configured it. For most identity providers, that is somewhere between one hour and one day. To try to solve this problem, I built two versions of the same agent against Kinde https://kinde.com/?utm source=devto&utm medium=content&utm campaign=shola&campaignid=chatgptapp&network=&adgroup=&keyword=&matchtype=&creative=25&device=&adposition= , an identity provider that issues M2M access tokens and verifies every call against them. Kinde also lets you set, per application, how long an access token lives before it expires. One agent in my build takes Kinde's default and never checks it again. The other agent treats its token as something with a short shelf life, and rebuilds it before that shelf life runs out. Then I'm going to try to steal both tokens and reuse them again. An M2M credential in Kinde has two parts: a client ID and a client secret, and an access token that Kinde issues when an app presents that ID and secret. The access token is what the app actually sends on every API call. Kinde signs it, and any server that trusts Kinde can verify that signature without calling Kinde back. The client ID and secret rarely change. Most teams set them once, in an environment variable, and leave them alone. The access token is supposed to be different: Kinde gives it a lifetime, after which it stops working no matter who holds it. The problem is what an agent's code does with that access token after Kinde hands it over. A static agent fetches one token when it starts, stores it in memory, and reuses it for every call until the process restarts. If that process runs for hours or days, so does the token. If someone copies the token during that time, they hold something that is still good. A rotating agent does the opposite. It checks the token's age before every call. And before the token gets close to expiring, the agent throws it away and asks Kinde for a new one. A copy of that token, made at any point in its short life, stops working within minutes. Both agents get their tokens the same way: a client-credentials grant. The agent sends its client ID and client secret directly to Kinde's token endpoint. There is no human and no browser involved in this process. Kinde sends back a signed access token. This is the standard OAuth flow for service-to-service calls, and it is the same flow whether the token that comes back lives for two minutes or two days. The grant itself does not decide how long the token is good for. The application's settings in Kinde https://kinde.com/?utm source=devto&utm medium=content&utm campaign=shola&campaignid=chatgptapp&network=&adgroup=&keyword=&matchtype=&creative=25&device=&adposition= do. Both agents in this build call the same API, and that API runs the same check on every request, with no branch for which agent is calling. sequenceDiagram participant Agent as Agent static or rotating participant Kinde participant API as Records API Convex Agent- Kinde: client credentials grant Kinde-- Agent: access token 24h or 120s expiry Agent- API: GET /api/records?action=... Bearer token API- Kinde: verify signature via JWKS API-- Agent: 200 + data, or 401 if expired/invalid Note over Agent,API: Later, an attacker replays a captured token directly Agent- API: same token, no agent involved API-- Agent: static: 200 still valid / rotating: 401 expired The API is a single Convex function. It reads the bearer token from the request, checks its signature against Kinde's public key set JWKS , and checks that the token has not expired: const { payload } = await jwtVerify token, getJwks , { issuer: requiredEnv "KINDE ISSUER" , audience: requiredEnv "KINDE M2M AUDIENCE" , } ; const mode = modeForClientId payload.azp as string | undefined ; if mode return await deny 403, "token not issued to a known agent client" ; The jwtVerify call, from the jose library, throws the moment a token's signature is wrong or its exp claim has passed. That single check is what turns a Kinde-configured expiry into an actual rejection. Nothing about this function knows or cares which agent sent the request; it treats every token the same way, and the token's own expiry decides the outcome. The only difference between the two agents lives in Kinde's dashboard https://kinde.com/?utm source=devto&utm medium=content&utm campaign=shola&campaignid=chatgptapp&network=&adgroup=&keyword=&matchtype=&creative=25&device=&adposition= , not in this code. The static agent's Kinde application keeps the default access token expiry: 86,400 seconds, one full day. The rotating agent's application is set to 120 seconds. Change that one number, and the identical verification code above starts rejecting tokens on a different schedule, with no redeploy of its own. The static agent's credential manager fetches a token once and holds it: js let staticToken: CachedToken | null = null; export async function getStaticCredential : Promise