AI Agent OAuth Recovery: Stop Expired Connections From Retrying Forever
An agent can have a perfect plan and still fail at the least interesting moment: halfway through a customer task, its connected account expires. The bad response is to retry the same API call until the run burns its budg
An agent can have a perfect plan and still fail at the least interesting moment: halfway through a customer task, its connected account expires. The bad response is to retry the same API call until the run burns its budget. The worse response is to ask the model to repair authentication.
OAuth failure is a product and workflow state, not a transient tool error. A reliable agent identifies whether a token can be refreshed, whether the customer must reconnect, and whether the original task is still safe to resume. This guide shows a vendor-neutral pattern for building that recovery path without exposing credentials or repeating side effects.
Why expired connections become runaway agent runs
OAuth access tokens are deliberately short-lived. A refresh token can usually obtain a replacement, but a user can revoke the grant, an administrator can change policy, or a provider can reject a refresh with invalid_grant. Googleβs MCP guidance, for example, notes that clients need the right offline-access behavior to avoid repeated sign-in when access tokens expire (Google documentation).
An agent loop often turns this ordinary condition into an incident:
- A tool returns
401. - The model sees an opaque error and tries the tool again.
- Several workers refresh the same connection at once.
- One refresh rotates the refresh token; another now fails.
- The workflow keeps retrying, or falls back to a different account without the user knowing.
The durable rule is simple: authentication recovery belongs below the model. The model can explain a blocked task, but it should not inspect, paste, select, or refresh bearer tokens.
Model the connection separately from the agent run
Do not represent every failure as tool_failed. Keep a connection record with an explicit state, then let each run reference that record by ID. The run holds intent and progress; the connection holds authorization health.
| Connection state | Meaning | Safe next action |
|---|---|---|
active |
A recent check or call succeeded | Permit the requested tool |
refreshing |
One service is refreshing the token | Wait briefly; do not start another refresh |
reauth_required |
The grant is revoked, invalid, or missing | Pause affected runs and show a reconnect action |
provider_unavailable |
The provider or token endpoint is unavailable | Retry on a bounded schedule; do not request consent yet |
disabled |
The user, tenant, or policy removed access | Fail closed and require an explicit new connection |
This distinction matters for UX. A reconnect screen cannot fix a provider outage, and an automatic retry cannot repair a revoked grant. Connection products already expose similar distinctions: an expired credential, a rejected reconnect, and a provider connection issue are different conditions with different remedies (connection troubleshooting example).
Here is a minimal TypeScript shape:
type ConnectionState =
| "active"
| "refreshing"
| "reauth_required"
| "provider_unavailable"
| "disabled";
type Connection = {
id: string;
tenantId: string;
provider: "crm" | "mail" | "calendar";
accountLabel: string; // safe display name, not an email if avoidable
state: ConnectionState;
grantedScopes: string[];
credentialRef: string; // vault reference, never the token itself
version: number;
lastVerifiedAt?: string;
reauthReason?: "revoked" | "invalid_grant" | "scope_changed" | "missing";
};
Bind it to the tenant and intended account. βUse any connected calendarβ is an unsafe fallback in a multi-account product. A reauthorized connection should update the same durable connection ID only after the user has seen the provider, account, and requested scopes.
Make refresh a locked, typed operation
Refresh-token rotation creates a small distributed-systems problem. Two requests might notice an old access token together. If both refresh independently, the provider may invalidate the token one of them was about to store.
Use a short database lease or compare-and-swap update to nominate one refresher. Everyone else waits for its result. Keep the credential material in a secret manager; the application database stores only a vault reference and harmless status metadata.
async function getUsableConnection(id: string) {
const connection = await db.connection.findUniqueOrThrow({ where: { id } });
if (connection.state === "disabled") return { kind: "blocked", reason: "disabled" };
if (connection.state === "reauth_required") return { kind: "reauth", reason: connection.reauthReason };
const claimed = await db.connection.updateMany({
where: { id, state: "active", refreshLeaseUntil: { lt: new Date() } },
data: { state: "refreshing", refreshLeaseUntil: plusSeconds(20), version: { increment: 1 } }
});
if (claimed.count === 0) return waitForConnectionResolution(id, 2500);
try {
const secret = await vault.read(connection.credentialRef);
const refreshed = await oauth.refresh(secret.refreshToken);
await vault.rotate(connection.credentialRef, refreshed);
await db.connection.update({ where: { id }, data: { state: "active", lastVerifiedAt: new Date(), refreshLeaseUntil: null } });
return { kind: "ready" };
} catch (error) {
return classifyAndPersistRefreshFailure(id, error);
}
}
The pseudo-code assumes your storage layer supports an atomic conditional update. If it does not, use a transaction or a distributed lock with an expiry. More importantly, classify the response:
- A timeout or
5xxis a bounded, retryable infrastructure failure. -
invalid_grant, an invalid refresh token, or a user revocation is terminal for that grant. - A missing required scope needs reconnection and possibly consent for the new scope.
- A
403from the resource API may mean the grant works but the user lacks provider-side permission. Do not blindly reconnect.
On a terminal refresh failure, mark the connection reauth_required once. Do not try it again from every queued run. A well-designed OAuth client does exactly this: it treats a failed refresh caused by a revoked or rotated grant as a reauthentication event rather than replaying the same unauthorized request (an implementation example).
Pause the run with a recoverable checkpoint
Once a task needs human reconnection, an agent has two responsibilities: preserve enough context to resume, and stop before it creates an ambiguous side effect.
Persist a checkpoint before every external write and before you show the reconnect UI:
{
"run_id": "run_84a",
"state": "waiting_for_connection",
"connection_id": "conn_29",
"required_capability": "crm.contacts.update",
"checkpoint": "validated_contact_change",
"intent_hash": "sha256:...",
"resume_after_connection_version": 18,
"created_at": "2026-11-04T10:15:00Z"
}
The intent_hash protects against quiet changes while the user is away. When the user returns, the agent should not send an old draft to a newly selected account or apply a stale CRM update. Fetch the current record, compare the relevant fields, re-run policy checks, and ask again if the intended action has changed.
For an action that might already have reached the provider, do not resume by repeating it. Use an idempotency key or query the provider for an operation receipt first. This is the same unknown-outcome boundary that makes a network timeout different from a rejected write.
Build a reconnect flow that earns trust
The reconnect card should be specific without being alarming. It needs to answer what failed, what will happen next, and what will not happen automatically.
Reconnect needed β Your CRM connection can no longer authorize updates. The draft change is saved but has not been applied. Reconnect the account that owns this record, then review the change before continuing.
Include these elements:
- Provider name and a privacy-safe account label.
- The capability that is blocked, not a raw OAuth error.
- The saved task status: not started, safely paused, or needs verification.
- A server-generated, short-lived reconnect URL bound to tenant, connection, and CSRF/session state.
- A clear path to cancel or disconnect.
Never put an authorization URL, authorization code, refresh token, or customer secret into a model prompt or chat message. The browser callback should reach your backend, exchange the code server-side, store the new credential in the vault, and redirect to a neutral completion page. The agent only receives connection_version: 19 and allowed capabilities.
Reconnection is not permission escalation. If the user grants different scopes or selects another account, record that as a new authorization version and re-evaluate the task. A tool policy or approval that applied to the old connection must not silently carry over to a broader one.
Resume safely instead of automatically continuing everything
When OAuth completes, publish a connection.restored event. A scheduler can find runs waiting on that exact connection, but it should use a resume policy rather than wake every run.
| Run type | Resume policy |
|---|---|
| Read-only lookup | Re-run after verifying tenant, account, and scope |
| Draft generation | Resume only if source inputs and requested account still match |
| External write | Recheck state, policy, and idempotency; often show a final confirmation |
| Destructive action | Require a fresh approval, even after reconnect |
This policy avoids an unpleasant surprise: a user reconnects their mailbox and suddenly receives a backlog of messages the agent attempted days earlier. The sensible default is βconnection restored, task ready to review,β not βall blocked side effects now execute.β
Use a version check to make the rule enforceable:
function mayResume(run: Run, connection: Connection) {
return run.state === "waiting_for_connection" &&
run.connectionId === connection.id &&
connection.state === "active" &&
connection.version >= run.resumeAfterConnectionVersion &&
run.tenantId === connection.tenantId;
}
The next tool call still goes through normal authorization, policy, rate limits, and approval gates. Resumption is a request to reconsider the action, not a stored permission slip.
Test the failure paths that happy-path OAuth misses
Most OAuth integration tests prove only that a new connection works. Production reliability comes from testing what happens later.
- Force two simultaneous requests to refresh one connection; assert only one vault rotation occurs.
- Return
invalid_grant; assert onereauth_requiredtransition and no retry storm. - Revoke an account between planning and execution; assert the write never starts.
- Reconnect a different account; assert the paused run stays blocked until it is reviewed.
- Change scopes during reconnection; assert a previously allowed write is reauthorized.
- Timeout after an external write; assert the run reconciles with an idempotency key instead of repeating it.
- Remove a connection while a run waits; assert all associated runs become safely terminal or need explicit reassignment.
Measure the operational signals too: connections by state, refresh success rate, terminal refresh failures, time spent waiting for reconnect, repeated calls blocked after one failure, and resumes rejected because the account or target changed. Avoid logging raw tokens, authorization codes, request bodies with secrets, or full provider error payloads.
A compact implementation checklist
- [ ] Separate durable connection state from workflow state.
- [ ] Keep credentials in a vault and expose only opaque references to application code.
- [ ] Serialize refreshes per connection and handle rotation atomically.
- [ ] Classify
invalid_grant, missing scope, provider outage, and disabled access separately. - [ ] Turn terminal refresh failures into one
reauth_requiredstate, not open-ended retries. - [ ] Checkpoint the run before reconnect and bind it to tenant, connection, account, and intent.
- [ ] Revalidate target data, policy, and idempotency before any resumed write.
- [ ] Make reconnect visible, explicit, and reviewable.
- [ ] Test revocation, rotation races, provider outages, changed scopes, and queued runs.
OAuth is not background plumbing for an agent product. It is the boundary between a userβs connected account and an autonomous workflow. Treat an expired connection as a first-class state, and the product can pause honestly, reconnect safely, and finish only when it still has the right to do so.
FAQ
Should an AI agent retry after a 401 error?
Not blindly. First determine whether the access token can be refreshed. A transient token-endpoint failure may justify a bounded retry, but a revoked grant or invalid_grant should move the connection to reauth_required and pause dependent work.
What is the difference between token refresh and reauthorization?
Refresh uses an existing refresh token without user interaction to obtain a new access token. Reauthorization starts a user-facing consent flow because the existing grant is invalid, missing, revoked, or needs different scopes.
Can an agent reconnect an OAuth account automatically?
It can refresh a valid existing grant in the background. It should not silently complete a new consent flow, choose a different account, or widen scopes. Those actions require the userβs explicit participation.
How do I prevent OAuth refresh token race conditions?
Allow one refresher per connection using an atomic database update, lease, or lock. Other requests wait for the outcome and read the newly stored credential reference. Rotate the vault value atomically and version the connection state.
Should paused agent runs resume as soon as the user reconnects?
Usually only read-only tasks can resume automatically. For drafts, writes, and destructive actions, revalidate the account, current target data, authorization, policy, and idempotency. A final review is often the safer experience.
What should an agent show a user when a connection expires?
Explain the blocked capability, provider, safe account label, and task status in plain language. Say whether any action was applied, preserve the task for review, and offer a secure reconnect action. Do not display raw OAuth errors, tokens, or authorization codes.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.