AI Engineering
tutorial
Featured

Your AI Agent Shares One API Key. That Is the Problem.

A recent survey found 93% of AI agent projects still authenticate with an unscoped API key, and most agents end up with more access than they need. That single shared credential makes every action unattributable and turns one leak into a total breach. Here is how to give your agent its own workload identity and mint short-lived, delegated, audience-scoped tokens with OAuth token exchange instead.

Viral Ruparel
11 min read
Share:

A recent industry survey put a number on something most of us already suspected: 93% of AI agent projects still authenticate with an unscoped API key, and 74% of teams admitted their agents end up with more access than they actually need. If you have shipped an agent in the last year, there is a good chance it is holding a single long-lived token in an environment variable, and that one token is how it talks to your database, your payment provider, your ticketing system, and your internal search. It works. That is the trap.

It works because authentication is the one part of an agent that fails silently until the day it fails catastrophically. The agent makes its calls, the downstream services accept them, nothing throws. Then a prompt injection convinces the agent to call a tool it should never touch for this user, or the key leaks through a log, or a customer asks why the agent refunded someone else's order, and you discover that every call your agent has ever made is indistinguishable from every other call. Same key, same permissions, same anonymous line in the audit log. You cannot say which user's request triggered the action, and you cannot contain the damage to one user's slice, because there are no slices.

The business problem hiding behind the convenience

Think about what a shared key actually costs you the moment anything goes wrong.

The first cost is attribution. When your agent takes an action on behalf of a user, the downstream service sees the agent's key, not the user. So your logs say "the agent updated this record" with no way to answer "for whom." Every incident review, every compliance audit, every "who authorized this" question hits the same wall. This is not a theoretical concern once you are in a regulated industry or handling money, and it is the first thing a procurement security team now asks vendors about.

The second cost is blast radius. A shared key holds the union of every permission any user of the agent might ever need. If one user can issue refunds and another can read analytics, the shared key can do both, for everyone, all the time. So the day that key leaks, or the day the model gets talked into misusing it, the exposure is not one user's data. It is the entire surface area of everything the agent was ever allowed to do. This is the same confused-deputy dynamic I wrote about in the tool authorization post, except here the deputy is not just confused, it is holding a master key.

The third cost is that your other controls are built on sand. You may already have an authorization gate and a human approval step, both good ideas I have covered before. But authorization asks "is this caller allowed to do this," and if the caller is always the same anonymous key, your policy engine is reasoning about a fiction. Identity is the input every one of those controls silently assumes it has.

The fix is to stop treating the agent as a faceless process holding a god key, and start treating it as what it is: a distinct non-human identity that acts on behalf of humans. That means two facts have to travel with every call. Which software is making the call, and on whose behalf.

Two identities, one call

The agent has its own identity. This is a workload credential, the machine equivalent of an employee badge, and it should not be a static secret at all. The current answer is SPIFFE, which issues each workload a short-lived cryptographic identity (an SVID) that rotates automatically and never sits in an env var as a bearer token anyone can copy. Google leaned on exactly this for its Agent Identity work earlier in 2026, and it is becoming the default substrate for agent-to-service auth.

The user has their own identity. This is the session that started the request, the human who asked the agent to do something.

The mistake is picking one. If you send only the agent's identity, you lose the user and with it all attribution. If you send only the user's token, you have handed a raw user credential to a non-deterministic process and lost the fact that an agent, not the user directly, made the call. What you want is a token that carries both, and that is precisely the problem OAuth 2.0 Token Exchange (RFC 8693) was designed to solve.

Token exchange lets a client trade a token it holds for a new token scoped to a specific downstream service, while recording a delegation chain. You send the user's token as the subject_token, the party on whose behalf you are acting, and the agent's own token as the actor_token, the party doing the acting. The authorization server hands back a short-lived token whose subject is the user and whose nested act claim names the agent. Downstream, one call now answers both questions at once.

Here is the exchange itself. This is the agent asking your authorization server for a token scoped to exactly one downstream API, on behalf of exactly one user.

// Trade the user's session token + the agent's own workload token for a
// short-lived, audience-scoped delegated token (RFC 8693 token exchange).
async function exchangeForDelegatedToken(params: {
  userToken: string;   // the human's access token from the session
  agentToken: string;  // the agent's workload identity (e.g. a SPIFFE JWT-SVID)
  audience: string;    // the ONE downstream service this token may call
  scope: string;       // the least privilege needed for this action
}): Promise<{ accessToken: string; expiresIn: number }> {
  const body = new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
    // subject = on whose behalf we act; actor = who is acting
    subject_token: params.userToken,
    subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
    actor_token: params.agentToken,
    actor_token_type: "urn:ietf:params:oauth:token-type:jwt",
    audience: params.audience,
    scope: params.scope,
  });

  const res = await fetch("https://auth.internal/oauth2/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });
  if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);

  const json = await res.json();
  return { accessToken: json.access_token, expiresIn: json.expires_in };
}

Two things are doing the real work here. The audience pins the token to a single service, so a token minted for your ticketing API is useless against your payments API even if it leaks. The scope is the least privilege for this one action, not the union of everything the agent can do. If a prompt injection steers the agent wrong, the token in hand can do one narrow thing for one user for a couple of minutes, which is a bad afternoon rather than a company-ending breach.

Make it the only door

Minting delegated tokens is worth nothing if a developer can still reach past it and use the shared key directly. So the exchange belongs behind a single chokepoint that every outbound tool call goes through, the same discipline I keep coming back to: a control only works if it is the only door.

type ToolCall = { audience: string; scope: string; url: string; init?: RequestInit };

// One cache keyed by (user, audience, scope). Delegated tokens are short-lived,
// so we reuse within their window and re-exchange when they are near expiry.
const tokenCache = new Map<string, { token: string; expiresAt: number }>();

async function tokenFor(userId: string, userToken: string, call: ToolCall) {
  const key = `${userId}|${call.audience}|${call.scope}`;
  const hit = tokenCache.get(key);
  // Refresh 30s early so a call never rides an about-to-expire token.
  if (hit && hit.expiresAt - Date.now() > 30_000) return hit.token;

  const agentToken = await getWorkloadIdentity(); // rotating SPIFFE JWT-SVID
  const { accessToken, expiresIn } = await exchangeForDelegatedToken({
    userToken,
    agentToken,
    audience: call.audience,
    scope: call.scope,
  });
  tokenCache.set(key, { token: accessToken, expiresAt: Date.now() + expiresIn * 1000 });
  return accessToken;
}

// The single exit. The agent cannot reach a downstream service any other way,
// so there is no code path that falls back to a shared god key.
export async function callTool(ctx: { userId: string; userToken: string }, call: ToolCall) {
  const token = await tokenFor(ctx.userId, ctx.userToken, call);
  return fetch(call.url, {
    ...call.init,
    headers: { ...(call.init?.headers ?? {}), Authorization: `Bearer ${token}` },
  });
}

The important part is not the cache, it is that callTool is the only exported way to reach a downstream service. Wire the raw fetch and the shared key so they are unreachable from tool code. If a developer can still get bytes out of your database without going through this function, the whole scheme is theater, and you are back to a god key with extra steps.

The other half: actually check the delegation

A delegated token only pays off if the downstream service reads it. The whole point was attribution and least privilege, and both live on the receiving end. When your resource server validates the token, it should pull the user out of sub and the agent out of the nested act claim, then log and authorize against both.

// On the resource server. The delegated token names BOTH parties, so we can
// log who acted for whom and enforce that an agent-issued token stays scoped.
function authorize(claims: {
  sub: string;                    // the user the action is on behalf of
  act?: { sub: string };          // the agent that actually made the call
  aud: string;
  scope: string;
}) {
  if (claims.aud !== "https://tickets.internal") throw forbidden("wrong audience");

  const actor = claims.act?.sub ?? "unknown-agent";
  // This line is the payoff: attribution is now structural, not guesswork.
  log.info({ user: claims.sub, actingAgent: actor, scope: claims.scope }, "tool call");

  if (!claims.scope.split(" ").includes("tickets:write")) throw forbidden("scope");
}

Now an incident review reads "agent X acted for user Y with scope Z at time T," which is the sentence you were missing. This is the same trust-boundary posture I described for cross-organization calls in the A2A agent card verification post, applied inward to your own services.

Where this bites

A few things will trip you up, so plan for them.

Short-lived tokens mean expiry mid-task. An agent that runs for ten minutes on a token that lives for five will get a 401 partway through. Handle it by re-exchanging on demand, which the cache above already does, and by making sure a re-auth does not silently retry a side effect that already happened. Idempotency keys, which I covered separately, are what keep that retry safe.

Token exchange adds a network hop, and a chatty agent making dozens of tool calls will feel it. The per-audience, per-scope cache absorbs most of that, since one user's burst of calls to the same service reuses one token. Do not cache across users or across scopes, though, or you have quietly rebuilt the shared key.

The full SPIFFE plus token-exchange stack is real infrastructure, not an afternoon. If you are early, a defensible first step is a static token per environment scoped to one service, which at least kills the single-god-key pattern and gives you an audience boundary to build on. Just do not let "for now" become the architecture, because internal-only is exactly where the shared key quietly becomes permanent.

The takeaway

The reflex is to treat agent authentication as plumbing you configure once and forget. It is not plumbing, it is the foundation every other safety control stands on. A shared unscoped key makes attribution impossible and blast radius total, and it turns your authorization and approval gates into theater because they have no real subject to reason about. Give the agent its own workload identity, mint a short-lived token per user and per downstream service through token exchange so every call carries both who is acting and on whose behalf, and put that behind the single door your tool calls cannot go around. The result is an agent whose every action is attributable to a real pair of identities and scoped to one narrow thing, which is the difference between a bad afternoon and a breach report.

If you are shipping an agent that still runs on one shared key and you are not sure how to untangle it without breaking every tool call, that is a good problem to walk through together. Book a consultation call and we can map where your agent authenticates today, find the calls that would lose attribution or over-reach, and put a delegated identity gate in front of them.

Viral Ruparel

Generative AI consultant helping teams ship reliable LLM and agent systems in production.

Contact Viral about your AI project →

Frequently Asked Questions

Why is a shared API key a problem if the agent works fine with it?+

It works right up until it does not, and then it fails badly. A shared key means every call your agent makes looks identical in the downstream logs, so when something goes wrong you cannot tell which user's request caused it or whether the agent acted at all. The key also carries the union of every permission any user might need, so a prompt injection or a leaked value gives an attacker the full blast radius rather than one user's slice. The agent working is not the bar. The bar is that when it misbehaves you can attribute the action and contain the damage, and a shared key gives you neither.

What is the difference between the agent's identity and the user's identity?+

They are two separate facts and a downstream service needs both. The agent's identity answers which piece of software is making this call, and it comes from a workload credential like a SPIFFE SVID or a service account, not from anything the model said. The user's identity answers on whose behalf the call is being made, and it comes from the session that started the request. OAuth token exchange lets you carry both in one token, with the user as the subject and the agent recorded in the nested actor claim, so the resource you call can log and authorize against the real pair instead of a single anonymous key.

Does this replace my authorization policy or the human approval gate?+

No, it sits underneath them and makes them trustworthy. Identity answers who is calling and on whose behalf; authorization answers whether that caller is allowed to do this specific thing. If the identity is a shared key, your policy engine is reasoning about a fiction, because every request looks the same. Once each call carries a real delegated identity, your policy-as-code gate and your human-in-the-loop approvals finally have an authenticated subject to decide against. Identity is the input those controls were always assuming they had.

Is OAuth token exchange overkill for an internal-only agent?+

The full SPIFFE and token-exchange stack is more than a weekend project, so if you are shipping a prototype, a static scoped token per environment is a reasonable first step. But internal is exactly where the shared-key habit calcifies, because nobody is forcing the question, and internal services are where agents accumulate the broadest access. The moment an agent acts on behalf of distinct users, touches anything regulated, or needs an audit trail, the delegated model stops being overkill and starts being the only design that can answer who did what.