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.
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 by binding it to the client that initiated the flow. It does not make an already issued bearer access token unusable. Limit stolen-token replay with audience restriction, short lifetime, downstream authorization and sender-constrained tokens such as DPoP or mTLS.
Key risks include prompt injection, especially indirect injection via tool or retrieval outputs, hijacking the agent, excessive tool permissions enabling damaging actions, data exfiltration, confused-deputy privilege escalation, and unbounded loops driving cost or harm. Mitigations include least-privilege tools, sandboxing, input and output guardrails, human-in-the-loop approval for sensitive actions, and audit logging.
A confused deputy occurs when an agent uses its elevated permissions to perform an action on behalf of a less-privileged caller that the caller could not do directly, leading to privilege escalation. The root cause is that a trusted agent acts on natural-language requests, including from other agents, without verifying the originator's authority, so robust systems propagate identity and scope on every hop and enforce access control on agent-to-agent calls.