Skip to main content

Delegated OAuth and MCP

Expose the fixed delegated-human MCP profile through the official Better Auth OAuth Provider and live Convex authorization.

Better Convex Nuxt supports one deliberately narrow OAuth authorization-server profile for MCP clients acting on behalf of a signed-in human. Use it when an external agent needs delegated access to application operations. Start with the provider-neutral MCP on Convex guide; this page adds the optional Better Auth authorization-server profile.

This is different from a private service actor. The delegated profile requires an interactive user session, verified consent, a preregistered OAuth client, and a short-lived access token. For controlled internal automation that does not act for a user, supply a provider-neutral verifier to @lupinum/better-convex-mcp and keep credential state and authorization in the application. Do not combine those credentials or add an MCP_SERVER_SECRET bridge to the delegated path.

The complete application reference is starters/mcp-oauth-agent.

Separate the three OAuth roles

“OAuth” can describe three independent application roles:

RoleResponsibilityHow Better Convex Nuxt enables it
Social/OIDC login clientThe application signs users in through a host-selected identity provider.Configure that Better Auth provider in the application's createAuth.
Authorization serverThe application issues delegated access tokens to preregistered clients.Supply the reviewed OAuth Provider options to convexAuth() and oauthProvider().
Resource serverA protected endpoint verifies bearer tokens and scopes before application authorization.Opt in to the fixed MCP route and verify the bearer again in the Convex HTTP action.

Enabling social login does not enable the authorization server, publish MCP metadata, or create a bearer-token resource endpoint. Each role has its own configuration and threat model.

Install the exact profile

Install the exact consumer-owned auth peers only for an auth-enabled application. A Convex-only Better Convex Nuxt install includes neither package:

bash
pnpm add @lupinum/better-convex-nuxt convex@1.42.2 nuxt@4.5.1 better-auth@1.7.0-rc.2 @better-auth/oauth-provider@1.7.0-rc.2
PackageSupported source-candidate version
Nuxt4.5.1
Convex1.42.2
Better Auth1.7.0-rc.2
OAuth Provider, optional peer1.7.0-rc.2
Convex Helpers, package-owned0.1.114

The root package manifest is canonical. Do not substitute another OAuth Provider release, patch the provider in the consumer, or allow duplicate physical Provider, Better Auth, or Better Auth Core runtimes. Better Auth owns its own Kysely dependency; it is not a Better Convex peer. See release compatibility before changing the tuple.

This source candidate is not stable auth support while its Better Auth family is still an RC.

Keep one auth source of truth

Mount exactly one component named betterAuth. Better Auth owns users, accounts, sessions, verification state, OAuth clients, resources, consent, tokens, and signing keys in that component database. The OAuth Provider uses the same Better Auth factory and adapter as browser sessions; MCP does not get another auth database or adapter.

Application-owned organization, membership, delegation, approval, and resource tables remain canonical Convex product data. A user display projection may be derived from Better Auth, but it is not another credential or session store.

If a schema-changing Better Auth plugin requires the local-component mode, generate one complete local schema before the first write and mount that component as betterAuth. Do not mount the packaged and local components together.

Enable the fixed public topology

Configure Nuxt for the authorization-server origin. Register the official MCP handler directly on the deployment-owned Convex HTTP router, as shown by the starter; Nuxt does not relay 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' },
  },
})

All public identifiers come from the one validated SITE_URL; request headers never select them.

PurposeFixed public value
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 from trusted configuration:

json
{
  "resource": "https://deployment.convex.site/mcp",
  "authorization_servers": ["https://app.example.com/api/auth"],
  "scopes_supported": ["mcp:read", "mcp:write"],
  "bearer_methods_supported": ["header"]
}

The authorization-server metadata is a validated projection of the official provider's metadata. Convex owns the resource document; Nuxt does not maintain a second capability document or MCP relay.

Both metadata documents are public and may be fetched by browser-based clients with Access-Control-Allow-Origin: *; they never enable credentialed CORS. The only cross-origin browser exception under /api/auth is the exact public-client form exchange at POST /oauth2/token and its exact OPTIONS preflight. It accepts no query, Cookie, Authorization, proxy authorization, or DPoP header and keeps the body bounded to application/x-www-form-urlencoded. Authorize, revoke, session, consent, administration, and every other auth route remain same-origin. BCN's boundary proves the stored client/resource/link profile and requires one resource before consent; the official provider owns authorization request parsing, PKCE, scopes, redirect trust, state preservation, and OAuth error responses. The token/revocation guard separately proves the registered client authentication shape, redirect shape, resource, and grant before the provider's consume boundary.

Register one Convex HTTP action at /mcp; that action is the resource server. There is no Nuxt MCP route, bearer relay, or caller-selected upstream/function. The OAuth resource uses exactly five Convex route registrations: POST, GET, and DELETE at /mcp, followed by GET and OPTIONS at /.well-known/oauth-protected-resource/mcp. Convex maps HEAD discovery to the metadata GET route. The metadata OPTIONS route serves public, credential-free discovery CORS; it does not enable cross-origin MCP transport, and there is no OPTIONS /mcp route.

Configure one reviewed provider profile

Create one OAuthOptions object per request and pass that same object to both convexAuth() and oauthProvider(). The supported plugin order is jwt(), convexAuth(), then oauthProvider().

The fixed controls are:

  • authorization code only, with codes expiring in at most 120 seconds;
  • access tokens expiring in at most 600 seconds;
  • PKCE S256 for every client, including confidential clients;
  • exact HTTPS redirects or RFC 8252 loopback-IP redirects, plus one linked resource;
  • explicit consent and the exact mcp:read/mcp:write scope allowlist;
  • encrypted social-account access, refresh, and ID tokens, plus hashed delegated OAuth client secrets and stored provider token records; the delegated profile issues no refresh token;
  • 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;
  • mandatory clientPrivileges and resourcePrivileges callbacks that return true only for the application's current authorized OAuth administrator.

The callbacks fail closed on a missing session/user, false, undefined, an exception, or timeout. They authorize administration only; they never replace per-tool product authorization.

Administrator revocation is a request-start gate: after the revocation commits, new provider administration requests are denied, but it cannot cancel a provider mutation whose privilege callback already returned true. If incident response requires a terminal cutover, close or drain auth ingress first, wait for bounded in-flight requests to finish, commit the revocation, and then reopen traffic.

Use the exact server shape in the maintained starter rather than copying a partial options list. Better Convex Nuxt rejects startup when the provider, JWT graph, storage controls, grants, algorithms, privilege callbacks, or plugin ordering drift from the reviewed profile.

The pinned Better Auth RC encrypts account access/refresh tokens but misses provider ID tokens at several persistence sites. Better Convex Nuxt closes that gap at the single adapter boundary: it encrypts the ID token before the component write and decrypts it only when Better Auth reads the account inside the auth process. Social-provider sign-in and account identity continue to work. Exporting provider API tokens to application code or the browser is outside the supported profile.

Preregister clients through the provider

Do not insert or patch OAuth tables directly. An application-owned, authenticated admin operation should call the official provider's admin endpoints to create the resource, create the client, and link them. Gate that operation with the same application authorization used by clientPrivileges and resourcePrivileges, and verify the stored profile after creation.

Client kindRequired stored profile
Confidential web clientpublic: false, token_endpoint_auth_method: "client_secret_basic", exact HTTPS redirects
Public agent/native clientpublic: true, token_endpoint_auth_method: "none", exact HTTPS or registered loopback redirects, no secret

Both kinds use only grant_types: ["authorization_code"], response_types: ["code"], require_pkce: true, skip_consent: false, one exact linked /mcp resource, and the approved scopes.

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.

For a confidential client, deliver the provider's one-time secret result directly into that client's secret manager. The client authenticates at token and revocation endpoints with HTTP Basic; client_secret_post, assertions, and mixed Basic/body identities are rejected. A public client receives no secret, identifies itself with its preregistered client ID, and must complete S256 PKCE.

Dynamic registration is not a fallback. If an MCP client cannot use preregistered static client information, it is not compatible with this beta profile.

OAuth query parameters are not trustworthy display data. The login and consent pages must:

  1. accept one bounded provider transaction query;
  2. require exactly one client ID, resource, and scope value;
  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 protections. Serve both pages with Cache-Control: no-store, Content-Security-Policy: frame-ancestors 'none', X-Frame-Options: DENY, and Referrer-Policy: strict-origin. This preserves an exact same-origin POST check while sending only the origin—not the transaction path—as Referer. Never render a client name, redirect URI, resource, or scope copied only from the browser URL.

Verify the bearer and current provider grant in the Convex action

The Convex /mcp HTTP action accepts the bearer only from the Authorization header. Keep the delegated scopes in one application-owned constant and use it for provider configuration, resource metadata, verification, and transaction validators. The package verifier combines official JOSE/JWKS processing with Better Convex Nuxt's exact claim checks, while the existing auth component owns current Better Auth authority:

Create convex/mcp/scopes.ts once using the exact module in Configure one scope authority.

convex/mcp.ts
import {
  createBetterAuthMcpAccessVerifier,
  requireAuthOrigin,
} from '@lupinum/better-convex-nuxt/convex-auth'

import type { ActionCtx } from './_generated/server'
import { authComponent } from './auth'
import { MCP_SCOPES } from './mcp/scopes'

export function createMcpVerifier(ctx: ActionCtx) {
  const origin = requireAuthOrigin('SITE_URL')
  const issuer = `${origin}/api/auth`

  return createBetterAuthMcpAccessVerifier({
    allowedScopes: MCP_SCOPES,
    jwksUrl: `${issuer}/jwks`,
    maxLifetimeSeconds: 600,
    validateLiveAccess: (access) => authComponent.validateOAuthAccess(ctx, access),
  })
}

Pass that request-local verifier under authorization.verifier in handleMcpRequest. The MCP boundary supplies the exact issuer and resource to the verifier, so neither is configured twice. The verifier requires RS256, typ = "at+jwt", the OAuth access-token class, the exact scalar issuer and audience, matching client/authorized-party claims, subject, session, bounded timestamps, and allowlisted scopes. A Convex session token, ID token, array audience, foreign resource, or unknown claim is rejected.

After verification, keep only the narrow subject, session ID, client ID, resource, and scopes in action memory. Map the MCP method through a closed tool allowlist to one tool-specific internal Convex function. Never pass the raw token, a caller-supplied principal, or a caller-selected function to a public Convex query/mutation.

Keep authorization live in Convex

Scopes and consent are ceilings. authComponent.validateOAuthAccess(ctx, access) re-reads the provider-owned session, user, OAuth client, resource, client-resource link, consent, and scope grants. Each tool-specific internal mutation calls it again, then reads only the application-owned:

  • application user, organization membership, role, and delegation;
  • 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, delegation, session, client, resource link, or consent must deny the next tool call even when the access token has not expired.

Provider semantics distinguish disabling from deletion: disabling an OAuth resource blocks new issuance but does not revoke an already-issued token. Delete the resource or its client link to invalidate existing access. If your product wants disable-as-immediate-revocation, add that stronger rule explicitly to application policy.

The self-contained access token can otherwise remain a valid bearer until its maximum ten-minute expiry. Do not describe the provider's individual-token revocation response as an immediate JWT blacklist.

Return a resource challenge for missing/invalid tokens and insufficient scopes:

WWW-Authenticate: Bearer resource_metadata="https://deployment.convex.site/.well-known/oauth-protected-resource/mcp"

Add scope="mcp:read" or scope="mcp:write" only when the rejected operation has that concrete requirement.

Provision the signing key before traffic

Do not let the first public token request create signing-key state. Export the internal operator action beside createAuth:

convex/auth.ts
export const { rotateSigningKey } = authComponent.jwksOperatorFunctions(createAuth)

After the final schema, functions, secrets, and HTTP routes are deployed—but before auth or OAuth traffic is admitted—run it with deployment-admin tooling:

bash
pnpm exec better-convex-nuxt-convex run auth:rotateSigningKey '{}'

For a fresh environment, require previousKids to be empty and record the returned newKid, rotatedAt, and previousVerifyUntil. After Nuxt is deployed behind a closed traffic gate, verify the new ID appears at the public /api/auth/jwks endpoint before opening ingress. The action returns bounded metadata only; it never returns private or public key rows. If a supposedly fresh deployment reports an earlier key, stop and inventory the environment instead of deleting data.

Keep the action internal. Do not add a public HTTP route, public Convex wrapper, private-key export, or second current-key registry.

Use the same action for later rotations. It generates an encrypted RS256 key through Better Auth, then one atomic Convex mutation inserts the new current key and retires keys current at commit time. The 21-minute verification grace covers the 15-minute Convex session-token lifetime, five-minute public JWKS cache, and one-minute clock allowance; it also exceeds the OAuth token lifetime. Concurrent rotations preserve every verification key through its full grace period.

Never delete all JWKS rows. After a rotation:

  1. verify the returned newKid is published;
  2. verify new session and OAuth tokens use the new key;
  3. retain prior keys through at least previousVerifyUntil and all cache/token bounds;
  4. rotate BETTER_AUTH_SECRETS separately, retaining every version needed to decrypt stored keys and other ciphertext until an inventory and decryption rehearsal passes.

Deploy and recover safely

Use an empty environment and keep public ingress closed until the complete profile is ready:

  1. install the exact tuple and generate the final packaged or local schema/metadata pair;
  2. set the exact Nuxt/Convex origins and independent secrets described in environment variables;
  3. deploy the single betterAuth component, application schema, functions, and HTTP routes;
  4. run the pre-traffic rotateSigningKey ceremony and require the fresh-environment metadata shape;
  5. deploy Nuxt behind a closed traffic gate and verify the returned key through public JWKS;
  6. use the provider-owned admin flow to create and verify the /mcp resource, clients, and resource links, then verify both metadata documents, login, consent, PKCE, wrong-client/resource/scope denial, and live authorization revocation;
  7. admit public traffic only after those checks pass.

Before public traffic, discard and recreate a disposable environment if provisioning is incomplete. After users, clients, consent, or audit state exist, preserve that state and forward-fix. Do not attach an older auth runtime, restore an incompatible schema, run an identity conversion, mount a second component, or delete JWKS/OAuth rows to simulate rollback.

To contain a delegated-access incident, disable the affected provider-owned clients/resources and the public MCP route, preserve evidence, and ship a reviewed fix through the normal immutable release path. Roll back only to an already tested artifact that uses the identical schema and security profile; otherwise deploy a new fixed artifact.

Continue with the deployment checklist and security model.

Disabled beta capabilities

The public metadata and raw routes must not advertise or enable:

  • refresh tokens or offline_access;
  • 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 workforce SSO behavior.

Do not turn on one of these features to accommodate a client. Treat it as a new security design requiring its own schema, protocol, threat-model, interoperability, and release review. See limitations and trade-offs.

Verify the profile

For an application deployment, test the real public origin with at least two users and the actual preregistered clients. Exercise consent denial, exact redirect and resource binding, missing/wrong scopes, session/client/consent/membership revocation, tenant crossover, destructive approval, key rotation, restart, and token expiry.

Repository changes to this profile run these focused gates:

CommandEvidence
pnpm check:auth-schemaFresh schema, metadata, codegen, component mount, and first-deploy shape
pnpm test:oauthDiscovery, PKCE, bindings, claims, replay, failures, and disabled routes
pnpm test:mcp-authTwo direct preregistered public-client PKCE flows plus live MCP authorization
pnpm test:mcp-conformanceStable-SDK contract proof plus published 2025 protocol scenarios
pnpm test:auth-concurrencyReal-backend atomics, rate limits, and concurrent JWKS rotation
pnpm test:auth-export-sentinelsReal component export plus browser-persistence credential canaries
pnpm test:auth-fuzzBounded hostile auth/OAuth HTTP inputs
pnpm verify:authComplete auth/OAuth/MCP repository gate

test:mcp-conformance uses the stable official 2.0.0 client for the 2026-07-28 request envelope. The older official conformance package is retained only for the published 2025-11-25 initialize, ping, and tools-list scenarios because it advertises no 2026-07-28 scenarios. Neither path certifies the OAuth authorization server; keep MCP protocol evidence, direct PKCE interoperability, and independent OAuth security review as separate results.