Authenticate with Better Auth
Better Auth runs inside your own application, so your app is the issuer. That changes the shape of the integration compared with a hosted identity provider: when the engine shares the app’s process there is no token to verify at all, and when it does not, the JWKS document you verify against is one your app publishes.
The generic JWKS mechanics — bearer parsing, jose verification, and the 401
versus 500 split — are covered once in the
JWT bearer recipe. This page shows only what is
specific to Better Auth.
Choose the surface
Section titled “Choose the surface”-
Embedded, when the engine and the app share a process
The Next.js route handler, Hono handler, or server action already resolved a session. Map it to a principal and invoke directly. Nothing is signed, transmitted, or re-verified.
export function principalFromSession(resolved: BetterAuthResolvedSession | null,): Principal | null {if (resolved === null) return null;const subject = optionalString(resolved.user.id);if (subject === undefined) return null;const email = optionalString(resolved.user.email);const emailVerified = optionalBoolean(resolved.user.emailVerified);const name = optionalString(resolved.user.name);const role = optionalString(resolved.user.role);const activeOrganizationId = optionalString(resolved.session.activeOrganizationId,);const claims: BetterAuthClaims = {subject,...(email === undefined ? {} : { email }),...(emailVerified === undefined ? {} : { emailVerified }),...(name === undefined ? {} : { name }),...(role === undefined ? {} : { role }),...(activeOrganizationId === undefined ? {} : { activeOrganizationId }),};return toPrincipal(claims);}auth.api.getSessionresolves much more than this — the opaque session token, timestamps, the IP address, the user’s image. The mapping picks the few fields the engine authorizes on and leaves the rest at the boundary.export async function invokeWhoamiForSession(resolved: BetterAuthResolvedSession | null,options: { readonly signal?: AbortSignal } = {},) {return engine.invoke("identity.whoami",{},{source: "direct",principal: principalFromSession(resolved),...(options.signal === undefined ? {} : { signal: options.signal }),},);}A
nullprincipal is not an error here. The capability declaresaccess: "authenticated", so the core reportsUNAUTHENTICATEDbeforerun. Pass the incoming request’sAbortSignalso a disconnected client cancels the invocation instead of letting it run to its timeout. -
Enable the JWT plugin, when the engine is a separate service
// Your Better Auth application, not the engine.import { betterAuth } from "better-auth";import { jwt } from "better-auth/plugins";export const auth = betterAuth({baseURL: process.env.BETTER_AUTH_URL,plugins: [jwt()],});The plugin publishes JWKS at
<baseURL>/api/auth/jwksand mints a token at<baseURL>/api/auth/token, which the client reaches throughauthClient.token(). With the defaults, the signing algorithm is EdDSA over Ed25519,subis the user id,issandaudare both the app base URL, and the token expires after 15 minutes. -
Narrow the payload before it is signed
// Your Better Auth application, not the engine.jwt({jwt: {definePayload: ({ user }) => ({email: user.email,emailVerified: user.emailVerified,name: user.name,}),},});By default the plugin puts the entire user object in the payload. Narrowing it at the issuer keeps the token small, and the engine narrows again on arrival so a new database column never widens a principal by itself.
-
Verify the token with injectable key resolution
export const betterAuthJwksPath = "api/auth/jwks";/*** Builds the JWKS URL the JWT plugin serves for an app base URL. The* relative resolution preserves a proxy subpath: `https://host/portal`* yields `https://host/portal/api/auth/jwks`.*/export function betterAuthJwksUrl(baseUrl: string): URL {return new URL(betterAuthJwksPath,baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`,);}/** Production key resolution: the app's own JWKS document, fetched and cached. */export function createBetterAuthRemoteKeySet(options: BetterAuthRemoteKeySetOptions,): JWTVerifyGetKey {return createRemoteJWKSet(betterAuthJwksUrl(options.baseUrl), {timeoutDuration: options.timeoutMs ?? defaultJwksTimeoutMs,});}keysis a constructor parameter, not a module-level constant. Production passes the remote key set; the tests passcreateLocalJWKSet, so signature verification stays real without any network call.export function createBetterAuthJwtVerifier(options: BetterAuthJwtVerifierOptions,): BetterAuthTokenVerifier {const algorithms = [...(options.algorithms ?? defaultAlgorithms)];return {async verify(token, { signal }) {const verified = await untilAborted(jwtVerify(token, options.keys, {issuer: options.issuer,audience: options.audience,algorithms,requiredClaims: ["sub", "iss", "aud", "exp"],...(options.clockToleranceSeconds === undefined? {}: { clockTolerance: options.clockToleranceSeconds }),}),signal,).catch((error: unknown) => {if (error instanceof IdentityVerificationUnavailableError) throw error;const code = readErrorCode(error);if (code !== undefined && invalidCredentialCodes.has(code)) return null;throw new IdentityVerificationUnavailableError();});return verified === null ? null : readBetterAuthClaims(verified.payload);},};}The
catchis where the 401-versus-500 split is decided. A jose error whose code names a bad credential becomesnull; anything else — an unreachable JWKS endpoint, an unusable key set, a cancelled request — becomes an infrastructure failure with a fixed message and no cause, because a jose orfetcherror can quote the token or the JWKS response body.const invalidCredentialCodes: ReadonlySet<string> = new Set(["ERR_JOSE_ALG_NOT_ALLOWED","ERR_JWKS_NO_MATCHING_KEY","ERR_JWS_INVALID","ERR_JWS_SIGNATURE_VERIFICATION_FAILED","ERR_JWT_CLAIM_VALIDATION_FAILED","ERR_JWT_EXPIRED","ERR_JWT_INVALID",]);An ambiguous key set (
ERR_JWKS_MULTIPLE_MATCHING_KEYS) is deliberately not in the invalid set: it is the app’s key-publication problem, and a 401 would silently reject legitimate tokens during a kid-less key rotation. -
Wire the hook into
serveMcpHttpexport function createBetterAuthAuthenticate(verifier: BetterAuthTokenVerifier,): (request: McpHttpAuthenticationRequest) => Promise<Principal | null> {return async (request) => {const token = readBearerToken(request.headers);if (token === null) return null;const claims = await verifier.verify(token, { signal: request.signal });return claims === null ? null : toPrincipal(claims);};}export async function startBetterAuthMcpHttp(options: BetterAuthMcpHttpOptions,): Promise<McpHttpServerHandle> {return serveMcpHttp(engine, {...(options.host === undefined ? {} : { host: options.host }),...(options.port === undefined ? {} : { port: options.port }),auth: {mode: "required",authenticate: createBetterAuthAuthenticate(options.verifier),},});}One base URL configures all three checks, because the app that issues the token is the app that publishes the keys:
const baseUrl = readRequired("BETTER_AUTH_URL");const issuer = process.env.BETTER_AUTH_JWT_ISSUER ?? baseUrl;const audience = process.env.BETTER_AUTH_JWT_AUDIENCE ?? baseUrl;
Run it
Section titled “Run it”The embedded surface needs no token and no server:
yarn buildyarn workspace @invokta/example-auth-better-auth embeddedThe MCP HTTP surface needs your app’s base URL, which must serve the JWT plugin’s JWKS document:
BETTER_AUTH_URL="https://app.example.com" PORT=3000 \ yarn workspace @invokta/example-auth-better-auth mcp:httpCall it with a token from authClient.token():
curl -sS http://127.0.0.1:3000/mcp \ -H "authorization: Bearer $BETTER_AUTH_JWT" \ -H "accept: application/json, text/event-stream" \ -H "content-type: application/json" \ -d '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"identity_whoami","arguments":{}}}'Without the header, or with an expired token, the adapter answers HTTP 401
before engine.invoke runs.
Verify it
Section titled “Verify it”yarn workspace @invokta/example-auth-better-auth testThe tests generate an Ed25519 key pair, serve it through createLocalJWKSet,
and mint tokens in Better Auth’s claim shape. They cover the valid token, every
invalid class (missing header, malformed token, expired, wrong issuer, wrong
audience, unknown signing key, no subject), the JWKS outage that must throw
rather than resolve null, and the absence of token material in the principal.
No test performs network I/O.
Read the complete
auth-better-auth-engine
example for the verifier, the claim mapping, the embedded surface, and the tests.
Authentication stops here. Decide what a principal may do in Authorize with capability access rules, and review the secret and logging rules in HTTP authentication.