Better Auth
Let a Better Auth server issue the tokens; verify them with h3-mcp/oauth.
Better Auth's mcp() plugin acts as the OAuth 2.1 authorization server. It handles login, consent, PKCE, Client ID Metadata Documents, and discovery metadata. h3-mcp is the resource server: it checks tokens but does not issue them.
Configure both servers with the same issuer, resource URL, and key set.
#The Better Auth side
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
import { mcp } from "@better-auth/mcp";
export const auth = betterAuth({
baseURL: "https://auth.example.com", // + basePath (`/api/auth` by default) = the issuer
plugins: [
jwt(), // signs the access tokens; EdDSA/Ed25519 by default
mcp({
loginPage: "/sign-in",
consentPage: "/consent",
resource: "https://api.example.com/mcp", // the audience for access tokens
scopes: ["openid", "profile", "mcp:read", "mcp:write"],
}),
],
});The values h3-mcp needs are:
| Better Auth | Value |
|---|---|
| Access token | An RFC 9068 JWT, typ: at+jwt, with a kid |
iss | jwt.issuer, or the resolved base URL — base path included: https://auth.example.com/api/auth |
aud | resource; when openid is granted, a list that also includes the UserInfo endpoint |
scope | Space-separated; the verifier converts it to AuthIdentity.scopes |
| Key set | {issuer}/jwks |
| Introspection (RFC 7662) | {issuer}/oauth2/introspect, client-authenticated |
| Protected-resource document | /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp on the Better Auth origin |
#The h3-mcp side
Use jwtValidator() to verify tokens locally with Better Auth's public keys. It uses WebCrypto and does not need jose:
import { defineMcpHandler } from "h3-mcp";
import { jwtValidator } from "h3-mcp/oauth";
const ISSUER = "https://auth.example.com/api/auth"; // Better Auth's base URL, base path included
const RESOURCE = "https://api.example.com/mcp"; // identical to `mcp({ resource })`
export default defineMcpHandler({
name: "my-server",
version: "1.0.0",
auth: {
schemes: ["bearer"],
validate: jwtValidator({
issuer: ISSUER,
audience: RESOURCE,
jwksUri: `${ISSUER}/jwks`,
requireType: true, // require Better Auth's `typ: at+jwt` header
}),
resourceMetadataUrl: "https://auth.example.com/.well-known/oauth-protected-resource/mcp",
scopes: ["mcp:read"],
},
tools: [
{
name: "delete_file",
scopes: ["mcp:write"], // returns 403 if this scope is missing
// ...
},
],
});iss and aud must match exactly. Copy them from the Better Auth config: a different trailing slash or missing base path causes 401 responses. If you set jwt.issuer, use that value for issuer here.
Handlers can read all verified claims from event.context.mcp.auth.claims, including sub, client_id, azp, jti, sid, and any resource customClaims.
#The metadata document
After a 401, an MCP client follows the challenge's resource_metadata URL to find the authorization server. Better Auth already serves this document. If Better Auth and /mcp run on the same origin, point resourceMetadataUrl to that document. You do not need another metadata route.
If they run on different origins, you can still point to Better Auth's document. The client checks that its resource matches the MCP endpoint. You can also serve the document on the MCP origin, as RFC 9728 expects:
import { protectedResourceMetadata } from "h3-mcp/oauth";
export default protectedResourceMetadata({
resource: "https://api.example.com/mcp",
authorizationServers: ["https://auth.example.com/api/auth"],
scopesSupported: ["mcp:read", "mcp:write"],
});#Checking for sign-out
Better Auth links each token to a session through sid. Its introspection endpoint reports the token as inactive when the session ends, whether through sign-out, admin revocation, or back-channel logout.
Local verification cannot detect these changes. It accepts a token until exp (one hour by default). Use introspection if MCP access needs to end sooner:
import { introspectionValidator } from "h3-mcp/oauth";
validate: introspectionValidator({
endpoint: `${ISSUER}/oauth2/introspect`,
issuer: ISSUER,
audience: RESOURCE,
clientId: process.env.MCP_RS_CLIENT_ID!,
clientSecret: process.env.MCP_RS_CLIENT_SECRET!,
cacheTtlMs: 30_000, // how long a sign-out can go unnoticed
}),Use credentials for a client registered with Better Auth and linked to the resource. Registration with resources creates this link as an oauthClientResource row. Other clients receive { "active": false }, so they cannot inspect tokens for your resource.
This also works if Better Auth issues opaque tokens. Set cacheTtlMs: 0 if every request must check for sign-out; the example above may accept a token for up to 30 seconds after its session ends.
#DPoP
Better Auth advertises DPoP support through dpop_signing_alg_values_supported. Clients using DPoP receive a token tied to a key and send it with Authorization: DPoP … and a proof.
Wrap the token verifier in dpopValidator() and enable the scheme, and those clients are served too:
import { dpopValidator, jwtValidator } from "h3-mcp/oauth";
auth: {
schemes: ["bearer", "dpop"],
validate: dpopValidator({
validate: jwtValidator({ issuer: ISSUER, audience: RESOURCE, jwksUri: `${ISSUER}/jwks`, requireType: true }),
resource: RESOURCE, // the proof's `htu`; identical to `mcp({ resource })`
// replay: yourSharedStore, // several instances behind one URL need one
}),
}The proof is verified against Better Auth's cnf.jkt binding (see sender-constrained tokens); Better Auth's default dpop.signingAlgorithms are all in h3-mcp's table. Clients using ordinary bearer tokens are unaffected, and a DPoP token sent as a bearer token is still refused by the transport. With this in place you can turn on dpopBoundAccessTokensRequired for the resource, which makes every client bind its token. Better Auth keeps its DPoP replay store in its database; h3-mcp's default is per process and evicts live entries at its cap, so give replay a shared store (one atomic add) when you run more than one instance or want the replay window airtight.
#Try it on the playground
Set the Better Auth base URL to try JWT verification — with DPoP, the playground wraps its verifier in dpopValidator() — in the playground:
MCP_PLAYGROUND_BETTER_AUTH_URL=http://localhost:3000/api/auth pnpm playConfigure Better Auth with mcp({ resource: "http://localhost:6274/mcp" }). HTTP is allowed only on loopback addresses.
Set MCP_PLAYGROUND_CLIENT_ID and MCP_PLAYGROUND_CLIENT_SECRET to use introspection instead. The secret tool requires a token with the secret:read scope.