- Updated
- Reading
- 10 min
- By
- Anlyon Team
OAuth for AI agents: what per-user tokens, refresh, revocation and deletion take to build
A build checklist for per-user OAuth in an AI agent: consent, redirects, forwarded links, encrypted tokens, refresh, revocation, OAuth apps and deletion.
- end users
- oauth
- credentials
- connections
- build vs buy
To let an AI agent act in each user's own HubSpot, Slack, Linear, GitHub or Notion, you need a consent flow, an allow-listed redirect, a confirm step that defeats forwarded links, encrypted token storage, refresh with a reconnect path, revocation at the provider, an OAuth app per environment, and deletion. Anlyon runs all eight on its own API, so your server writes one confirm route and one line that names the user.
This guide is the build-it-yourself checklist, with the mechanism Anlyon uses for each row, the part you still write, and the code. For the wider picture of an agent acting in your users' accounts, start at the guide to letting your agent act for your users.
Why OAuth for an AI agent is different
A web app that signs a user in with Google needs a token for a few seconds. An agent that updates a user's CRM at 3am needs a token that still works next month, for every user, in every provider, without the model ever seeing it. That turns a login feature into a credential service:
- Tokens live for months, so storage, key handling and refresh are yours.
- The model picks tools, so the token has to sit outside its reach, as covered in AI agent secrets management.
- Users leave, revoke access in the provider, or ask to be deleted, and each of those has to reach the provider too.
The checklist below is what that takes.
The checklist, and how Anlyon handles each row
| What you have to build | What goes wrong if you skip it | How Anlyon does it |
|---|---|---|
| Consent flow and state | A forged callback or a replayed code stores a grant nobody asked for | createConnectLink returns a link that works once, for 10 minutes. Opening it stores a state and a PKCE verifier; the state is taken once on the way back, and the code is exchanged on Anlyon's API. Neither your user's id nor any token is ever put in a URL the browser is sent to |
| Redirect allow-list | Your connect link becomes an open redirect | Each environment lists up to 20 allowed redirect URLs, each an origin plus a path prefix. https is required, http is accepted for localhost. User info, fragments, backslashes, whitespace and scheme-relative //host URLs are refused, and a redirectUrl outside the list answers 400 REDIRECT_URL_NOT_ALLOWED |
| The forwarded-link problem | An attacker mints a link for their account, a victim opens it, and the victim's HubSpot is now the attacker's | The finished grant stays pending until your server confirms it for the user in your own session. A flow confirmed for a different user throws EndUserMismatchError (403); the grant is revoked at the provider and deleted. The flow id is single use, and a grant nobody confirms is deleted after 10 minutes |
| Encrypted storage and key separation | A database dump is a bag of working tokens | AES-256-GCM, with a key derived per store from a connections key that is held apart from the secrets vault key, so the two rotate separately. Each ciphertext is bound to its row and to whether it is the access or the refresh token, so it cannot be moved onto another user. No API returns a token; listConnections returns integration, status, scopes and timestamps |
| Refresh, and failure to reconnect | Two concurrent calls both refresh, a provider that rotates refresh tokens (Linear does) honours one, and the grant dies | Anlyon refreshes near expiry under a short lease, so concurrent calls refresh once and the rest reuse the new token. A refresh the provider refuses marks the connection needs_reconnect; the next call answers 409 END_USER_RECONNECT_REQUIRED with a fresh connectUrl, and nothing is sent |
| Revocation at the provider | You delete your row and the grant lives on in the user's HubSpot | deleteConnection and erase revoke at HubSpot, Slack, Linear, GitHub and Notion, then delete the row. Revocation is best effort: if the provider does not answer, the token is deleted anyway |
| Per-environment OAuth apps and provider review | Staging and production share one app, or production hits an install cap | Each environment can set its own client id and secret per integration. The secret is encrypted and never returned, each grant records which client issued it so refresh and revocation use that client, and setting an app from an API key waits for a person's sign-off |
| Users who bring a key instead | You build a key form, a key check and a second storage path | For an API-key integration the same link opens a hosted form; the key is checked with the provider before it is kept, stored encrypted the same way, and held to the same confirm step |
| Deletion | A deleted user's tokens keep working, and their id sits in every log | endUsers.erase(userId) revokes and deletes every connection, cancels pending approvals and clears per-user limit counters. Kept records carry a keyed one-way hash in place of the id. See deleting a user from your AI agent |
Each row is a few days of work for one provider. The cost is in the per-provider details: HubSpot revokes a refresh token with the client credentials in a form post, Slack revokes with the access token as a bearer, GitHub revokes one token by DELETE against an endpoint that names your client id, and Linear rotates refresh tokens on every refresh. Five providers means five of each.
Provider review and install caps
Building it yourself also means registering and reviewing an OAuth app with every provider, per environment. Some details from Anlyon's catalogue, as of 2026-11-02:
| Provider | Sign-in with Anlyon's app | What the provider imposes |
|---|---|---|
| HubSpot | Yes | An unlisted app is capped at 25 installs until HubSpot reviews and lists it, so set your own app for production |
| Slack | Yes | Unlisted apps get a 1 request per minute limit on conversations.history; a Marketplace listing lifts it |
| Linear | Yes | No review for an OAuth app used outside a marketplace listing |
| GitHub | Yes, or the user's own token | No review; a Marketplace listing is reviewed |
| Notion | Yes, or the user's own integration token | No review once the integration is public; a gallery listing is reviewed |
With your own app set for an environment, the provider's consent screen shows your app's name and tokens are issued to it. Without one, the link opens Anlyon's consent page, which names your workspace, then the provider's sign-in.
The code with Anlyon
Three routes on your server, and no token handling in any of them.
1. Send the user to a connect link
import { Client } from '@anlyonhq/sdk';
export const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });
// POST /connections/hubspot
export async function startHubSpotConnect(request: Request) {
const user = await requireSession(request);
const { data } = await anlyon.endUsers.createConnectLink({
endUserId: user.id,
integration: 'hubspot',
redirectUrl: 'https://app.example.com/connections/callback',
});
return Response.redirect(data!.url, 303); // works once, for 10 minutes
}
redirectUrl must sit under one of the environment's allowed redirect URLs. Leave it out and the environment's default is used.
2. Confirm the flow for the user in your session
This is the route you write. It is short, and it is the step that defeats the forwarded link.
import { parseConnectRedirect, EndUserMismatchError } from '@anlyonhq/sdk';
// GET /connections/callback
export async function connectCallback(request: Request) {
const user = await requireSession(request); // from YOUR session, never from the URL
const result = parseConnectRedirect(request.url);
if (result?.status === 'pending_confirmation' && result.flowId) {
try {
await anlyon.endUsers.confirmConnection(user.id, result.flowId);
} catch (error) {
if (error instanceof EndUserMismatchError) {
// The link was made for someone else. Nothing was connected, and the grant is gone.
return Response.redirect(new URL('/settings/connections?error=wrong_account', request.url), 303);
}
throw error;
}
}
// result.status may also be 'declined' or 'error': nothing was connected.
return Response.redirect(new URL('/settings/connections', request.url), 303);
}
parseConnectRedirect deliberately does not return a user id. Anyone can edit a URL, so the user always comes from your session.
3. Act for the user, and handle a missing or dead connection
import { EndUserNotConnectedError, EndUserReconnectRequiredError } from '@anlyonhq/sdk';
export async function moveDeal(user: { id: string }, dealId: string) {
try {
const { data } = await anlyon
.forUser(user.id)
.actions.invoke('hubspot-update-deal-stage', { dealId, dealstage: 'contractsent' });
return { status: data!.status, effectId: data!.effectId };
} catch (error) {
if (
(error instanceof EndUserNotConnectedError || error instanceof EndUserReconnectRequiredError) &&
error.connectUrl
) {
return { status: 'connect_required', connectUrl: error.connectUrl }; // nothing was sent
}
throw error;
}
}
forUser sends the Anlyon-End-User header, and a client built by it cannot be switched to another user. The hubspot-update-deal-stage action comes from the HubSpot pack, which reads the deal back after the write, so the receipt can grade confirmed. With the Vercel AI SDK, anlyonTools({ client: anlyon.forUser(session.user.id), actions: [...] }) returns tools that report connect_required or reconnect_required with the connectUrl instead of throwing, and the tool schemas carry no user field, so a prompt cannot name another user.
4. Show connections, and disconnect
const { data: connections } = await anlyon.endUsers.listConnections(user.id);
// [{ integration: 'hubspot', status: 'active', scopes: [...], connectedAt, updatedAt }]
await anlyon.endUsers.deleteConnection(user.id, 'hubspot'); // revoked at HubSpot, then deleted
A connection with status: 'needs_reconnect' was refused by the provider or failed to refresh. Send the user through a new connect link; the confirmed grant replaces the old one.
Python
import os
from anlyon import Client
anlyon = Client(api_key=os.environ["ANLYON_API_KEY"])
link = anlyon.end_users.create_connect_link(
end_user_id=user.id,
integration="hubspot",
redirect_url="https://app.example.com/connections/callback",
)
# redirect to link.data["url"]; in the callback:
anlyon.end_users.confirm_connection(user.id, flow_id)
anlyon.for_user(user.id).actions.invoke(
"hubspot-update-deal-stage", {"dealId": "123", "dealstage": "contractsent"}
)
What you still write
Anlyon takes the token service off your plate. Two jobs stay with you:
- The confirm route. Anlyon cannot know who is signed in to your app, so your server confirms each flow for the user in its own session. It is the 15 lines above. A browser session token from
endUsers.createSessioncannot confirm a connection, by design. - Your own OAuth app, for some providers. HubSpot in production is the common one, because of the 25-install cap on an unlisted app. You register the app with HubSpot, register the redirect URI Anlyon gives you, and set the client id and secret for the environment in the console or with
PUT /api/v2/environments/{environmentId}/oauth-apps/{integration}. Removing or replacing an app leaves the grants it issued needing a reconnect.
You also choose which user id to pass. If your code takes that id from model output instead of your session, you have handed the choice back to the model.
Limits
- Live sign-in integrations today are HubSpot, Slack, Linear, GitHub and Notion. Another 26, among them Attio, Asana, Jira and Zendesk, connect with the user's own API key. Google and Salesforce are on the way.
- Revocation at the provider is best effort. Anlyon deletes the token whether or not the provider confirms.
- An API key a user pasted was not issued to any app, so there is no grant to revoke. Anlyon deletes its copy; the key itself lives on in the user's provider account until they delete it there.
- Browser and edge code cannot act for a user on its own. Calls go through your server, which holds the API key.
- The MCP server and CLI do not act for end users in this build. Use the HTTP API, the TypeScript or Python SDK, or the AI SDK, LangChain and LlamaIndex adapters.
Build or buy
If you need one provider and want to own the token service, the checklist above is the spec. If your agent will act in several providers for many users, compare the options in agent auth platforms compared, and read credential custody for AI agents for why the token should never sit in the agent's process.
Next step: connect one test user to HubSpot with the three routes above, following the act for your users docs guide and the end-user connections reference. Every paid plan starts with a 14-day trial. Start building.
Frequently asked questions
- How do I store OAuth tokens for an AI agent that acts for many users?
- Encrypt each user's access and refresh token at rest under a key your application database does not hold, bind each ciphertext to its row so it cannot be swapped onto another user, and never return a token from any API. With Anlyon, the tokens stay in Anlyon's database, encrypted with AES-256-GCM, and your agent and your server never receive them.
- How should an AI agent handle an expired or revoked refresh token?
- Stop the call before anything is sent, mark the connection as needing a reconnect, and give the user a link to sign in again. Anlyon does this for you: the call fails with END_USER_RECONNECT_REQUIRED and a fresh connectUrl, and nothing reaches the provider.
- Should I build OAuth for my AI agent or use an auth platform?
- Build it if you need one provider and you are ready to own refresh races, revocation, provider app review and deletion. Use a platform if your agent will act in several providers for many users, because each of those jobs is per provider and none of them is your product.
- What is the forwarded-link problem in OAuth connect flows?
- Anyone who opens a connect link can finish its consent, so an attacker can mint a link for their own account and get a victim to open it, attaching the victim's account to the attacker. The fix is to confirm the finished flow on your server for the user signed in to your app, and to discard it when that user is not the one the link was made for.
