On this page
Better Auth MCP is Better Auth's mcp() plugin, which since version 1.7
lives in its own package, @better-auth/mcp. It makes your app the OAuth 2.1
sign-in server that MCP clients use, and its requireMcpAuth wrapper checks
the token on every request to your MCP route.
It isn't an MCP server you install. It's a library you add to your own TypeScript app. It fits when your users already sign in with Better Auth and you want AI apps to reach your MCP server as those users. The current release is 1.7.22 (12 November 2026), MIT licensed, from the better-auth/better-auth repo (about 30,700 stars).
Some background in plain words. An MCP server gives an AI app a set of tools. A remote one needs to know who is calling. The MCP spec answers that with OAuth: the client sends the user to a sign-in page, gets a token back, and sends that token with every request. Better Auth can be the part that signs people in and hands out the tokens. The full flow is in how MCP OAuth works.
Heads-up if you're following an older tutorial. Most Better Auth MCP posts
online show the 1.6 plugin: import { mcp } from "better-auth/plugins" with
withMcpAuth. That API changed in 1.7, released 17 August 2026. This guide
uses the current one, and the migration table
maps the old names to the new ones.
What the Better Auth MCP plugin does
mcp() switches on Better Auth's OAuth 2.1 provider with MCP settings. Per the
plugin docs, Better Auth owns
"OAuth discovery, client registration and discovery, PKCE authorization,
resource binding, token issuance and refresh, and protected-resource
challenges." Here's who does what:
| Part | Who handles it |
|---|---|
Discovery documents (/.well-known/oauth-protected-resource, authorization server metadata) |
Better Auth |
| Knowing which client is asking (CIMD, or DCR if you turn it on) | Better Auth |
| Sign-in with PKCE, tokens tied to your server's address, refresh | Better Auth |
401 and 403 answers on the MCP route |
Better Auth (requireMcpAuth) |
| The MCP protocol and transport | The official MCP TypeScript SDK v2 (@modelcontextprotocol/server) |
| Your sign-in page, your consent page and the tools themselves | You |
The OAuth endpoints live under /oauth2/*: /oauth2/authorize,
/oauth2/token, /oauth2/userinfo, and /oauth2/register only when you
enable dynamic registration.
Better Auth MCP setup in 5 steps
The docs' example is a Next.js app. The same parts apply to other frameworks.
1. Install the packages
npm install @better-auth/mcp @better-auth/cimd @modelcontextprotocol/server zod
Keep better-auth and @better-auth/mcp on the same version. The 1.7.22
plugin asks for better-auth ^1.7.22.
2. Add the plugin to your auth config
This is the docs' auth.ts:
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import { cimd } from "@better-auth/cimd";
import { mcp } from "@better-auth/mcp";
import { fetchClientMetadataResource } from "@better-auth/cimd/node";
export const auth = betterAuth({
disabledPaths: ["/token"],
plugins: [
jwt({ disableSettingJwtHeader: true }),
mcp({
loginPage: "/sign-in",
consentPage: "/consent",
resource: "https://api.example.com/mcp",
}),
cimd({
fetchClientMetadataResource,
metadataProfile: "mcp-2026-07-28",
}),
],
});
What each part does:
resourceis your MCP server's public address. Tokens carry it as their audience, so a token for this server won't work anywhere else. It must be HTTPS; plain HTTP is accepted only on localhost.jwt()is required. It signs the tokens and publishes the/jwkskeys your MCP route checks them against.disabledPaths: ["/token"]anddisableSettingJwtHeader: trueswitch off the JWT plugin's own session tokens. The docs explain why: "Session JWTs share the access tokens' signing key", so a loose check could accept one as an access token. Don't drop these two lines.cimd()lets MCP clients identify themselves with a document on their own website. More on that below.- Don't also add
oauthProvider(). The docs are clear: "mcp()is the OAuth provider."
3. Create the tables
npx auth migrate
Or run npx auth generate to write the schema for your ORM. auth is Better
Auth's own CLI package. It adds the OAuth tables: oauthClient,
oauthAccessToken, oauthRefreshToken, oauthConsent,
oauthClientAssertion, oauthResource and oauthClientResource.
4. Build the sign-in and consent pages
When a client starts sign-in, Better Auth sends the user to loginPage if
they aren't signed in, then to consentPage to approve the client. Add the
client plugin to the app that renders those pages:
import { createAuthClient } from "better-auth/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
export const authClient = createAuthClient({
plugins: [oauthProviderClient()],
});
@better-auth/oauth-provider is the package @better-auth/mcp is built on;
add it to your own dependencies since you import from it. On the consent page,
show who is asking and which scopes they want, then call:
await authClient.oauth2.consent({ accept: true });
Pass scope with a space-separated subset to grant only some of the requested
scopes. Client names and logos come from the client itself, so we'd also show
the client ID. Under CIMD that's a web address the user can recognise.
5. Protect the MCP route
A trimmed version of the docs' app/api/mcp/route.ts:
import { auth } from "@/lib/auth";
import { requireMcpAuth } from "@better-auth/mcp";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod";
const resource = "https://api.example.com/mcp";
const mcpServerHandler = createMcpHandler(
() => {
const server = new McpServer({ name: "example-mcp-server", version: "1.0.0" });
server.registerTool(
"echo",
{ description: "Echo a message", inputSchema: z.object({ message: z.string() }) },
async ({ message }) => ({ content: [{ type: "text", text: `Tool echo: ${message}` }] }),
);
return server;
},
{ legacy: "reject" },
);
const POST = requireMcpAuth(auth, (request) => mcpServerHandler.fetch(request), {
resource, // must match mcp({ resource })
});
export { POST };
What happens on each request:
requireMcpAuthreads theAuthorizationheader and checks the token's signature, issuer, audience and expiry against your JWKS. It also enforces DPoP when a token is bound to a key.- No token: a
401with aWWW-Authenticateheader that tells the client where to sign in. - Missing scope: a
403withinsufficient_scope, so the client can ask for more. Set the scopes every call needs withrequiredScopes. - Only
POSTis exported. MCP 2026-07-28 is stateless, soGETandDELETEget405. The Streamable HTTP guide explains that change.
About legacy: "reject". It accepts only the 2026-07-28 protocol. The
SDK's type docs say the default, "stateless", also answers older 2025-era
clients one request at a time. If some of your users' clients haven't moved to
2026-07-28 yet, leave the option out.
If your auth lives under a base path such as /api/auth, check discovery.
Better Auth serves the discovery documents, but only for requests that reach
its handler, and most frameworks forward only /api/auth/*. For each
well-known path outside it, add a route. The
OAuth Provider docs
show oauthProviderAuthServerMetadata(auth) at
app/.well-known/oauth-authorization-server/api/auth/route.ts.
Client ID Metadata Documents vs dynamic client registration
Before an MCP client can sign anyone in, your server has to know the client. There are two ways.
- Client ID Metadata Documents (CIMD). The client's ID is a web address.
Your server fetches a small JSON document from it with the client's name and
callback addresses. Nothing is stored ahead of time. The 2026-07-28 spec
prefers this, and Better Auth calls it "the recommended client identity
mechanism".
metadataProfile: "mcp-2026-07-28"picks the matching version. - Dynamic client registration (DCR). The client posts its details to
/oauth2/registerand gets an ID back. The 2026-07-28 spec deprecates it, and "Better Auth never enables DCR implicitly."
To keep DCR for older clients that still need it, turn on both settings:
mcp({
loginPage: "/sign-in",
consentPage: "/consent",
resource: "https://api.example.com/mcp",
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
clientRegistrationDefaultApplicationType: "infer",
})
Know what that opens. The docs warn it "lets anyone on the internet register a
client, with a name and logo they choose." Rate-limit /oauth2/register, or
anyone can fill your database with client registrations. "infer" helps clients that register
localhost or custom-scheme callbacks without saying they're desktop apps:
they're registered as native instead of being rejected.
CIMD means your server fetches an address the client picked. On Node.js,
the bundled fetchClientMetadataResource from @better-auth/cimd/node
handles this. On Bun, Deno, Workers and other runtimes, the docs say you must supply a
fetcher that "resolves each hostname once, rejects RFC 6890 special-use
addresses, pins the approved address for the connection, and refuses
redirects." Skip that and a fake client could make your server call addresses
on your internal network.
Moving from the old Better Auth MCP plugin
The 1.6 docs warned: "This plugin will soon be deprecated in favor of the
OAuth Provider Plugin." It wasn't dropped. In 1.7 it moved to
@better-auth/mcp, now built on that OAuth Provider plugin. Better Auth's
1.7 notes say existing setups need a
migration "because the package, setup, and OAuth paths have changed." From
the 1.7 upgrade guide:
| Old (1.6) | New (1.7) |
|---|---|
import { mcp } from "better-auth/plugins" |
import { mcp } from "@better-auth/mcp" |
withMcpAuth |
requireMcpAuth |
mcpHandler |
createMcpProtectedRequestHandler |
createMcpAuthClient and its adapters |
Removed: use @modelcontextprotocol/client or @modelcontextprotocol/server |
Options nested under oidcConfig |
Flat options on mcp({ ... }) |
| No JWT plugin needed | jwt() required |
resourceMetadataMappings |
One HTTPS resource |
Endpoints under /mcp/* |
/oauth2/* (clients find them through discovery) |
| Unauthenticated registration on by default | Off unless you set both DCR flags |
GET and DELETE exports, redisUrl session store |
POST only, no session store |
Route scopes |
requiredScopes, plus challengeScopes for the 401 hint |
oauthApplication table |
oauthClient: copy or re-register each client (redirectUrls becomes redirectUris) |
The CLI doesn't copy client records for you. The guide says to expire the old
access tokens, then drop or rename the old oauthAccessToken table before
creating the 1.7 token tables. Expect your MCP users to sign in again after
the switch.
Better Auth MCP security: advisories to patch
TrustedMCP doesn't grade Better Auth: it's a library, not an MCP server. These are the public signals we'd check for any auth library.
The repo lists 49 published security advisories (4 critical, 24 high) as of 12 November 2026. That's a lot. It also shows people are looking hard and fixes ship. What matters is your version. The latest batch, 12 advisories published on 3 November 2026, is fixed in 1.7.16. It includes a critical flaw in the Expo authorization proxy and an OAuth sign-in that could treat an unverified email as verified.
These touched the MCP plugin or the OAuth provider it now runs on:
| Advisory | What went wrong | Fixed in |
|---|---|---|
| GHSA-86j7-9j95-vpqj (medium) | OIDC and MCP plugins accepted javascript: redirect addresses that could run scripts |
1.6.13, 1.7.0-beta.4 |
| GHSA-pw9m-5jxm-xr6h (medium) | OIDC provider and MCP plugins accepted refresh tokens without the client secret | 1.6.11 |
| GHSA-7w99-5wm4-3g79 (high) | Parallel requests could reuse one authorization code | oauth-provider 1.6.11 |
| GHSA-392p-2q2v-4372 (high) | Concurrent refreshes could create extra valid refresh tokens | oauth-provider 1.6.11 |
| GHSA-p2fr-6hmx-4528 (medium) | Access tokens could target APIs the user didn't authorize | oauth-provider 1.7.0-beta.4 |
| GHSA-q84f-53jg-9ppm (high) | Device login approval didn't show the requesting client or permissions | 1.7.0-rc.3 |
None of the six affects a 1.7 stable release. Run 1.7.16 or later and check what you actually have:
npm ls better-auth @better-auth/mcp @better-auth/oauth-provider
If the same app also runs an MCP server on the official SDK, check that too: MCP TypeScript SDK vulnerabilities.
Check your Better Auth MCP setup before you ship
0 of 7 done
- Patched versions.
better-auth,@better-auth/mcpand@better-auth/oauth-provideron 1.7.16 or later, all on the same version. - Session tokens off.
disabledPaths: ["/token"]andjwt({ disableSettingJwtHeader: true })are both still in your config. - One exact
resource. Your MCP server's real HTTPS address, with the same value passed torequireMcpAuth. - DCR off unless you need it. If a client you must support can't do CIMD
and you turn DCR on, rate-limit
/oauth2/register. - A decision on refresh-token reuse. The plugin lets a client reuse a
refresh token for 30 seconds so retries work. The docs warn that an
attacker who replays a stolen one inside that window "gets the replacement
refresh token". Set
refreshTokenReuseInterval: 0if strict replay handling matters more to you than retries on flaky networks. - Small scopes. Require read scopes up front. For a tool that needs more,
throw
createInsufficientScopeError(["mcp:admin"])frombetter-auth/oauth2so the client asks for that scope. If granting a scope can't help (the record belongs to someone else), return a plain403. - A real test. Connect with the
MCP Inspector
and no token: you should get a
401whoseWWW-Authenticateheader names yourresource_metadata. Then run the full sign-in, and confirm a token issued for a differentresourceis refused.
The rest of the server-side list (input checks, timeouts, logging) is in how to create an MCP server, securely.
Running the MCP server on another host
requireMcpAuth checks tokens against your Better Auth JWKS, so the MCP route
can run anywhere that can reach that URL. When it runs separately from Better
Auth, the docs use createMcpProtectedRequestHandler with the checks spelled
out:
import { createMcpProtectedRequestHandler } from "@better-auth/mcp";
const handler = createMcpProtectedRequestHandler(
{
issuer: "https://auth.example.com/api/auth",
audience: "https://api.example.com/mcp",
jwksUrl: "https://auth.example.com/api/auth/jwks",
jwtVerifyOptions: { typ: "at+jwt", requiredClaims: ["azp"] },
},
async (request, accessTokenClaims) => {
// accessTokenClaims holds the verified token claims
return yourMcpHandler(request, accessTokenClaims);
},
);
It sends the same 401 and 403 answers as requireMcpAuth.
A Python or Go MCP server can't use this helper, but it can make the same
checks with any JWT library: signature against the /jwks URL, the issuer,
the audience (your resource), expiry and the at+jwt token type. It must
also send the 401 challenge itself.
When Better Auth isn't the right fit
- Your company already has an identity provider (Okta, Microsoft Entra ID, Auth0 and so on). Point your server's protected resource metadata at it instead. See adding OAuth to your own MCP server.
- Your app doesn't use Better Auth. Adding a whole auth library just for MCP sign-in means a second user database to keep in sync.
- Your server is local. A stdio server on the user's own machine doesn't need OAuth. Read a key from the environment, as in keeping API keys out of your MCP config.
Searching "better auth mcp server" also turns up a few community GitHub repos with that name. They're separate projects, last updated in 2025, and not the plugin this guide covers.
Want OAuth added to an MCP server you already run, or a new server built around your app's sign-in? Tell us what it connects to.
Frequently asked questions
Is Better Auth MCP an MCP server?
No. It's a plugin for Better Auth, the TypeScript auth library. It makes your app the OAuth sign-in server for your own MCP server. You still write the MCP server, usually with the official TypeScript SDK.
Is the Better Auth MCP plugin deprecated?
No. The 1.6 docs said it would "soon be deprecated in favor of the OAuth
Provider Plugin". Instead, 1.7 moved it to @better-auth/mcp, built on that
OAuth Provider plugin. The old import from better-auth/plugins is the part
that's gone.
Does Better Auth MCP support dynamic client registration?
Yes, but it's off by default. Set both allowDynamicClientRegistration and
allowUnauthenticatedClientRegistration to turn it on, and rate-limit the
registration endpoint. Client ID Metadata Documents are the recommended way.
Which MCP spec version does Better Auth MCP support?
Better Auth says @better-auth/mcp "supports the MCP 2026-07-28
authorization profile", the current spec version. Its example route rejects
older 2025-era clients with legacy: "reject"; the MCP SDK's default,
"stateless", still serves them.
Do I need the JWT plugin for Better Auth MCP?
Yes. In 1.7 jwt() is required. It signs the access tokens and publishes the
keys your MCP route checks them with. Also turn off its /token route and
set-auth-jwt header, as in the docs' config.
Can I use Better Auth MCP with a Python MCP server?
Yes, if Better Auth runs in a TypeScript app as the sign-in server. Your
Python server then checks each token against Better Auth's /jwks URL, issuer
and audience with a JWT library, and answers 401 with the
WWW-Authenticate header when a token is missing.
How do I install the better-auth MCP plugin?
Run npm install @better-auth/mcp @better-auth/cimd @modelcontextprotocol/server zod,
add mcp(), jwt() and cimd() to your betterAuth config, run
npx auth migrate, then wrap your MCP route with requireMcpAuth. The
five setup steps above show each part.