
# Better Auth

> Let a [Better Auth](https://better-auth.com) server issue the tokens; verify them with `h3-mcp/oauth`.

Better Auth's [`mcp()` plugin](https://better-auth.com/docs/plugins/mcp) 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

```ts [lib/auth.ts]
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](https://datatracker.ietf.org/doc/html/rfc9068) 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`](/security/auth#scopes)                       |
| Key set                                                                   | `{issuer}/jwks`                                                                                                   |
| Introspection ([RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662)) | `{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`:

```ts [routes/mcp.ts]
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:

```ts [routes/.well-known/oauth-protected-resource.ts]
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:

```ts
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](https://datatracker.ietf.org/doc/html/rfc9449) 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:

```ts
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](/security/auth#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:

```bash
MCP_PLAYGROUND_BETTER_AUTH_URL=http://localhost:3000/api/auth pnpm play
```

Configure 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.

:read-more{to="/security/auth#oauth" title="OAuth"}
