Summary
Adding @better-auth/oauth-provider (to expose an MCP / OAuth 2.1 server) alongside the convex() plugin builds and deploys cleanly but breaks login at runtime: after a successful sign-in, GET /api/auth/convex/token returns 404 while authenticated, so the Convex client never receives its session JWT and the app bounces back to the login page. Removing oauthProvider fixes it instantly.
I believe this is a collision between @better-auth/oauth-provider and the deprecated oidc-provider that convex() registers internally to serve /convex/token. I'd love to know if there's a supported way to run them together, or if a standalone (non-plugin) OAuth server is the only path on Convex.
Environment
@convex-dev/better-auth: 0.12.5 (local install)
better-auth: 1.6.18
@better-auth/oauth-provider: 1.6.18
@better-auth/core: 1.6.18
convex: 1.42.0
- Node: 24.13.1
- Framework: Next.js (App Router), auth handler mounted at
/api/auth/[...all]
Static JWKS (the experimental optimization) is enabled: convex({ authConfig, jwks: process.env.JWKS }) + getAuthConfigProvider({ jwks: process.env.JWKS }).
What I'm trying to do
Turn the Convex app into an OAuth 2.1 provider so MCP clients (Claude Desktop, etc.) can authenticate via the standard /.well-known discovery + PKCE flow, using @better-auth/oauth-provider.
Steps to reproduce
- oauthProvider requires a "jwt" plugin. Adding it alone throws:
BetterAuthError: jwt_config
at getJwtPlugin (@better-auth/oauth-provider/dist/utils-*.mjs)
Source — @better-auth/oauth-provider looks up a plugin by id "jwt":
const getJwtPlugin = (ctx) => {
const plugin = ctx.getPlugin("jwt");
if (!plugin) throw new BetterAuthError("jwt_config");
return plugin;
};
But convex() merges its JWT into a single plugin with id: "convex" (and ...jwt.schema) — it does not expose id: "jwt" (@convex-dev/better-auth/dist/plugins/convex/index.js). So ctx.getPlugin("jwt") is undefined. → I must add an explicit jwt().
-
The explicit jwt() must be RS256. getAuthConfigProvider() returns type: "customJwt", algorithm: "RS256" (dist/auth-config.js), and Convex's customJwt only accepts RS256/ES256 — so a default-EdDSA jwt() would break Convex's session-token validation. Pinning RS256 reuses the existing key.
-
Final config (this builds, generates schema, typechecks, and pushes cleanly):
disabledPaths: ["/token"],
plugins: [
convex({ authConfig, jwks: process.env.JWKS }),
organization({ /* ... / }),
admin({ / ... */ }),
// ...existing plugins, unchanged...
jwt({ jwks: { keyPairConfig: { alg: "RS256" } }, disableSettingJwtHeader: true }),
oauthProvider({ loginPage: "/login", consentPage: "/oauth/consent" }),
]
npx auth generate --output generatedSchema.ts succeeds and adds the 4 OAuth tables; custom indexes preserved; tsc clean; convex dev reports "Convex functions ready!".
▎ Note: schema generation requires env vars (SITE_URL etc.) because oauthProvider.init calls new URL(baseURL) and throws Invalid URL with an empty base in the env-less CLI context.
Expected behavior
Login continues to work; /api/auth/convex/token returns the Convex session JWT for an authenticated user.
Actual behavior
Sign-in succeeds at the Better Auth level, but the Convex token endpoint 404s while authenticated, so the Convex client stays unauthenticated and the app redirects to /login. Next.js request log:
POST /api/auth/sign-in/email 200 ← Better Auth sign-in SUCCEEDS
GET /api/auth/organization/list 200 ← BA session valid
GET /api/auth/get-session 200
GET / 307 ← redirect
GET /api/auth/convex/token 404 ← ❌ Convex token endpoint broken (authenticated!)
GET /login 200 ← bounced back to /login
POST /api/auth/organization/set-active 200 ← BA session still works (misleading)
GET /api/auth/convex/token 404 ← still 404
The Convex deployment also logs this warning on every request once oauthProvider is active:
[WARN] [Better Auth]: Please ensure '/.well-known/oauth-authorization-server/api/auth' exists.
Upon completion, clear with silenceWarnings.oauthAuthServerConfig.
Removing oauthProvider (and the explicit jwt()) restores /convex/token → login works again.
Root-cause hypothesis
convex() serves /convex/token (and the Convex OIDC discovery) via the deprecated oidc-provider plugin it registers internally. @better-auth/oauth-provider is the successor to oidc-provider, and registering it appears to shadow/replace that internal OIDC handling — so /convex/token no longer resolves (404). This seems structural (two OIDC providers in one instance), not a config typo.
What I've ruled out
- disabledPaths: ["/token"] is not the cause — Better Auth matches it exactly (disabledPaths.includes(normalizedPath)), and the Convex endpoint normalizes to /convex/token, not /token.
- Static JWKS is not involved — the 404 happens regardless; removing it doesn't change anything (and it's unrelated to OIDC routing).
- Not a build/type error — schema gen, tsc, and the convex dev push are all clean. The break is purely runtime.
- GET /convex/token → 404 when logged out is normal — only the 404 while authenticated is the bug.
Questions
- Is running @better-auth/oauth-provider alongside convex() supported? If so, what's the correct configuration?
- Does the internal oidc-provider that convex() uses conflict with the newer oauth-provider by design? Is there an option to prevent the collision (e.g. namespacing, or having convex() expose its JWT under id: "jwt" so a second jwt() isn't needed)?
- If it's not supported, is there a roadmap for OAuth-provider / MCP support — or is a standalone OAuth Authorization Server (separate Convex HTTP routes / a Convex component, not a Better Auth plugin) the recommended approach?
Summary
Adding @better-auth/oauth-provider (to expose an MCP / OAuth 2.1 server) alongside the convex() plugin builds and deploys cleanly but breaks login at runtime: after a successful sign-in, GET /api/auth/convex/token returns 404 while authenticated, so the Convex client never receives its session JWT and the app bounces back to the login page. Removing oauthProvider fixes it instantly.
I believe this is a collision between @better-auth/oauth-provider and the deprecated oidc-provider that convex() registers internally to serve /convex/token. I'd love to know if there's a supported way to run them together, or if a standalone (non-plugin) OAuth server is the only path on Convex.
Environment
@convex-dev/better-auth: 0.12.5 (local install)better-auth: 1.6.18@better-auth/oauth-provider: 1.6.18@better-auth/core: 1.6.18convex: 1.42.0/api/auth/[...all]Static JWKS (the experimental optimization) is enabled: convex({ authConfig, jwks: process.env.JWKS }) + getAuthConfigProvider({ jwks: process.env.JWKS }).
What I'm trying to do
Turn the Convex app into an OAuth 2.1 provider so MCP clients (Claude Desktop, etc.) can authenticate via the standard /.well-known discovery + PKCE flow, using @better-auth/oauth-provider.
Steps to reproduce
BetterAuthError: jwt_config
at getJwtPlugin (@better-auth/oauth-provider/dist/utils-*.mjs)
Source — @better-auth/oauth-provider looks up a plugin by id "jwt":
const getJwtPlugin = (ctx) => {
const plugin = ctx.getPlugin("jwt");
if (!plugin) throw new BetterAuthError("jwt_config");
return plugin;
};
But convex() merges its JWT into a single plugin with id: "convex" (and ...jwt.schema) — it does not expose id: "jwt" (@convex-dev/better-auth/dist/plugins/convex/index.js). So ctx.getPlugin("jwt") is undefined. → I must add an explicit jwt().
The explicit jwt() must be RS256. getAuthConfigProvider() returns type: "customJwt", algorithm: "RS256" (dist/auth-config.js), and Convex's customJwt only accepts RS256/ES256 — so a default-EdDSA jwt() would break Convex's session-token validation. Pinning RS256 reuses the existing key.
Final config (this builds, generates schema, typechecks, and pushes cleanly):
disabledPaths: ["/token"],
plugins: [
convex({ authConfig, jwks: process.env.JWKS }),
organization({ /* ... / }),
admin({ / ... */ }),
// ...existing plugins, unchanged...
jwt({ jwks: { keyPairConfig: { alg: "RS256" } }, disableSettingJwtHeader: true }),
oauthProvider({ loginPage: "/login", consentPage: "/oauth/consent" }),
]
npx auth generate --output generatedSchema.ts succeeds and adds the 4 OAuth tables; custom indexes preserved; tsc clean; convex dev reports "Convex functions ready!".
▎ Note: schema generation requires env vars (SITE_URL etc.) because oauthProvider.init calls new URL(baseURL) and throws Invalid URL with an empty base in the env-less CLI context.
Expected behavior
Login continues to work; /api/auth/convex/token returns the Convex session JWT for an authenticated user.
Actual behavior
Sign-in succeeds at the Better Auth level, but the Convex token endpoint 404s while authenticated, so the Convex client stays unauthenticated and the app redirects to /login. Next.js request log:
POST /api/auth/sign-in/email 200 ← Better Auth sign-in SUCCEEDS
GET /api/auth/organization/list 200 ← BA session valid
GET /api/auth/get-session 200
GET / 307 ← redirect
GET /api/auth/convex/token 404 ← ❌ Convex token endpoint broken (authenticated!)
GET /login 200 ← bounced back to /login
POST /api/auth/organization/set-active 200 ← BA session still works (misleading)
GET /api/auth/convex/token 404 ← still 404
The Convex deployment also logs this warning on every request once oauthProvider is active:
[WARN] [Better Auth]: Please ensure '/.well-known/oauth-authorization-server/api/auth' exists.
Upon completion, clear with silenceWarnings.oauthAuthServerConfig.
Removing oauthProvider (and the explicit jwt()) restores /convex/token → login works again.
Root-cause hypothesis
convex() serves /convex/token (and the Convex OIDC discovery) via the deprecated oidc-provider plugin it registers internally. @better-auth/oauth-provider is the successor to oidc-provider, and registering it appears to shadow/replace that internal OIDC handling — so /convex/token no longer resolves (404). This seems structural (two OIDC providers in one instance), not a config typo.
What I've ruled out
Questions