DEV Community

Diven Rastdus
Diven Rastdus

Posted on

OAuth Token Vault Patterns for AI Agents

OAuth Token Vault Patterns for AI Agents

AI agents that access third-party APIs on behalf of users (GitHub, Slack, Google Calendar) face a hard security problem: where do the OAuth tokens live?

Most tutorials store them in your app database. That works until someone dumps your DB and now has read/write access to every user's GitHub repos, email, and calendar. Here's a better pattern.

The problem

Your AI agent needs to:

  1. Authenticate users via OAuth to third-party services
  2. Store access tokens securely
  3. Refresh tokens when they expire
  4. Let the agent use those tokens at execution time

The naive approach looks like this:

// DON'T DO THIS
const user = await db.users.findOne({ id: userId });
const githubToken = user.github_access_token; // stored in your DB
const repos = await fetch('https://api.github.com/user/repos', {
  headers: { Authorization: `Bearer ${githubToken}` }
});
Enter fullscreen mode Exit fullscreen mode

This has several problems:

  • Your database is now a credential store. Every breach leaks user tokens.
  • Token refresh logic lives in your app. You're now maintaining refresh flows for every provider.
  • No audit trail for which tokens were used when.
  • No granular revocation. User can't revoke GitHub access without revoking everything.

Token Vault pattern

The core idea: never store OAuth tokens in your application. Delegate credential storage to an identity provider that was built for exactly this.

The flow:

User -> Your App -> Identity Provider (stores tokens)
                         |
                         v
Agent needs GitHub -> Token Exchange (RFC 8693) -> Fresh token -> GitHub API
Enter fullscreen mode Exit fullscreen mode

Instead of your app holding a GitHub token, the identity provider holds it. When your agent needs to call GitHub, it exchanges its own session token for a scoped, short-lived GitHub token via RFC 8693 token exchange. The token never touches your database.

RFC 8693 token exchange in practice

RFC 8693 defines a standard way to exchange one security token for another. In this context:

  1. Agent has a user session (the "subject token")
  2. Agent needs a GitHub access token (the "requested token")
  3. Agent sends both to the token exchange endpoint
  4. Identity provider validates the session, checks permissions, returns a fresh GitHub token
  5. Agent uses the GitHub token for one API call, then discards it
// Token exchange request
const response = await fetch(`${AUTH_DOMAIN}/oauth/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
    subject_token: userSessionToken,
    subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
    requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
    audience: 'github',
  }),
});

const { access_token } = await response.json();
// access_token is a fresh, short-lived GitHub token
Enter fullscreen mode Exit fullscreen mode

The identity provider handles:

  • Secure storage of the underlying OAuth refresh tokens
  • Automatic token refresh when tokens expire
  • Scope validation (agent can only access what the user permitted)
  • Audit logging of every token exchange

Implementation with Auth0 Token Vault

Auth0 ships a Token Vault specifically for this use case. Here's how I wired it into an AI agent that pulls data from GitHub, Google Calendar, and Slack.

1. Define your tools with token requirements

Each AI tool declares which connection it needs:

import { getAccessTokenForConnection } from '@auth0/ai-vercel';

const githubTool = tool({
  description: 'Fetch recent pull requests for the authenticated user',
  parameters: z.object({
    repo: z.string().optional(),
  }),
  execute: async ({ repo }) => {
    // Token exchange happens here, at execution time
    const token = await getAccessTokenForConnection('github');

    const url = repo 
      ? `https://api.github.com/repos/${repo}/pulls?state=all&per_page=10`
      : 'https://api.github.com/user/repos?sort=updated&per_page=5';

    const response = await fetch(url, {
      headers: { 
        Authorization: `Bearer ${token}`,
        Accept: 'application/vnd.github+json'
      },
    });

    return response.json();
  },
});
Enter fullscreen mode Exit fullscreen mode

The key detail: getAccessTokenForConnection('github') performs the RFC 8693 exchange under the hood. No token stored in your app. No refresh logic. Just a fresh token when you need it.

2. Wire tools into the AI SDK

Using the Vercel AI SDK, the tools plug directly into streamText:

import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';

const result = streamText({
  model: anthropic('claude-sonnet-4-5-20250514'),
  system: `You are a developer assistant. Use the available tools 
           to fetch REAL data before answering. Never guess.`,
  messages: conversationHistory,
  tools: {
    github_prs: githubTool,
    calendar_events: calendarTool,
    slack_messages: slackTool,
  },
  maxSteps: 5,
});
Enter fullscreen mode Exit fullscreen mode

When the model decides it needs GitHub data, the tool executes, the token exchange happens transparently, and the model gets real data. The user's GitHub token never appears in your logs, database, or application code.

3. Permission boundaries

Users connect services through a standard OAuth consent flow. The Token Vault stores the resulting credentials. Your app only stores a reference to the connection, not the credential itself.

// User connects GitHub
// Redirect to: /auth/connect/github
// Auth0 handles the OAuth flow, stores the token in Token Vault
// Your app gets: { connection: 'github', status: 'connected' }
// Your app does NOT get: the actual token
Enter fullscreen mode Exit fullscreen mode

Each tool call creates an audit event:

  • Who requested the token (user ID)
  • Which connection was accessed (GitHub, Slack, etc.)
  • When the exchange happened
  • What scopes were used

If a user revokes GitHub access, the Token Vault invalidates the stored credential. Next tool call that needs GitHub fails cleanly with a "reconnect required" message.

What this looks like in production

I built DevContext using this pattern. It's an AI assistant that connects to your GitHub, Google Calendar, and Slack to generate developer briefings. Ask it "what did I work on yesterday?" and it:

  1. Calls the GitHub tool (token exchange for GitHub)
  2. Calls the Calendar tool (token exchange for Google)
  3. Calls the Slack tool (token exchange for Slack)
  4. Synthesizes a briefing from real data

Three separate token exchanges, three separate audit events, zero tokens in my database. If my Supabase instance got compromised tomorrow, the attacker would find user profiles and preferences. Not a single OAuth token.

When to use this vs. storing tokens yourself

Use Token Vault when:

  • Your agent accesses multiple third-party APIs per user
  • You're in a regulated industry (healthcare, finance) where credential handling is audited
  • You want users to be able to revoke individual service connections
  • You don't want to build and maintain token refresh logic for 5 different OAuth providers

Store tokens yourself when:

  • You're accessing one service with simple API keys (not OAuth)
  • You're building a prototype and security posture isn't critical yet
  • The identity provider you're using doesn't support token exchange

The boring parts that matter

A few implementation details that caused real debugging sessions:

System prompt enforcement. The AI model will happily hallucinate a standup summary without ever calling a tool. Force tool use by including "you MUST call tools before responding" in the system prompt. Better yet, validate on the server that tool calls happened before streaming the response.

Token exchange latency. Each RFC 8693 exchange adds 100-200ms. If your agent calls 3 tools, that's 300-600ms of token exchange overhead. Not noticeable in a streaming response, but worth knowing if you're building something latency-sensitive.

Connection status caching. Don't check Token Vault on every page load to see if GitHub is still connected. Cache the connection status client-side and only re-check when a tool call fails with a "reconnect" error.

Fallback when a connection is missing. If the user hasn't connected Slack, the Slack tool should return a helpful message ("Slack not connected. Connect it in Settings to include Slack data in briefings.") instead of throwing an error that crashes the agent.

Summary

The pattern is straightforward:

  1. Use an identity provider with Token Vault support (Auth0, or roll your own with RFC 8693)
  2. Define each AI tool with the connection it needs
  3. Token exchange happens at execution time, not at login time
  4. Your app never stores, sees, or logs OAuth tokens
  5. Users get granular control over which services are connected
  6. You get an audit trail for every credential access

Zero tokens in your database. Zero refresh logic in your codebase. Zero credential leaks from a database breach.

That's the kind of boring infrastructure that prevents the exciting kind of incident response.


I build production AI systems. If you're working on something similar, I'm at astraedus.dev. My book Production AI Agents covers patterns like this in depth.

Top comments (0)