Refresh Token Replay
Someone else is using your AI agent's keys.
Why it works
A refresh token is a bearer secret. Without rotation, a copy works just like the original until it expires.
The fix
Rotate on every refresh. If an old token comes back, revoke the whole family. Bind tokens to the client with DPoP.
Context
AI agents and LLM apps act on a user's behalf through OAuth-connected tools (Gmail, GitHub, Slack, Drive). To keep working without asking the user to log in again, the agent stores a long-lived refresh token and exchanges it at the authorization server for short-lived access tokens. Refresh tokens often live for weeks or months, and agent platforms hold many of them: one per user, per connector.
Architecture
| Component | Role | Notes |
|---|---|---|
| AI Agent (OAuth client) | Calls tools on the user's behalf | Stores refresh tokens in a DB / secret store |
| Refresh token | Long-lived credential to mint access tokens | Bearer by default: whoever holds it can use it |
| Auth server | Exchanges refresh token → access token | Validates signature/expiry, not who presents it |
| Resource APIs | Accept access tokens | Short-lived (minutes–1 h) |
| Attacker | Obtains a copy of the refresh token | Logs, backups, debug dumps, compromised plugin, XSS on a web client |
Request flow
- User connects a tool; the agent receives an access token + refresh token.
- When the access token expires, the agent sends the refresh token to the auth server.
- The auth server returns a new access token (and, without rotation, the same refresh token stays valid).
- The agent keeps calling the tool API.
The failure mode
The refresh token leaks — a request log that captured the token response, a database backup, a tool plugin with too-broad access. The attacker sends it to the token endpoint. Because refresh tokens are bearer credentials and the server doesn't rotate them, the request is indistinguishable from the agent's: it gets valid access tokens. Both the agent and the attacker keep refreshing in parallel, and nothing alerts until the token expires or someone notices data access. Revoking a single access token doesn't help — the attacker simply refreshes again.
Why it happens
A long-lived bearer secret with no rotation and no binding to the client that was issued it.
The fix
- Refresh token rotation with reuse detection: every refresh returns a new refresh token and invalidates the old one. Tokens from one login form a family. If an already-used token is ever presented again, someone has a copy, so revoke the whole family and force a re-login.
- Sender-constrained tokens: bind tokens to a key the client holds, using DPoP (RFC 9449) or mutual-TLS (RFC 8705), so a copied token is useless without the private key.
- Hygiene: never log token responses; encrypt refresh tokens at rest; scope them narrowly; cap absolute lifetime; alert on refreshes from new IPs or ASNs.
Trade-offs
- Rotation needs care with concurrent refreshes (two workers refreshing at once look like reuse). Allow a short grace window or serialise refreshes per token family.
- DPoP adds a signed proof to each request and key management on the client.
- Reuse detection logs the legitimate user out too. That is the point, but it is a UX cost.
Numbers worth knowing
- The OAuth 2.0 Security Best Current Practice (RFC 9700, Jan 2025) requires refresh tokens for public clients to be either sender-constrained or rotated.
- DPoP is standardised as RFC 9449 (2023).
- Token lifetimes in the video are illustrative ("months"); they vary by provider.
Coming next in the series: Token Rotation done right