Token Theft in AI Agents — Credential-Blind Execution
Keep API keys and OAuth tokens out of model context, then authorize every tool call at runtime. Build a safe fake-token lab and learn vault injection, least privilege, audience restriction, DPoP, and approval gates.
Written and reviewed by Shreyash Prashu Last reviewed
What you'll learn
- Why a secret in model context is already outside the security boundary
- How credential-blind tool execution keeps raw tokens away from the model
- How scope, audience, lifetime, and proof of possession limit stolen-token replay
- Why authorization must run after planning but before dispatch
- How to test token-exfiltration defenses using fake credentials only
Before you start
An AI agent usually touches a credential in one of three places: its prompt, the process environment, or a tool executor. Only the third location is a good default. Prompts are observable by the model and may appear in traces. Process-wide environment variables are invisible to the model until you give it a shell, debugger, file reader, or error log that can expose them. A narrow executor can keep the secret behind a typed operation.
This lesson uses fake strings such as tok_demo_read_7f3a. Never paste a real
credential into a prompt, playground, test fixture, screenshot, or trace.
The attack path
Token theft in an agent is normally a chain, not one bug:
- An attacker controls text the agent will read: an email, issue, page, PDF, retrieved chunk, or tool result.
- The text induces the model to request a dangerous action or disclose data.
- A raw credential is visible to the model, or an overpowered tool silently attaches it.
- The system has an egress channel: HTTP, email, a pull request, a rendered image URL, a generated file, or logs visible to another party.
The first step is prompt injection. The third and fourth steps turn it into a breach. This is why “tell the model not to reveal secrets” is not a security control. The model must not possess the secret in the first place.
Put policy between plan and action
The model can propose create_ticket(customer_id, summary). It cannot decide
whether the current user may create that ticket, select a broader OAuth scope,
or attach a bearer token. Those are deterministic checks.
from dataclasses import dataclass
@dataclass(frozen=True)
class Action:
tool: str
operation: str
resource: str
external_write: bool = False
def authorize(user, action):
allowed = user["scopes"]
required = f"{action.tool}:{action.operation}"
if required not in allowed:
return "deny"
if action.external_write:
return "approve" # pause for a human
return "allow"
The important placement is after the model produces a structured action
and before the executor dispatches it. An input-only guardrail cannot see
the final tool arguments. An output-only secret scanner may catch a literal
token, but it cannot tell whether delete_repository("prod") was authorized.
Four properties every agent token needs
The IETF’s OAuth 2.0 Security Best Current Practice (RFC 9700) gives us a useful design vocabulary:
| Property | Question | Why it limits damage |
|---|---|---|
| Scope | What operations may it perform? | A read token cannot delete or send. |
| Audience | Which resource server accepts it? | A token for Drive is rejected by Slack. |
| Lifetime | How long is it useful? | Short-lived access narrows the replay window. |
| Sender constraint | Which client proves it owns the token? | DPoP (RFC 9449) or mTLS-bound tokens resist replay by a thief who has only the token string. |
RFC 9700 recommends minimum required privileges, audience restriction, and sender-constrained access tokens where feasible. Refresh tokens for public clients must be sender-constrained or rotated. These controls do not excuse a leak: they make a leaked credential less reusable and less powerful.
Safe lab: can the request leave the sandbox?
This lab has no network call and no real secret — only the deterministic policy
that sits between a (possibly injected) plan and dispatch. Before reading the
output, predict each verdict: the attacker’s exfiltration send, a read aimed at
an un-allowlisted destination, a read whose payload smuggles the fake secret, and
an ordinary read. Which gate catches each one?
from dataclasses import dataclass
@dataclass
class Request:
operation: str
destination: str
data: str
FAKE_SECRET = "tok_demo_read_7f3a" # inert test string
user_scopes = {"tickets:read"}
approved_destinations = {"support.internal.example"}
def decide(req, human_approved=False):
required = "tickets:write" if req.operation == "send" else "tickets:read"
if required not in user_scopes:
return "DENY: missing " + required
if req.destination not in approved_destinations:
return "DENY: destination is not allowlisted"
if FAKE_SECRET in req.data:
return "DENY: fake-secret detector fired"
if req.operation == "send" and not human_approved:
return "PAUSE: external write needs approval"
return "ALLOW"
# The injected attacker's request tries to exfiltrate via an external "send".
attack = Request("send", "collect.attacker.example", FAKE_SECRET)
print("attack ->", decide(attack))
print("read, bad dest ->", decide(Request("read", "collect.attacker.example", "ticket #42")))
print("read, secret ->", decide(Request("read", "support.internal.example", FAKE_SECRET)))
print("legit read ->", decide(Request("read", "support.internal.example", "ticket #42")))
print("The executor never received a real credential.")
attack -> DENY: missing tickets:write
read, bad dest -> DENY: destination is not allowlisted
read, secret -> DENY: fake-secret detector fired
legit read -> ALLOW
The executor never received a real credential.
The gates are ordered, and each is independent. The attacker’s send never even
reaches the destination or secret checks — it lacks the scope, so least privilege
stops it first. (A request that did hold tickets:write and tried an external send
would fall through to the last gate, PAUSE: external write needs approval.) Every
verdict comes from deterministic policy on the typed action — not the model’s
confidence, and not a raw credential, which the executor attaches only after ALLOW.
Production checklist
- Keep secrets out of prompts, tool schemas, model-visible environment dumps, exception text, and traces.
- Let a server-side executor fetch a per-user, per-tool credential only after policy allows the typed action.
- Default tools to read-only; split broad tools into narrow operations.
- Bind tokens to the smallest scope and audience; prefer short lifetimes and rotation. Use DPoP or mTLS where the ecosystem supports it.
- Re-authorize at the resource server. The agent’s decision is never proof of the user’s permission.
- Redact request headers, query strings, tool results, and errors before they enter logs or model context.
- Put sends, deletes, payments, deployments, and permission changes behind an explicit approval gate with the exact action and destination shown.
- Test with canary strings and fake services. Measure leakage rate, task success, false positives, approval burden, and added latency.
In one breath
- A secret in model context is already outside the security boundary — a malicious doc, email, or tool result can make the model repeat it, so “tell the model not to reveal secrets” is not a control.
- Credential-blind execution: the model proposes a typed action, deterministic policy authorizes it, and only a trusted executor injects the scoped credential for that one API call.
- Run authorization after planning, before dispatch, on the final structured action and arguments — an input guardrail can’t see them, an output scanner can’t tell whether the action was authorized.
- Give every agent token four properties — scope, audience, lifetime, sender-constraint (DPoP/mTLS) — to blunt a stolen token’s replay; PKCE doesn’t help once a bearer token is taken.
- Re-authorize at the resource server, redact secrets/results out of logs and context, default tools to read-only, and gate sends/deletes/payments behind explicit approval — the model’s decision is never proof of permission.
Quick check
Quick check
Next
Credentials are only half the boundary. The next lesson covers what happens when the tool catalogue itself lies: MCP tool poisoning, shadowing, and rug pulls.
Practice this in an interview
All questionsKeep raw credentials outside model context and traces. Let the model propose typed intent, authorize the final action and arguments deterministically, then have a trusted executor inject a short-lived, narrowly scoped, audience-restricted credential for one call. Re-authorize downstream and gate high-impact writes with explicit approval.
No. PKCE protects the authorization-code exchange, not a copied bearer access token. Reduce replay risk with audience and scope restrictions, short-lived tokens, resource-server authorization, and sender-constrained tokens such as DPoP or mTLS.
Autonomous agents are risky because untrusted prompts, retrieved documents, tool outputs, and memories can influence a model that has real authority to read data and take actions. The main risks are prompt injection and hijacking, excessive permissions and confused-deputy actions, data exfiltration, poisoned memory or tools, and runaway cost or destructive loops; defenses must enforce authorization, isolation, approvals, validation, budgets, and auditability outside the model.
In a normal MCP deployment, blindly forwarding the client’s bearer token to a downstream API violates the token’s audience boundary and can create confused-deputy, replay, and audit risks. The server should validate a token intended for itself, authorize the tool call, and obtain a separate, narrowly scoped credential for the downstream resource.