Skip to main content

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:

RoleResponsibilityHow Better Convex Nuxt enables it
Social/OIDC login clientThe application signs users in with an identity provider such as GitHub.Pass socialProviders to createBetterConvexAuth().
Authorization serverThe application issues delegated access tokens to preregistered clients.Pass oauth: { mcp } to createBetterConvexAuth().
Resource serverA 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:

bash
pnpm add better-auth@1.7.6 @better-auth/core@1.7.6 @better-auth/oauth-provider@1.7.6

Do 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:

nuxt.config.ts
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.

PurposePublic URL
Authorization issuerhttps://app.example.com/api/auth
Authorization-server metadatahttps://app.example.com/.well-known/oauth-authorization-server/api/auth
JWKShttps://app.example.com/api/auth/jwks
MCP resourcehttps://deployment.convex.site/mcp
Protected-resource metadatahttps://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:

json
{
  "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:

convex/auth.ts
export const auth = createBetterConvexAuth<DataModel>(components.betterAuth, {
  authFunctions,
  oauth: {
    mcp: {
      scopes: { 'mcp:read': 'Read your projects.', 'mcp:write': 'Change your projects.' },
      hosts: ['chatgpt', 'claude'],
    },
  },
})
OptionMeaning
scopesDelegated scopes, name to consent description. 1 to 64 scopes; openid, profile, email, and offline_access are rejected.
resourceThe MCP endpoint and token audience. A path (default /mcp) resolves against CONVEX_SITE_URL; an absolute HTTPS URL is used as is.
hostsHosts whose callback presets auth.oauthOperator.createHostClient accepts: chatgpt, claude.
renewalIssue session-bound refresh tokens for offline_access. Default true.
loginPageThe application page that signs a person in for an authorization request. Default /login.
consentPageThe 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: false and disabled /get-access-token and /refresh-token routes, so provider credentials remain inside the auth process;
  • RS256 access tokens with typ = "at+jwt" and token_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.oauthOperator in 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:

convex/connections.ts
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 callEffect
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.

Never trust OAuth query parameters as display text. The login and consent pages must:

  1. accept one bounded provider transaction query;
  2. require exactly one client ID, resource, and scope value; the scope value lists only allowlisted scopes and optional offline_access;
  3. verify the preregistered client through the provider-owned prelogin/transaction path before rendering its name;
  4. compare the resource to the exact deployment-owned CONVEX_SITE_URL + "/mcp" identifier and the scopes to the fixed allowlist;
  5. display the verified client name, exact resource, and requested scopes;
  6. submit the original bounded transaction state back to the provider;
  7. let approval preserve or narrow the requested scope set, never widen it;
  8. 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:

convex/mcp.ts
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:

convex/auth.ts
export const { ensureSigningKey, pruneSigningKeys, rotateSigningKey } = auth.jwksOperatorFunctions()

After you deploy the schema, functions, environment variables, and HTTP routes, run:

bash
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:

  1. check that the returned newKid appears at the JWKS URL;
  2. check that new session and OAuth tokens use the new key;
  3. keep the previous keys until at least previousVerifyUntil;
  4. rotate BETTER_AUTH_SECRETS separately. 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:

  1. install the exact package versions and generate the final auth schema;
  2. set the origins and secrets described in environment variables;
  3. deploy the betterAuth component, your schema, functions, and HTTP routes;
  4. run auth:ensureSigningKey;
  5. deploy Nuxt and check that the key appears at the JWKS URL;
  6. create host clients with auth.oauthOperator, then test both metadata documents, login, consent, PKCE, a wrong client, resource, or scope, and revocation;
  7. 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_access when renewal is false;
  • 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-token or /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:

CommandWhat it checks
pnpm testDiscovery, PKCE, bindings, claims, replay, failures, disabled routes, and bounded hostile auth and OAuth HTTP inputs
pnpm test:integrationOn 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.