Delegated OAuth and MCP
The MCP OAuth profile, its security controls, client provisioning, live grant checks, and deployment steps.
Your application can act as an OAuth server for MCP hosts, such as ChatGPT and Claude, that act for a signed-in person. This page is the reference for that oauth.mcp profile: what it turns on, what it checks, and how to run it. Start with MCP on Convex for the overview.
The profile always needs a person: a signed-in session, a consent screen, an OAuth client that you created, and a short-lived access token. For automation that does not act for a person, give @lupinum/better-convex-mcp your own token verifier and keep those credentials and their checks in your application. Do not mix the two kinds of credentials, and do not add a shared MCP_SERVER_SECRET to the delegated path.
The copyable application path is Add MCP to your application. starters/mcp-oauth-agent is the complete, tested example. Host settings are in Connect ChatGPT and Claude.
Three different OAuth roles
"OAuth" can mean three different jobs for your application:
| Role | Responsibility | How Better Convex Nuxt enables it |
|---|---|---|
| Social/OIDC login client | The application signs users in with an identity provider such as GitHub. | Pass socialProviders to createBetterConvexAuth(). |
| Authorization server | The application issues delegated access tokens to preregistered clients. | Pass oauth: { mcp } to createBetterConvexAuth(). |
| Resource server | A protected endpoint verifies bearer tokens and scopes before application authorization. | Register the MCP routes and verify with auth.createMcpAccessVerifier(ctx). |
Social sign-in does not turn on the authorization server, publish MCP metadata, or create an MCP endpoint. You configure each role separately. See Social sign-in for the first role.
Install the packages
Install Better Auth and the OAuth provider at the exact versions that the module declares as peers:
pnpm add better-auth@1.7.6 @better-auth/core@1.7.6 @better-auth/oauth-provider@1.7.6Do not install another OAuth provider version or patch it in your application. The application must resolve exactly one copy of each package. See release compatibility before you change a version.
Keep one auth component
Mount exactly one component named betterAuth. It stores users, accounts, sessions, verification state, OAuth clients, resources, consent, tokens, and signing keys. The OAuth provider uses the same component as browser sessions. MCP does not get a second auth database.
Your organizations, memberships, approvals, and resources stay in your own Convex tables. A user projection copied from Better Auth is display data. It does not store credentials or sessions.
better-convex init writes a local auth component. Its schema has no OAuth tables until you add oauthProvider() to convex/betterAuth/schemaPlugins.ts and regenerate the schema. Step 1 of Add MCP to your application shows how. Generate the complete schema before the first write and mount it as betterAuth. Do not mount the packaged and the local component together.
Public URLs
Nuxt serves the OAuth server under SITE_URL. Convex serves the MCP endpoint
from its own HTTP router. Nuxt does not forward MCP traffic:
export default defineNuxtConfig({
modules: ['@lupinum/better-convex-nuxt'],
convex: {
url: process.env.NUXT_PUBLIC_CONVEX_URL,
siteUrl: process.env.NUXT_PUBLIC_CONVEX_SITE_URL,
auth: {
origin: process.env.SITE_URL ?? 'http://localhost:3000',
trustedClientIpHeader: process.env.BCN_AUTH_TRUSTED_CLIENT_IP_HEADER,
},
},
})Every public URL comes from SITE_URL and CONVEX_SITE_URL. Request headers never change them.
| Purpose | Public URL |
|---|---|
| Authorization issuer | https://app.example.com/api/auth |
| Authorization-server metadata | https://app.example.com/.well-known/oauth-authorization-server/api/auth |
| JWKS | https://app.example.com/api/auth/jwks |
| MCP resource | https://deployment.convex.site/mcp |
| Protected-resource metadata | https://deployment.convex.site/.well-known/oauth-protected-resource/mcp |
The Convex MCP handler publishes the protected-resource document. Include offline_access in scopesSupported, so hosts that read this document ask for renewal:
{
"resource": "https://deployment.convex.site/mcp",
"authorization_servers": ["https://app.example.com/api/auth"],
"scopes_supported": ["mcp:read", "mcp:write", "offline_access"],
"bearer_methods_supported": ["header"]
}The authorization-server metadata comes from the official OAuth provider, checked by the library.
Browser-based clients can read both metadata documents from any origin
(Access-Control-Allow-Origin: *, without credentials). The only other
cross-origin route under /api/auth is the public-client token exchange,
POST /oauth2/token, and its OPTIONS preflight. That route accepts no query,
cookie, Authorization, proxy authorization, or DPoP header, and only a
bounded application/x-www-form-urlencoded body. Every other auth route,
including authorize, revoke, session, and consent, accepts same-origin requests
only.
The library checks that the client, its resource, and the link between them exist before consent, and that the request names exactly one resource. The official provider handles the authorization request, PKCE, scopes, redirect matching, state, and OAuth error responses. Before the provider issues or revokes a token, the library checks the client's authentication method, redirect, resource, and grant again.
Register one Convex HTTP action for /mcp. There is no Nuxt MCP route. The
OAuth resource uses exactly five Convex route registrations: POST, GET,
and DELETE at /mcp, and GET and OPTIONS at
/.well-known/oauth-protected-resource/mcp. Convex sends HEAD requests to the
metadata GET route. The metadata OPTIONS route allows cross-origin reads of
the metadata only. There is no OPTIONS /mcp route, so browsers on other
origins cannot call MCP tools.
Configure the MCP OAuth profile
Add oauth.mcp to createBetterConvexAuth(). The factory builds and validates
the complete provider profile:
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
authFunctions,
oauth: {
mcp: {
scopes: { 'mcp:read': 'Read your projects.', 'mcp:write': 'Change your projects.' },
hosts: ['chatgpt', 'claude'],
},
},
})| Option | Meaning |
|---|---|
scopes | Delegated scopes, name to consent description. 1 to 64 scopes; openid, profile, email, and offline_access are rejected. |
resource | The MCP endpoint and token audience. A path (default /mcp) resolves against CONVEX_SITE_URL; an absolute HTTPS URL is used as is. |
hosts | Hosts whose callback presets auth.oauthOperator.createHostClient accepts: chatgpt, claude. |
renewal | Issue session-bound refresh tokens for offline_access. Default true. |
loginPage | The application page that signs a person in for an authorization request. Default /login. |
consentPage | The application page that asks for consent. Default /oauth/consent. |
auth.mcp.issuer(), auth.mcp.resource(), and auth.mcp.scopes() return the
configured values for handleMcpRequest. auth.mcp.scopesSupported() returns
the scope names plus offline_access when renewal is on; pass it as
scopesSupported so hosts can request renewal. Each throws
AUTH_OAUTH_MCP_PROFILE_REQUIRED when oauth.mcp is not configured.
The profile sets these rules. You cannot change them:
- authorization code with codes expiring in 120 seconds, plus the refresh-token grant when renewal is on;
- access tokens expiring in 600 seconds;
- PKCE S256 for every client;
- exact HTTPS redirects or RFC 8252 loopback-IP redirects, plus one linked resource per client;
- explicit consent and the exact scope allowlist;
- hashed delegated OAuth client secrets and stored provider token records, and encrypted social-account access, refresh, and ID tokens;
account.storeAccountCookie: falseand disabled/get-access-tokenand/refresh-tokenroutes, so provider credentials remain inside the auth process;- RS256 access tokens with
typ = "at+jwt"andtoken_use = "oauth-access"; - database-backed Better Auth rate limiting on authorize, token, and revoke;
- client and resource management through Better Auth endpoints is denied. Clients are created only by
auth.oauthOperatorin Convex functions.
The factory sets up the adapter, routes, JWT settings, and plugin order. It is the only supported way to build auth. Do not assemble Better Auth plugins yourself.
You cannot combine oauth.mcp and oauthProvider. Use oauthProvider only
for a provider profile that you write and review yourself, for example one
that manages clients through the provider's admin endpoints. That profile must
define clientPrivileges and resourcePrivileges callbacks that return true
only for a signed-in OAuth administrator. A missing session or user, false,
undefined, a thrown error, or a timeout denies the request. The factory fails
at startup when either profile does not keep the rules above.
Better Auth 1.7.6 encrypts provider access and refresh tokens, but not every provider ID token. The library encrypts ID tokens before it writes them to the component and decrypts them only inside Better Auth. Your application code and the browser cannot read provider tokens.
Connection renewal
Renewal is on by default. A host that requests offline_access receives a
refresh token, so the connection stays usable after the ten-minute access-token
lifetime. offline_access permits renewal only; it grants no application
operation. Set renewal: false to issue access tokens only.
The refresh family is bound to the Better Auth session that granted consent and to the current client, resource link, consent, and scopes. It expires after at most seven days and never after that session. Signing out, deleting the session, or revoking consent denies renewal and the next protected operation. Renewal does not create a new application session.
The provider rotates refresh tokens. A completed retry with the same bound
request inside ten seconds returns the encrypted cached response; it does not
mint another successor. A concurrent request that reaches an in-flight exchange
receives temporarily_unavailable with Retry-After: 1; clients must use
bounded recovery rather than an unlimited retry loop. Reuse outside the
interval revokes the affected client and user consent and the refresh family.
Deleting a session, user, or client removes access at once, without walking through its refresh history. Hashed token rows and encrypted retry records can remain and point to deleted rows, but the live check rejects them. Delete old rows on your own schedule. Deleting one refresh family removes at most 128 rows and revokes its consent; rows that remain cannot regain access through a new consent.
Every access token carries the immutable consent identity, with renewal on or
off. The typed principal exposes it as grantId, and every live check binds it
to the current consent. A new consent cannot restore a token issued under an
older consent, and a token without the claim is rejected.
To turn renewal on for a deployment that already has connections, register new
clients that request offline_access and obtain new consent. Do not widen an
existing grant silently. Test expiry, a fresh conversation, concurrent
exchange, a lost response, out-of-interval reuse, session, client, and consent
revocation, and reconsent.
Provision clients
Do not insert or patch OAuth tables directly. Create clients with
auth.oauthOperator from an internal Convex function or an admin-only mutation:
export const createHostClient = internalMutation({
args: {
host: v.union(v.literal('chatgpt'), v.literal('claude')),
redirectUri: v.optional(v.string()),
},
handler: async (ctx, args) => await auth.oauthOperator.createHostClient(ctx, args),
})| Operator call | Effect |
|---|---|
createHostClient(ctx, { host, redirectUri?, name?, scopes? }) | A public PKCE client for a host listed in oauth.mcp.hosts, bound to the MCP resource. Claude uses its fixed callback https://claude.ai/api/mcp/auth_callback. ChatGPT needs the exact callback it displays: https://chatgpt.com/connector_platform_oauth_redirect or https://chatgpt.com/connector/oauth/<id>. scopes defaults to every profile scope, plus offline_access with renewal. |
createPublicClient(ctx, input) | A public PKCE client with explicit name, profile, redirect URIs, resource, and scopes, for example for MCP Inspector on http://localhost:6274/oauth/callback. |
setClientDisabled(ctx, { clientId, disabled }) | Disables a client for every user. Its access tokens fail on the next request. |
deleteClient(ctx, { clientId }) | Removes a client and its resource link. |
The operator creates or verifies the application-owned resource, creates the
client, and links both records. It accepts only scopes from the configured
profile and accepts HTTPS redirects or exact HTTP loopback redirects without
credentials or fragments. If client creation or linking fails, it removes the
records that attempt created or reports AUTH_OAUTH_CLIENT_PARTIAL_CLEANUP_FAILED.
The factory does not decide who may call the operator. Your application
decides that. Keep operator functions internal or admin-only.
Every client uses only grant_types: ["authorization_code"] (plus
refresh_token with offline_access), response_types: ["code"],
require_pkce: true, skip_consent: false, token_endpoint_auth_method: "none", one exact linked resource, and the approved scopes. A public client
receives no secret and identifies itself with its client ID.
HTTP loopback registrations use one canonical callback with an explicit port.
For 127.0.0.1 and [::1], RFC 8252 permits the native client to choose an
ephemeral port at authorization time; scheme, IP literal, path, and query still
match exactly, and token redemption must repeat the exact callback used for the
authorization code. localhost is a DNS name, so its port remains exact.
Dynamic registration is not a fallback. If an MCP client cannot use preregistered client information, it is not compatible with this profile.
Build the login and consent pages
Never trust OAuth query parameters as display text. The login and consent pages must:
- accept one bounded provider transaction query;
- require exactly one client ID, resource, and scope value; the scope value lists only allowlisted scopes and optional
offline_access; - verify the preregistered client through the provider-owned prelogin/transaction path before rendering its name;
- compare the resource to the exact deployment-owned
CONVEX_SITE_URL + "/mcp"identifier and the scopes to the fixed allowlist; - display the verified client name, exact resource, and requested scopes;
- submit the original bounded transaction state back to the provider;
- let approval preserve or narrow the requested scope set, never widen it;
- provide an explicit denial path.
Use the existing Better Auth session, CSRF, and origin checks. Serve both pages with Cache-Control: no-store, Content-Security-Policy: frame-ancestors 'none', X-Frame-Options: DENY, and Referrer-Policy: strict-origin. With this referrer policy, the browser still sends the origin for the same-origin POST check, but not the transaction path. Never render a client name, redirect URI, resource, or scope copied only from the browser URL. Add MCP to your application has a complete implementation.
Check the token and the grant in the Convex action
The Convex /mcp HTTP action accepts the bearer only from the Authorization
header. Create the verifier from the auth factory for each request:
export const handleMcp = httpAction((ctx, request) =>
handleMcpRequest(request, {
serverInfo: { name: 'projects', version: '1.0.0' },
resource: auth.mcp.resource(),
authorization: {
mode: 'oauth',
issuer: auth.mcp.issuer(),
verifier: auth.createMcpAccessVerifier(ctx),
scopesSupported: auth.mcp.scopesSupported(),
},
configureServer: ({ principal, server, tools }) => registerTools(ctx, principal, server, tools),
}),
)The verifier rejects a malformed or oversized token before any key lookup. It reads public RS256 signing keys from the auth component through one query and caches them per isolate for at most five minutes. The Convex action never fetches JWKS over HTTP. An unknown key ID triggers one fresh read. A retired key verifies only until its grace period ends.
It then requires RS256 with a key ID and no embedded key material, typ = "at+jwt", the OAuth access-token class, the exact issuer ${SITE_URL}/api/auth,
the exact scalar audience (the MCP resource), matching client and
authorized-party claims, the subject and session, bounded timestamps, and
allowlisted scopes. A caller-selected issuer, a Convex session token, an ID
token, an array audience, a foreign resource, or an unknown claim is rejected.
Finally, one component query checks the session, client, resource,
client-resource link, and consent together.
The result carries the typed BetterConvexMcpPrincipal. handleMcpRequest
passes it to configureServer. Map each MCP tool to one tool-specific internal
Convex function and pass the principal as its argument, validated with
mcpPrincipalValidator. Never pass the raw token, a principal from a public
function's arguments, or a caller-selected function.
Keep authorization live in Convex
Scopes and consent set the most a token can do. They do not grant access to a
specific record. In each tool's internal function, call
auth.requireMcpPrincipal(ctx, principal, { scope }) first. It checks the
principal's shape, expiry, issuer, and resource, repeats the live grant check
in the function's transaction, and checks the scope. It throws a ConvexError
with code MCP_ACCESS_DENIED or MCP_INSUFFICIENT_SCOPE, and returns the
public user. Then load your own data:
- application user, organization membership, and role;
- requested resource ownership and product capability;
- operation-specific rate limit and any destructive-action approval.
Perform those checks and the state change in the same Convex transaction. Removing a membership, session, client, resource link, or consent, or disabling the resource, denies the next tool call even when the access token has not expired.
Let each person revoke their own connections. auth.oauthConnections.list(ctx, { userId }) returns up to 100 grants with client name, scopes, and grant time.
auth.oauthConnections.revoke(ctx, { userId, clientId }) deletes the consent
first, then the refresh tokens of that user and client. Pass the authenticated
user's ID, for example from auth.requireUser(ctx). Never accept it from
client input.
Disabling an OAuth resource stops new tokens and rejects tokens that already
exist on the next request. Enabling it again restores them until they expire.
Deleting the resource or its client link also ends existing access. To stop one
client at once, use auth.oauthOperator.setClientDisabled: the live check
rejects its tokens on the next request.
Without a live check, an access token stays valid until it expires, after at most ten minutes. Revoking one token through the provider does not change that.
Return a resource challenge for missing or invalid tokens and insufficient scopes:
WWW-Authenticate: Bearer resource_metadata="https://deployment.convex.site/.well-known/oauth-protected-resource/mcp"A tool's scopes add scope="mcp:write" to its challenge only when the rejected operation has that concrete requirement.
Create the signing key before the first sign-in
Create the signing key yourself before users sign in. Do not let the first
token request create it. Export the internal operator functions beside
createAuth:
export const { ensureSigningKey, pruneSigningKeys, rotateSigningKey } = auth.jwksOperatorFunctions()After you deploy the schema, functions, environment variables, and HTTP routes, run:
pnpm exec better-convex convex run auth:ensureSigningKey '{}'The action returns { created, kid }. Running it again returns the existing
key instead of creating another one. It never returns key material. After you
deploy Nuxt, check that kid appears at https://app.example.com/api/auth/jwks.
Keep these functions internal. Do not add a public HTTP route or a public Convex function that calls them.
Use rotateSigningKey for later rotations. It creates a new encrypted RS256
key through Better Auth. One Convex mutation stores the new key and marks
the previous one as retired. Retired keys still verify tokens for 21 minutes. This covers
the 15-minute Convex session token, the five-minute JWKS cache, and one minute
of clock drift. It is also longer than the OAuth access-token lifetime.
Delete retired keys only with pruneSigningKeys. It deletes keys whose
21-minute period has ended, oldest first, at most batchSize (1 to 256,
default 64) per call. It never deletes the current key. Run it again while it
returns hasMore: true, for example from a daily Convex cron. Never delete
JWKS rows by hand. After a rotation:
- check that the returned
newKidappears at the JWKS URL; - check that new session and OAuth tokens use the new key;
- keep the previous keys until at least
previousVerifyUntil; - rotate
BETTER_AUTH_SECRETSseparately. Keep every secret version that stored keys or other encrypted data still need, until you have tested that everything decrypts without it.
Deploy and recover
Keep the application closed to the public until every step works:
- install the exact package versions and generate the final auth schema;
- set the origins and secrets described in environment variables;
- deploy the
betterAuthcomponent, your schema, functions, and HTTP routes; - run
auth:ensureSigningKey; - deploy Nuxt and check that the key appears at the JWKS URL;
- create host clients with
auth.oauthOperator, then test both metadata documents, login, consent, PKCE, a wrong client, resource, or scope, and revocation; - open the application to the public.
If a step fails before real users exist, delete the environment and start again. After users, clients, or consent exist, keep that data and deploy a fix. Do not restore an older auth version, restore an incompatible schema, mount a second component, or delete JWKS or OAuth rows to undo a change.
If delegated access is misused, disable the affected clients and the /mcp
route, keep the logs, and deploy a fix. Roll back only to an earlier release
that uses the same schema and the same auth settings.
Continue with the deployment checklist and security model.
Features the profile does not support
The profile does not offer and does not advertise:
- refresh tokens that outlive the granting session, or
offline_accesswhenrenewalisfalse; - dynamic/unauthenticated registration or Client ID Metadata Documents;
- client credentials, implicit, password, device, or assertion grants;
- DPoP, PAR, JAR, request objects, or multi-resource access tokens;
- introspection, UserInfo, end-session, or OIDC discovery/ID tokens;
- Better Auth provider-token export through
/get-access-tokenor/refresh-token; - outbound OIDC identity-provider or enterprise SSO behavior.
If an MCP client needs one of these features, it cannot use this profile. Do not turn the feature on through another path. See limitations.
Verify the profile
Test the deployed application on its real public origin, with at least two users and the real OAuth clients. Test consent denial, exact redirect and resource matching, missing and wrong scopes, revocation of a session, client, consent, or membership, access to another organization's data, approval of destructive actions, key rotation, restarts, token expiry, and renewal.
Changes to this profile in the library repository run two test commands:
| Command | What it checks |
|---|---|
pnpm test | Discovery, PKCE, bindings, claims, replay, failures, disabled routes, and bounded hostile auth and OAuth HTTP inputs |
pnpm test:integration | On a real local Convex backend: preregistered public-client PKCE flows with live MCP authorization, the official MCP client, concurrency, and schema deployment |
The official MCP conformance suite (0.1.16) has no 2026-07-28 scenarios
yet, so the integration suite uses the official client instead. These tests
do not replace a security review of your own OAuth deployment.