OAuth and 2FA for AI Apps: Securing Access Without Killing UX
An MCP client is a non-browser, long-lived consumer. That breaks the assumptions behind short-lived OAuth tokens, and the fix is scoped keys with rotation.
OAuth 2.0 was designed around a browser, a human, and a redirect. An MCP client is none of those. It is a long-lived process on someone's laptop, started once and left running for weeks, with no browser in its address space and nobody watching to click Approve when a token expires at 3am.
Every awkward decision in securing an AI tool integration comes from that mismatch. A 15-minute access token is excellent security advice for a single-page app and an outage generator for a headless client. So you have to be deliberate about which leg of the flow you are securing and what is actually present at that moment.
Split the problem by what is present
There are three distinct situations, and treating them uniformly is the root error.
- Human plus browser. Interactive login, consent, key issuance. Full OAuth is appropriate and cheap here, because there is someone to redirect.
- Client process with a prior human authorization. An MCP client that a user set up last month. There was a browser once; there is not one now. This is the refresh-token case.
- No human, ever. A CI job, a server-side agent, a self-hosted instance syncing on a cron. There will never be a redirect. This is the API-key case, and pretending otherwise produces an OAuth flow with a headless browser in it, which is worse than a scoped key by every measure.
The browser leg: authorization code plus PKCE
For the interactive leg, the current guidance is settled. The OAuth 2.1 draft consolidates a decade of errata into a short list: authorization code flow with PKCE for all clients including confidential ones, the implicit grant removed, the resource owner password credentials grant removed, and exact string matching required on redirect URIs.
PKCE is worth understanding rather than pasting. The client generates a random code_verifier, sends code_challenge = BASE64URL(SHA256(verifier))with the authorization request, and presents the raw verifier when redeeming the code. An attacker who intercepts the authorization code (a malicious app registered on the same custom URI scheme, a leaked referrer, a log line) cannot redeem it, because the verifier never traveled over the channel that leaked the code. It costs two lines and it closes the entire class of code-interception attacks.
The long leg: refresh tokens with rotation and reuse detection
Long-lived access tokens are the thing to avoid, not long-lived sessions. Keep access tokens short (minutes) and hold the duration in a refresh token that rotates on every use.
// refresh handler, the part that matters
const row = await db.refreshToken.findUnique({ where: { id: presented.id } });
if (!row) return deny();
if (row.usedAt !== null) {
// this token was already redeemed. either a replay or a stolen token.
// we cannot tell which, so assume the worst.
await db.refreshToken.updateMany({
where: { familyId: row.familyId, usedAt: null },
data: { revokedAt: new Date(), revokedReason: 'reuse_detected' },
});
await audit('refresh_reuse', { userId: row.userId, familyId: row.familyId });
return deny();
}
await db.refreshToken.update({
where: { id: row.id },
data: { usedAt: new Date() },
});
return issuePair({ userId: row.userId, familyId: row.familyId, scopes: row.scopes });The falsifiable property this buys you: with rotation and reuse detection, a stolen refresh token is usable for at most one refresh cycle, and its use is detectable. Either the attacker redeems first and the legitimate client's next refresh trips the reuse check, or the client redeems first and the attacker trips it. Whichever order, the family is revoked and you have an audit event. Without rotation, a stolen refresh token is a permanent credential whose theft produces no signal at all.
The one operational hazard is that a client which loses the response to a successful refresh (network drop after the server committed) will retry with a now-used token and get its family killed. Mitigate with a short grace window: accept a used token within a few seconds of its usedAt and return the same pair it already minted, rather than treating it as theft.
API keys: prefix plus hash, never the raw value
Where no browser will ever exist, issue a scoped key. The storage format is the part people get wrong, and there are two independent reasons for the shape.
umx_live_7f3a91c2_9RkQ2vTnP8sLmXe4YbHwZjA6DcFgKu1N
└┬─┘ └─┬─┘ └───┬──┘ └──────────────┬──────────────┘
│ │ │ └── secret. 32 bytes of CSPRNG output,
│ │ │ bcrypt-hashed, never stored raw.
│ │ └── lookup prefix. indexed, stored plaintext.
│ └── environment
└── issuer namespaceOnly the bcrypt hash of the secret is stored. This means a database dump does not yield working credentials, and it also means you cannot show the user their key again. That is a product constraint, not an oversight: the "copy this now, we cannot show it twice" modal exists because the alternative is reversible storage.
The prefix solves the performance problem bcrypt creates. Bcrypt is deliberately slow, so you cannot compare a presented key against every stored hash. Index the prefix, look up the single candidate row in O(1), then run exactly one bcrypt comparison against that row's hash. One verification per request instead of N.
The second reason for the prefix is external. A structured, greppable prefix is what makes automated secret scanning possible: GitHub's secret scanning program matches on provider-registered patterns, and a key that looks like generic base64 cannot be matched. A distinctive prefix means a key pasted into a public repo can be detected and revoked before anyone uses it. This is a real, measurable security benefit that comes from making part of your credential deliberately recognizable.
2FA belongs on the human, not on the machine path
TOTP, specified in RFC 6238, is HOTP with a time-derived counter: T = floor((now - T0) / X) with X = 30 seconds, HMAC-SHA1 over that counter with the shared secret, truncated to six digits. It is a good defense for interactive login and it is completely wrong as a per-request control for a headless client, which would have to store the TOTP seed to generate codes, at which point the second factor is sitting next to the first one on the same disk and has become decoration.
The correct granularity is step-up authentication on sensitive operations. Normal reads and writes go through on the key or access token alone. A small set of operations demands a fresh second factor from a real human in a real browser:
- Issuing or rotating an API key
- Bulk export of memory content
- Deleting a workspace or the account
- Changing email, password, or 2FA enrollment
- Granting another principal access to a workspace
Implement it as a claim with its own clock. Record amr (the methods used) and auth_time in the session, and require auth_time within, say, five minutes for the operations above. This is one middleware and a re-prompt modal. A user who never touches those operations never sees a TOTP prompt after login, and an attacker with a stolen session token cannot mint a permanent API key with it, which is the escalation path that actually gets used.
Two implementation notes that bite. Allow a one-step clock skew window (accept T-1 and T) because client clocks drift, and refuse to accept the same code twice inside its window, or you have built a 30-second replay window into your second factor. Store the TOTP seed encrypted, not plaintext, and treat recovery codes as single-use hashed credentials with the same discipline as API keys.
Unimatrix stores API keys as bcrypt hashes with an indexed lookup prefix and gates key issuance, export, and deletion behind step-up auth with an audit record on each, so a long-lived MCP connection can stay connected for weeks without that convenience widening what a stolen token can do. The full breakdown is on the security page.
Your AI remembers everything. Everywhere.
Unimatrix gives you a shared, durable memory layer across Claude Desktop, Cursor, ChatGPT, and Gemini. Setup in 2 minutes. Free and paid plans available.
Keep reading
Self-consistency samples k reasoning paths and takes the majority answer. It works because errors scatter and correct answers converge, and it costs k times as much.
License terms, context length, and tool-calling reliability matter more than leaderboard rank. A filter for deciding which open weights deserve a GPU-hour.
Two true statements about the same entity at different times are not a conflict, they are a version history. Resolution strategies and when each one is wrong.