DEV Community

Diven Rastdus
Diven Rastdus

Posted on

OAuth Token Vault Patterns for AI Agents (With Code)

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])
}
Enter fullscreen mode Exit fullscreen mode
// 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;
}
Enter fullscreen mode Exit fullscreen mode

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();
      }
    });
  }
);
Enter fullscreen mode Exit fullscreen mode

What happens under the hood:

  1. User authenticates with your app (normal OAuth flow)
  2. Auth0 stores the upstream tokens (GitHub, Google, Slack) in their vault
  3. When the agent invokes a tool, the SDK exchanges the user's session token for the upstream access token via RFC 8693
  4. The upstream token is returned just-in-time, used, and discarded
  5. 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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'] }, ...);
Enter fullscreen mode Exit fullscreen mode

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)