Your AI agent needs to call GitHub, Slack, Google Calendar on behalf of a user. The user has already authorized your app via OAuth. Now the agent needs the token.
Where does it live? How does the agent get it? What happens when it expires?
Most tutorials say "store the token in your database." That works until you have 10 agents, 5 OAuth providers, and a security audit asking why plaintext access tokens sit next to user profiles in Postgres.
Here are three patterns I've used in production, ranked by security posture.
Pattern 1: Direct Database Storage (The Baseline)
Store tokens in your database, encrypted at rest. Simple. Every tutorial teaches this.
// schema.prisma
model OAuthToken {
id String @id @default(cuid())
userId String
provider String
accessToken String // encrypted via Prisma middleware
refreshToken String?
expiresAt DateTime
scopes String[]
@@unique([userId, provider])
}
// token-store.ts
async function getToken(userId: string, provider: string): Promise<string> {
const record = await prisma.oAuthToken.findUnique({
where: { userId_provider: { userId, provider } }
});
if (!record) throw new Error(`No ${provider} token for user ${userId}`);
if (record.expiresAt < new Date()) {
return await refreshToken(record);
}
return record.accessToken;
}
When this works: Small apps, single agent, you control the whole stack.
When it breaks: Your agent framework runs tools in parallel. Two tools both detect an expired token. Both call the refresh endpoint. One gets a new token, the other gets a "token already used" error from the OAuth provider. Now you need distributed locking around token refresh.
Also: your database now contains credentials that access user accounts on third-party services. That's a different threat model than storing user preferences.
Pattern 2: Token Vault with RFC 8693 Exchange
This is what Auth0 ships as "Token Vault" in their AI SDK. The idea: your app never stores OAuth tokens directly. Instead, you store a reference, and exchange it for the real token at execution time using RFC 8693 (OAuth Token Exchange).
// Using @auth0/ai-vercel
import { Auth0AI } from '@auth0/ai-vercel';
const auth0AI = new Auth0AI();
// Define a tool that needs GitHub access
const getRecentPRs = auth0AI.withTokenForConnection(
{
connection: 'github',
scopes: ['repo', 'read:user']
},
({ accessToken }) => {
// accessToken is fresh, just-in-time, never stored in your DB
return tool({
description: 'Get recent pull requests',
parameters: z.object({ repo: z.string() }),
execute: async ({ repo }) => {
const res = await fetch(
`https://api.github.com/repos/${repo}/pulls?state=all&sort=updated&per_page=10`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
return res.json();
}
});
}
);
What happens under the hood:
- User authenticates with your app (normal OAuth flow)
- Auth0 stores the upstream tokens (GitHub, Google, Slack) in their vault
- When the agent invokes a tool, the SDK exchanges the user's session token for the upstream access token via RFC 8693
- The upstream token is returned just-in-time, used, and discarded
- If the upstream token expired, Auth0 handles the refresh internally
Agent calls tool
-> SDK detects tool needs GitHub token
-> SDK sends RFC 8693 request to Auth0
-> Auth0 returns fresh GitHub access token
-> Tool executes with token
-> Token is not persisted in your app
The security win: Your application database contains zero third-party credentials. If your DB leaks, attackers get user profiles and preferences, not GitHub/Slack/Google tokens. The blast radius of a breach shrinks dramatically.
The operational win: Token refresh, rotation, and revocation are Auth0's problem. No distributed locking. No race conditions on refresh. No migration when a provider changes their token lifetime.
Trade-off: You depend on Auth0's availability for every tool call. Added latency per tool invocation (one extra HTTP round-trip). Vendor lock-in on Auth0's token exchange implementation.
Pattern 3: Encrypted Sidecar with Automatic Rotation
Middle ground. You store tokens yourself, but in a dedicated secrets store, not your application database.
// token-sidecar.ts
import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const sm = new SecretsManagerClient({ region: 'us-east-1' });
interface StoredToken {
accessToken: string;
refreshToken: string;
expiresAt: number;
provider: string;
}
async function getAgentToken(
userId: string,
provider: string
): Promise<string> {
const secretId = `agent-tokens/${userId}/${provider}`;
const { SecretString } = await sm.send(
new GetSecretValueCommand({ SecretId: secretId })
);
const stored: StoredToken = JSON.parse(SecretString!);
// Refresh 5 minutes before expiry
if (stored.expiresAt - Date.now() < 300_000) {
const fresh = await refreshWithProvider(provider, stored.refreshToken);
const updated: StoredToken = {
accessToken: fresh.access_token,
refreshToken: fresh.refresh_token ?? stored.refreshToken,
expiresAt: Date.now() + fresh.expires_in * 1000,
provider
};
await sm.send(new PutSecretValueCommand({
SecretId: secretId,
SecretString: JSON.stringify(updated)
}));
return updated.accessToken;
}
return stored.accessToken;
}
Why separate the token store from the app DB:
- Different access controls. Your web app's DB credentials don't need access to the secrets store.
- Different backup/retention policies. Tokens rotate; user data is long-lived.
- Different audit requirements. You can log every secret access without polluting your app's query logs.
- Secrets Manager handles encryption, rotation scheduling, and access policies natively.
Trade-off: More infrastructure to manage. Cost per secret per month (AWS charges ~$0.40/secret/month). Still your responsibility to handle refresh races (use Secrets Manager's version staging for optimistic locking).
Which Pattern for Which Situation
| Factor | DB Storage | Token Vault (RFC 8693) | Secrets Sidecar |
|---|---|---|---|
| Security posture | Low | High | Medium |
| Operational complexity | Low | Low (vendor managed) | Medium |
| Latency per tool call | Lowest | +50-100ms | +10-30ms |
| Vendor dependency | None | Auth0 | AWS/GCP/Azure |
| Cost | DB storage only | Auth0 plan | ~$0.40/secret/mo |
| Best for | MVPs, single-agent | Multi-agent, regulated | Self-hosted, multi-provider |
My recommendation:
- Building an MVP or hackathon project? Pattern 1. Don't over-engineer.
- Multiple agents, multiple OAuth providers, or handling sensitive data? Pattern 2. Let the vault handle it.
- Self-hosted, need full control, or can't use Auth0? Pattern 3. Secrets Manager gives you 80% of the vault's security properties.
One More Thing: Scoped Tokens for Agents
Regardless of which storage pattern you pick, scope your tokens as tightly as possible. An agent that reads GitHub PRs doesn't need delete_repo.
// Good: minimal scopes per tool
const prTool = withToken({ scopes: ['repo:status', 'read:user'] }, ...);
const calTool = withToken({ scopes: ['https://www.googleapis.com/auth/calendar.readonly'] }, ...);
// Bad: one token with all scopes
const godToken = withToken({ scopes: ['repo', 'admin:org', 'user'] }, ...);
If an agent tool gets prompt-injected into making unexpected API calls, tight scopes limit the blast radius. The token physically cannot delete repos if it only has read access.
This is defense in depth applied to AI agents. The agent's LLM might get confused. The token's scopes don't.
I build production AI systems with these patterns. More at astraedus.dev.
Top comments (0)