DEV Community

Cover image for How to Stop a Leaked AI Agent Key From Still Working With Kinde Access Tokens
Shola Jegede
Shola Jegede Subscriber

Posted on

How to Stop a Leaked AI Agent Key From Still Working With Kinde Access Tokens

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, 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.

Why the leaked token still works

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 do.

The enforcement seam

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");
Enter fullscreen mode Exit fullscreen mode

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, 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.

Two credential managers, one verification function

The static agent's credential manager fetches a token once and holds it:

let staticToken: CachedToken | null = null;
export async function getStaticCredential(): Promise<CachedToken> {
  if (staticToken) return staticToken;
  staticToken = await fetchM2MToken(
    env("STATIC_AGENT_CLIENT_ID"),
    env("STATIC_AGENT_CLIENT_SECRET"),
  );
  return staticToken;
}
Enter fullscreen mode Exit fullscreen mode

The rotating agent's credential manager checks the token's age before every use, with a 15-second safety margin, and fetches a new one if the old one is close to expiring:

const ROTATION_SAFETY_MARGIN_MS = 15_000;
let rotatingToken: CachedToken | null = null;
export async function getRotatingCredential(): Promise<CachedToken> {
  const now = Date.now();
  if (rotatingToken && now < rotatingToken.expiresAt - ROTATION_SAFETY_MARGIN_MS) {
    return rotatingToken;
  }
  rotatingToken = await fetchM2MToken(
    env("ROTATING_AGENT_CLIENT_ID"),
    env("ROTATING_AGENT_CLIENT_SECRET"),
  );
  return rotatingToken;
}
Enter fullscreen mode Exit fullscreen mode

Neither function knows about the other, or what the API on the other end will do with the token. The only thing that changes the outcome is how long Kinde told each token to live, and whether the calling code respects that.

A third file in the build, a closed action registry, lists the only three calls either agent may make: list_records, read_record, and export_records. The API rejects any other action before it even looks at the token. This keeps the demo's scope narrow: the test here is about token lifetime, not about which actions an agent should be allowed to take.

The bug I found during hardening

During a review of the API's verification code, a bug turned up. When a request carried no token, or a token from an application the API did not recognize, the logging code defaulted that request's log entry to mode: "static". An anonymous or malformed request would then show up in the dashboard as if the static agent had made it.

This did not affect the proof numbers below, since every call in this build came from one of the two known agents. It got fixed before the proof ran, though: unattributed requests now log as mode: "unknown", a separate value from "static" and "rotating".

Live proof

With both Kinde applications configured (24 hours for static, 120 seconds for rotating), the proof works in four steps. First, each agent asks Kinde for a token. Second, both tokens are captured at the same instant, the way a log scraper or a support-ticket screenshot would capture them. Third, the script waits past the rotating agent's 120-second window. Fourth, both captured tokens get replayed, unmodified, straight against the API, without going through either agent's own code.

Static Agent's app, at the default 86,400-second expiry:

Static agent Kinde token expiry

Rotating Agent's app, set to 120 seconds:

Rotating agent Kinde token expiry

The live dashboard, built with Convex, shows both agents' history in real time as each call happens:

Live dashboard showing both agents and the leak replay proof

The replay produced these results:

Agent Token expiry Replay result Status Latency
Static 24h (default) still works 200 1353ms
Rotating 120s dead 401 938ms

A stolen token's usable window: a day vs two minutes

The static agent's leaked token authenticated successfully more than two minutes after it was captured, using the exact same replay the rotating agent's token failed. It will keep authenticating for the rest of its 24-hour life, because nothing in this build, or in most production setups, checks whether a token has been copied. Kinde's own JWT verification only checks whether a token is signed correctly and has not expired. It has no way to know the token in front of it is a copy.

The rotating agent's leaked token failed after 120 seconds, the same limit set on its Kinde application. Whoever captured it had, at most, two minutes to use it before Kinde's own signature check started returning 401 on every attempt.

The gap between those two numbers, a day against two minutes, is the entire value of short-lived credentials. Nothing about the attack changes. Nothing about the API's verification code changes. Only the answer to "how long is a copy of this token worth anything" changes, and that answer comes from one number set on one screen in Kinde's dashboard.

What I think about all of this

Kinde already gives every M2M application a token with an expiry, and most teams never touch that setting. I don't think that is a Kinde problem. Most agent code never checks the expiry either; it just holds whatever Kinde handed back at start-up. Short-lived credentials are not a feature you have to add. The primitive already exists. What is missing is code that treats the expiry as real, instead of code that mints a token once and assumes it will always still be good.

The fix in this build took two changes: setting one number in Kinde's dashboard, and writing a credential manager that checks the clock before it checks a cache. Neither change touched the API's verification logic at all.

I would argue the harder habit to build is not the code. It is remembering that a credential an agent holds in memory is a credential that can leave that memory. Developers on Hacker News asked whether they would trust an agent with an API key at all. Most kept landing on the same answer: give the agent a temporary, narrowly scoped credential, issued through something that logs when it was requested. Not a long-lived key sitting in an environment variable. A short access token from an identity provider that already tracks every issuance is a plain way to get that, without building a separate credential broker.

Limitations

Kinde documents rotating an M2M application's client secret as a manual action, triggered from the dashboard or the Management API, not something Kinde does on a schedule. This build rotates the access token, which Kinde issues fresh on every client-credentials request. That is the layer that matters for a leaked-token scenario, but it is a different mechanism from secret rotation, and the two should not be confused for each other.

120 seconds is a number chosen so this proof runs in a couple of minutes, not a production recommendation. A production window more commonly sits between 5 and 15 minutes, traded off against how often a workload can tolerate a refresh call.

The API's JWT verification returns a generic 401 for every failure case. It does not tell a caller whether their token expired, or whether it came from an application the API does not recognize. That is fine for this demo. A real audit trail would need to tell those cases apart.

Code and sources

Full source: github.com/sholajegede/rotating-credentials-demo

Every number in this article came from a live run against a real Kinde tenant and a real Convex deployment. Clone the repo, run the same proof against your own tenant, and drop your numbers in the comments. If your static agent's leaked token survives longer than 24 hours, or your rotating agent's dies in under 120 seconds, tell me why.

Top comments (0)