Email OAuth token management for AI agents is the machinery that lets an autonomous system send as alice@company.com without ever seeing Alice's password. It sounds simple enough — OAuth is a solved problem — but agents introduce failure modes that human-driven flows don't: they run unattended, at unpredictable times, across many users at once. A stale token at 3 AM means a missed deadline, not a login prompt.
This post covers the full token lifecycle for agents: scopes, authorization code flow, encrypted storage, proactive refresh, revocation detection, and the retry logic that holds it all together.
Why agents need OAuth instead of SMTP credentials
The direct answer: major providers have either disabled basic auth (Microsoft 365 killed it in October 2022 per the Microsoft 365 deprecation announcement) or rate-limit and flag it as suspicious. OAuth is the only supported path for programmatic sending against real user mailboxes.
Beyond provider enforcement, OAuth gives you:
- Granular scopes — request only
gmail.sendorMail.Send, not full mailbox access - Revocability — a user can cut off your agent without changing their password
- Audit trails — the provider logs which app used which token
- Short-lived credentials — access tokens expire in 1 hour (Google) or 1 hour (Microsoft), so a leaked token has a short blast radius
For agents that send as themselves rather than as a user, a purpose-built email infrastructure for AI agents is often simpler — no OAuth dance, no per-user token storage. But when your agent genuinely needs to send from a user's real mailbox — calendar confirmations from cto@startup.com, automated replies from a support rep's address — OAuth is mandatory.
OAuth 2.0 scope selection
Minimum viable scopes keep your attack surface small and your consent screen honest.
Gmail
| Scope | What it allows |
|---|---|
https://www.googleapis.com/auth/gmail.send |
Send-only, no read |
https://www.googleapis.com/auth/gmail.modify |
Send + read + label (needed if agent also processes replies) |
https://www.googleapis.com/auth/gmail.readonly |
Read-only (no send) |
For a pure sending agent, gmail.send is sufficient and minimizes the consent ask. If your agent also needs to read replies, use gmail.modify — combining gmail.readonly and gmail.send still requires separate consent grants and is no more restrictive.
Microsoft 365 (Graph API)
| Permission | Type | What it allows |
|---|---|---|
Mail.Send |
Delegated | Send as the signed-in user |
Mail.ReadWrite |
Delegated | Send + full mailbox R/W |
Mail.Send.Shared |
Delegated | Send on behalf of shared mailboxes |
For delegated permissions, Mail.Send is correct. Application permissions skip user consent but require admin approval and let the app send as any user — avoid this unless you have a genuine organizational automation use case and an IT admin sponsor.
The authorization code flow for agents
Agents are confidential clients — they run on your server, can keep a secret, and use authorization code flow with PKCE (Proof Key for Code Exchange). The implicit flow is deprecated; device flow is for headless devices without a redirect URI.
sequenceDiagram
participant U as User Browser
participant A as Agent Backend
participant P as OAuth Provider
U->>A: Connect mailbox
A->>U: Redirect to provider with code_challenge
U->>P: Authorize scopes
P->>U: Redirect with auth_code
U->>A: POST auth_code
A->>P: Exchange code plus code_verifier
P->>A: access_token plus refresh_token
A->>A: Encrypt and store tokens per user
Code: Google OAuth exchange (Node.js)
import { OAuth2Client } from 'google-auth-library';
const oauth2Client = new OAuth2Client(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI
);
// After receiving the code from the redirect:
async function exchangeCode(userId: string, code: string) {
const { tokens } = await oauth2Client.getToken(code);
// tokens.access_token, tokens.refresh_token, tokens.expiry_date
await storeTokens(userId, tokens);
}
Two things to get right here:
- Request
access_type=offlinein the authorization URL — Google only issues a refresh token on the first authorization unless you do this. - Request
prompt=consentif you need to force a new refresh token (for example, after revoking and re-authorizing).
Encrypted token storage
Refresh tokens are long-lived credentials. Treat them like passwords.
What to store per user
interface StoredToken {
user_id: string;
provider: 'google' | 'microsoft';
access_token_encrypted: string; // AES-256-GCM encrypted
refresh_token_encrypted: string; // AES-256-GCM encrypted
access_token_expires_at: number; // Unix timestamp ms
scope: string;
created_at: number;
last_used_at: number;
}
Encryption in practice
Use AES-256-GCM with a per-record random nonce. Do not reuse nonces.
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY!, 'hex'); // 32 bytes
export function encrypt(plaintext: string): string {
const nonce = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, KEY, nonce);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
// Store: nonce(12) + tag(16) + ciphertext
return Buffer.concat([nonce, tag, encrypted]).toString('base64');
}
export function decrypt(encoded: string): string {
const buf = Buffer.from(encoded, 'base64');
const nonce = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const ciphertext = buf.subarray(28);
const decipher = createDecipheriv(ALGORITHM, KEY, nonce);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
}
The encryption key itself should live in a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager) — not in environment variables on disk.
Proactive token refresh
Agents fail silently when tokens expire mid-task. The fix is proactive refresh: never use a token that expires within the next N minutes.
const REFRESH_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
async function getValidAccessToken(userId: string): Promise<string> {
const stored = await loadTokens(userId);
const now = Date.now();
if (stored.access_token_expires_at - now > REFRESH_THRESHOLD_MS) {
return decrypt(stored.access_token_encrypted);
}
// Refresh needed
return refreshAccessToken(userId, stored);
}
async function refreshAccessToken(userId: string, stored: StoredToken): Promise<string> {
const refreshToken = decrypt(stored.refresh_token_encrypted);
if (stored.provider === 'google') {
oauth2Client.setCredentials({ refresh_token: refreshToken });
const { credentials } = await oauth2Client.refreshAccessToken();
await updateStoredAccessToken(userId, {
access_token_encrypted: encrypt(credentials.access_token!),
access_token_expires_at: credentials.expiry_date!,
});
return credentials.access_token!;
}
if (stored.provider === 'microsoft') {
const response = await fetch(
`https://login.microsoftonline.com/${process.env.TENANT_ID}/oauth2/v2.0/token`,
{
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.MS_CLIENT_ID!,
client_secret: process.env.MS_CLIENT_SECRET!,
refresh_token: refreshToken,
scope: 'https://graph.microsoft.com/Mail.Send offline_access',
}),
}
);
const data = await response.json();
if (data.error) throw new Error(`Token refresh failed: ${data.error_description}`);
await updateStoredAccessToken(userId, {
access_token_encrypted: encrypt(data.access_token),
access_token_expires_at: Date.now() + data.expires_in * 1000,
});
// Microsoft sometimes rotates the refresh token
if (data.refresh_token) {
await updateStoredRefreshToken(userId, encrypt(data.refresh_token));
}
return data.access_token;
}
throw new Error(`Unknown provider: ${stored.provider}`);
}
Microsoft sometimes rotates the refresh token during a refresh call. If you discard the new one, the next refresh fails. Always check data.refresh_token in the response and persist it.
Sending email with the access token
Gmail via REST
The Gmail API takes a raw RFC 2822 message encoded in base64url. Build the message manually or with nodemailer to a buffer:
import * as nodemailer from 'nodemailer';
async function sendViaGmail(userId: string, opts: {
to: string; subject: string; text: string;
}) {
const accessToken = await getValidAccessToken(userId);
const stored = await loadTokens(userId);
const userEmail = stored.email; // store this at auth time
// Build raw MIME
const transport = nodemailer.createTransport({ streamTransport: true });
const { message: stream } = await transport.sendMail({
from: userEmail,
to: opts.to,
subject: opts.subject,
text: opts.text,
});
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(chunk as Buffer);
const raw = Buffer.concat(chunks).toString('base64url');
const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ raw }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`Gmail send failed: ${err.error.message}`);
}
}
Microsoft 365 via Graph API
async function sendViaMicrosoft(userId: string, opts: {
to: string; subject: string; text: string;
}) {
const accessToken = await getValidAccessToken(userId);
const res = await fetch('https://graph.microsoft.com/v1.0/me/sendMail', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
subject: opts.subject,
body: { contentType: 'Text', content: opts.text },
toRecipients: [{ emailAddress: { address: opts.to } }],
},
saveToSentItems: true,
}),
});
if (res.status === 202) return; // success, no body
const err = await res.json();
throw new Error(`Graph send failed: ${err.error.message}`);
}
Revocation detection and re-authorization
Refresh tokens get revoked when a user manually disconnects your app, an admin policy sweeps third-party tokens, a Google account password changes (all tokens revoked), or the refresh token sits unused for 6 months (Google's idle expiry).
Revocation surfaces as a specific error you must catch and handle without retrying:
function isRevocationError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes('invalid_grant') ||
msg.includes('token has been expired or revoked') ||
msg.includes('authorizationpending') ||
msg.includes('unauthorized_client')
);
}
async function sendWithRevocationHandling(userId: string, opts: SendOpts) {
try {
await sendViaGmail(userId, opts);
} catch (err) {
if (isRevocationError(err)) {
// Mark token as invalid, notify user, queue for re-auth
await markTokenRevoked(userId);
await notifyUserReauthRequired(userId);
throw new Error(`REAUTH_REQUIRED:${userId}`);
}
throw err;
}
}
Do not loop on invalid_grant. Retrying a revoked token wastes API quota and never succeeds.
Multi-user token management at scale
When an agent manages tokens for hundreds of users simultaneously:
- Isolate token storage per user — one row/document per
(user_id, provider)pair - Run a background job that proactively refreshes any token expiring within 30 minutes, so the first task of the day never blocks on a refresh call
- Use a distributed lock on
refresh:{user_id}before calling the token endpoint — two simultaneous refreshes can invalidate each other's refresh tokens - Back off with exponential jitter on 429 responses; Google's token endpoint has per-project quotas, Microsoft's has per-tenant limits
For agents that have their own sending identity and don't need to impersonate users, Mails.ai's agent email platform sidesteps this entire stack — each agent gets a stable address, and the platform handles deliverability without per-user OAuth flows.
Security checklist
- Access tokens and refresh tokens encrypted at rest (AES-256-GCM, per-record nonce)
- Encryption key stored in a secrets manager, rotated annually
- Minimum necessary scopes requested
- Refresh tokens stored in a table separate from application data (different access controls)
-
invalid_granttreated as terminal — no retry, immediate re-auth notification - Token operations logged (refresh timestamp, revocation events) without logging the token values
- Distributed lock on refresh for multi-worker deployments
- User-facing disconnect flow that calls the provider's revoke endpoint
On the revoke endpoint: always call it when a user disconnects. Google: POST https://oauth2.googleapis.com/revoke?token={refresh_token}. Microsoft: POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/logout (or delete the permission grant via Graph). This ensures the token can't be misused if your database is later compromised.
Frequently Asked Questions
Why does my agent only get a refresh token on the first authorization?
Google only issues a refresh token when access_type=offline is set and the user hasn't previously authorized your app. On subsequent authorizations, Google skips issuing a new refresh token unless you pass prompt=consent to force re-consent. If you lost the original refresh token, revoke the app access from the user's Google Account security page and re-authorize with prompt=consent.
How long do refresh tokens last?
Google refresh tokens for consumer accounts expire after 6 months of inactivity. For Google Workspace accounts with configured session policies, expiry is set by the admin (often 24 hours). Microsoft 365 refresh tokens in a default tenant last 90 days of inactivity; sliding window tokens can stay active indefinitely with regular use. Never assume a refresh token is permanent — build revocation handling regardless.
Should I store access tokens or just refresh on every use?
Store the access token alongside its expiry time. Calling the token endpoint on every send adds 100–300 ms of latency and unnecessarily consumes token endpoint quota. The proactive refresh approach — check expiry, refresh if within 5 minutes — is the right balance.
What happens if two agent workers try to refresh the same token simultaneously?
Google and Microsoft typically accept the first refresh and invalidate the old refresh token. The second worker's request may fail or return a token that is immediately invalidated when the first worker stores its result. Use a distributed lock (Redis SET NX with a short TTL) around the refresh operation keyed on (user_id, provider) to serialize concurrent refreshes.
Can an agent send on behalf of a user without them explicitly authorizing it?
For delegated OAuth (acting as the user), no — the user must complete the consent flow at least once. Application permissions in Microsoft 365 bypass individual consent but require tenant admin approval and are inappropriate for consumer scenarios. Service accounts in Google Workspace with domain-wide delegation also bypass individual consent but are restricted to managed organizational accounts and require a Workspace admin to configure.
Is OAuth the right approach if my agent has its own email identity?
No. OAuth is specifically for when the sent mail must appear from the user's real address. If your agent has its own identity — scheduler@yourtool.com or agent@yourworkspace.mails.ai — a purpose-built email API for AI agents is simpler: no per-user token storage, no refresh lifecycle, and you control the sending domain directly.
Related guides: Model Context Protocol for Email Tools: Dev Guide, Transactional Email APIs With Inbound Parsing: Dev Guide, Agent Email Address: Why Every AI Agent Needs One and Thread Email Replies Programmatically: Complete Guide.
Explore the product: Sender reputation, All features and Pricing.
