Transport Auth

Authenticate requests with bearer tokens, API keys, or a custom validator. Use OAuth scopes to control access.

Auth is off by default because many MCP endpoints sit behind a gateway that already authenticates. Turn it on and every request to the route — POST, GET, and DELETE — must present a credential.

defineMcpHandler({
  name: "my-server",
  version: "1.0.0",
  auth: {
    tokens: [process.env.TOKEN!],
  },
  tools: [/* ... */],
});

With no schemes given, both bearer and api-key are accepted: Authorization: Bearer <token> and x-api-key: <token>. A third scheme, dpop, is opt-in — see sender-constrained tokens.

#API Key Only

defineMcpHandler({
  name: "my-server",
  version: "1.0.0",
  auth: {
    schemes: ["api-key"],
    header: "x-mcp-token", // default: x-api-key
    tokens: [process.env.TOKEN!],
  },
});

#Custom Validator

Use validate to check JWTs, tenant keys, or credentials stored in a database. The callback receives the parsed credentials and the event. Return false to reject the request, true to accept it without scopes, or an identity object with the caller's details and scopes:

defineMcpHandler({
  name: "my-server",
  version: "1.0.0",
  auth: {
    schemes: ["bearer"],
    validate: async (auth, event) => {
      const claims = await verifyJwt(auth.token);
      if (!claims) return false;
      return { subject: claims.sub, scopes: claims.scope.split(" "), claims };
    },
  },
});

Handlers can read the identity from event.context.mcp.auth. The library uses scopes for scope checks and passes subject and claims through unchanged for your handlers to use.

A validator can also refuse with a reason: { error: "invalid_token" } puts error="invalid_token" on the 401 challenge of the scheme the client used (RFC 6750; RFC 9449 §7.2), and the two DPoP reasons — invalid_dpop_proof, and use_dpop_nonce with a nonce — are for a validator that checks proofs; raised for a bearer client, or with the dpop scheme off, they are sent as invalid_token. A bare false sends the same challenge as a missing credential.

Enabling auth requires at least one of tokens or validate; a config with neither throws at startup rather than accepting everything. The dpop scheme requires validate (nothing else can check a proof), and auth.scopes requires it too (a static token holds no scopes).

Important

Construct a verifier once, outside any dynamic options function. Everything a h3-mcp/oauth verifier remembers — the JWKS cache and its refresh floor, the introspection cache, the DPoP replay store — lives in the closure jwtValidator() / introspectionValidator() / dpopValidator() returns. Creating one per request throws all of it away: every request fetches the key set, and a DPoP proof can be replayed freely because no two requests share a store.

#Responses

Missing or invalid credentials get HTTP 401 with a WWW-Authenticate header. Valid credentials that lack a required scope get 403. Both responses use plain JSON, not JSON-RPC.

Tokens are trimmed and have a length limit. Invalid header names and auth.scopes are rejected when auth options are resolved. Scopes on definitions are checked per request.

#Scopes

The library checks scopes at two levels:

  • auth.scopes applies to every request, including POST, GET, and DELETE.
  • scopes on a definition adds requirements for a tool, resource, resource template, or prompt. These are checked before the handler runs on both protocol eras. The checks cover tools/call, resources/read, resources/subscribe (a subscription reveals when a protected resource changes, so it is gated on the same resource a read is), prompts/get, and completion/complete for prompts and templates.
defineMcpHandler({
  name: "my-server",
  version: "1.0.0",
  auth: {
    schemes: ["bearer"],
    validate: verifyToken, // returns { scopes: [...] }
    resourceMetadataUrl: "https://example.com/.well-known/oauth-protected-resource",
    scopes: ["mcp:invoke"],
  },
  tools: [
    defineTool({ name: "read", handler: readFile }),
    defineTool({ name: "write", scopes: ["files:write"], handler: writeFile }),
  ],
});

A caller needs mcp:invoke to use this endpoint. With that scope, they can list both tools and call read. Calling write without files:write returns:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer realm="mcp", resource_metadata="https://example.com/.well-known/oauth-protected-resource", error="insufficient_scope", scope="mcp:invoke files:write"

The scope parameter lists the endpoint and definition scopes so the client can request the access it needs. A 401 response also includes scope="mcp:invoke" to guide the client's first authorization request.

Keep these rules in mind:

  • Listings are not filtered by scope. A scoped tool still appears in tools/list, but its scopes field does not. Use dynamic options to hide tools.
  • A legacy batch is one request. If any call lacks a required scope, the whole batch is rejected. The challenge combines the endpoint scopes and the requirements of the failing calls, up to 64 scopes.
  • Static tokens have no scopes. A token accepted from tokens cannot access a scoped definition. Return scopes from validate instead. Setting auth.scopes without validate throws when auth options are resolved.
  • Scoped definitions require auth. If auth is off, calling a scoped definition returns 500 without running its handler.

Unknown names and URIs get the method's normal not-found error, not a scope error.

#Scope Hierarchies

By default, scopes must match exactly. Use impliesScope when a broader scope should satisfy a narrower one, such as files granting files:read:

auth: {
  validate: verifyToken,
  impliesScope: (granted, required) =>
    granted === required || granted === "admin" || required.startsWith(`${granted}:`),
}

The callback is only reached when the caller holds at least one scope and the operation requires at least one. If it throws, the request is answered with a static 500 on both eras (the same answer a malformed definition scope gets), never with a 403 that would read as a genuine denial.

#OAuth

h3-mcp can act as an OAuth 2.1 resource server. Every server entry includes auth challenges and scope checks. Import the metadata handler and token verifiers from h3-mcp/oauth when you need them. The library is not an authorization server.

#Protected Resource Metadata

Clients use your RFC 9728 metadata document to find your authorization server. Mount protectedResourceMetadata() on a well-known route and set auth.resourceMetadataUrl to that URL:

import { defineMcpHandler } from "h3-mcp";
import { protectedResourceMetadata } from "h3-mcp/oauth";

app.get(
  "/.well-known/oauth-protected-resource",
  protectedResourceMetadata({
    resource: "https://example.com/mcp", // the audience a valid token must name
    authorizationServers: ["https://auth.example.com/"],
    scopesSupported: ["mcp:invoke"],
  }),
);

app.all(
  "/mcp",
  defineMcpHandler({
    name: "my-server",
    version: "1.0.0",
    auth: {
      schemes: ["bearer"],
      validate: verifyToken,
      resourceMetadataUrl: "https://example.com/.well-known/oauth-protected-resource",
    },
  }),
);

Auth 401 and 403 responses then include resource_metadata="https://example.com/.well-known/oauth-protected-resource" in the Bearer challenge. The document always includes bearer_methods_supported: ["header"]: bearer tokens are read from the Authorization header, never the query string.

You can use the root path shown above or an endpoint-specific path, such as /.well-known/oauth-protected-resource/mcp for /mcp.

The helper validates its options when you create it:

  • URLs must be absolute https: URLs without fragments; http: is allowed only on loopback hosts (localhost, 127.0.0.1, [::1]) for local development.
  • resource and each authorizationServers entry are identifiers (RFC 9728 §2, RFC 8414 §2) and must not carry a query. resourceDocumentation may.
  • authorizationServers must contain at least one URL.
  • scopesSupported must contain valid RFC 6749 scope tokens and must not include offline_access.

URLs in the document are kept exactly as supplied because clients compare resource and issuer identifiers as exact strings. For example, https://mcp.example.com stays unchanged; no trailing slash is added.

The separate auth.resourceMetadataUrl option is normalized when auth options are resolved. It must be an absolute https: URL (or http: on a loopback host) safe to use in a header: a client fetches it and trusts the authorization servers it names, so a plaintext pointer is a path to a rogue authorization server. Invalid values, or using it without the bearer or dpop scheme (the challenges that carry it), throw an error.

#Token Verification

Important

validate must verify the token. Check its signature, issuer, expiry, and audience (aud, including in an introspection response). Without an audience check, your server could accept a token issued for another service. See the authorization security considerations.

h3-mcp/oauth provides two verifiers that check tokens and return an identity with scopes. Both have no dependencies and use WebCrypto. At request time, they return false instead of throwing if verification fails. Network failures, malformed tokens, and bad signatures all return 401 to the client. Invalid options throw when you create the verifier.

#JWT access tokens

Use jwtValidator() to verify JWT access tokens (RFC 9068) locally with the issuer's public key set (JWKS):

import { jwtValidator } from "h3-mcp/oauth";

auth: {
  schemes: ["bearer"],
  resourceMetadataUrl: "https://example.com/.well-known/oauth-protected-resource",
  scopes: ["mcp:invoke"],
  validate: jwtValidator({
    issuer: "https://auth.example.com",
    audience: "https://example.com/mcp",
    jwksUri: "https://auth.example.com/.well-known/jwks.json",
  }),
}

The verifier checks these in order:

Algorithm: alg must be in algorithms. By default, all supported asymmetric algorithms are allowed: RS*, PS*, ES*, and EdDSA. HS* and none are never allowed.
Signature: the key must match the algorithm and, if supplied, the token's kid. The key's alg, use, and key_ops must also allow verification. An RSA key must be at least 2048 bits (RFC 7518 §3.3): a shorter one in keys is a construction error, and one in a fetched JWKS is dropped.
Claims: iss must match issuer, and aud must include a configured audience. exp must not have passed, and nbf, if present, must not be in the future. Time checks allow clockToleranceMs of leeway (default one minute).

The returned identity uses scope for scopes, sub for subject, and the full payload for claims. Use scopeClaim: "scp" if your issuer stores scopes in an scp array. Set requireType: true to require typ: at+jwt (RFC 9068). This rejects ID tokens by type as well as checking their audience.

Keys are cached for cacheTtlMs (default ten minutes). The verifier keeps using the cached keys while it fetches an update, and keeps them if the fetch fails. An unknown kid triggers a refresh to pick up rotated keys. The other side of that: a key the issuer has removed keeps verifying until the cached set expires, so after a key compromise, lower cacheTtlMs or restart the process rather than waiting on rotation alone. Fetches are limited to one every 30 seconds so clients cannot trigger a fetch with every token.

Pass keys instead of jwksUri to use a fixed key set with no network requests. A token without a kid is checked against at most three eligible keys. Issuers with more keys should include a kid in each token.

issuer, jwksUri, and endpoint require HTTPS, except for local development on loopback addresses. The transport rejects bearer tokens larger than 8 KiB before calling a validator.

Neither verifier checks a token's cnf claim; that is the transport's job, described next.

Read more in Better Auth as the authorization server.

#Sender-constrained tokens

A token with a cnf claim (RFC 7800) is bound to a key the caller must prove it holds — with a DPoP proof (cnf.jkt, presented as Authorization: DPoP <token> plus a DPoP header) or a TLS client certificate (RFC 8705, cnf["x5t#S256"]). dpopValidator() verifies the DPoP proof on top of whichever token verifier you use:

import { dpopValidator, jwtValidator } from "h3-mcp/oauth";

auth: {
  schemes: ["bearer", "dpop"],
  validate: dpopValidator({
    validate: jwtValidator({ issuer, audience: "https://example.com/mcp", jwksUri }),
    resource: "https://example.com/mcp", // what the proof's `htu` must name
  }),
}

The inner validator verifies the token first; then the proof is held to RFC 9449 §4.3 — typ: dpop+jwt, an allowed alg with a public jwk (an RSA key at least 2048 bits with an exponent of at most 4 bytes — the key is the client's, so its cost is bounded before any signature is checked), jti, htm against the request method, htu against resource (its query and fragment ignored, and scheme/host case, the default port and dot segments normalized on both sides — compared to config rather than the request URL, so a proxy in front changes nothing), iat within maxAgeMs (default 5 minutes, plus clockToleranceMs of future skew), ath against the token, the signature with the proof's own key, and that key's RFC 7638 thumbprint against the token's cnf.jkt. A proof that passes is remembered by its jti and refused the second time. The identity comes back with binding: "dpop"; a bearer request is passed through untouched. Under the dpop scheme, a token with no cnf.jkt is invalid_token; any proof failure is invalid_dpop_proof.

Two options matter in production:

  • replay — where accepted jtis are kept. The default is a Map inside the validator, bounded by replayMax (default 10,000): expired entries are swept as it fills, and at the cap with nothing expired the oldest live entry is evicted — which reopens that proof to replay until it would have expired, something an attacker holding a bound token of their own can bring about on purpose. It is also per process: with several instances behind one URL, a proof accepted by one would be accepted by the next. For either reason, supply a store they share — one call, add(jti, expiresAt), that remembers the jti unless it is already held and returns whether it was new (a unique index, SET NX, putIfAbsent). It is one call so it can be atomic: two copies of a proof arriving together must end with exactly one admitted. Anything but true is treated as a replay. expiresAt is always ahead of the validator's clock when add is called — the age check is repeated just before — so a store with real expiry can use it as a TTL as is.
  • nonce — { secret, windowMs? } turns on server-provided nonces (RFC 9449 §8): a proof without the current nonce is answered 401 with error="use_dpop_nonce", a DPoP-Nonce header and Cache-Control: no-store, and the client retries with it. Nonces are an HMAC of resource and the time window under secret, so instances sharing the secret need no shared state and two resources never mint each other's; the current and previous window (default 5 minutes each) are accepted. secret must be at least 32 bytes — it is all that makes a nonce unpredictable.

The transport enforces the rest, for any validator:

  • An identity whose claims.cnf is present without binding is refused with 401 and error="invalid_token" — a bound token is never admitted as a plain bearer, whatever the validator said. A binding other than "dpop" or "mtls" is a 500: the validator's bug, never a confirmation.
  • A dpop request is refused unless the identity says binding: "dpop"; a proof nobody checked is no proof.
  • The dpop scheme is opt-in (schemes: ["bearer", "dpop"]) and requires validate. The validator receives the DPoP header, unverified, as auth.proof, and the challenge gains DPoP realm="mcp", algs="…" beside the Bearer one.

mTLS-bound tokens (cnf["x5t#S256"]) have no verifier yet; a validator that checks the certificate itself returns binding: "mtls" and the transport admits it under Bearer, as RFC 8705 §3 has it.

#Opaque tokens

Use introspectionValidator() to ask the authorization server to check a token (RFC 7662). This works with opaque tokens and avoids local signature verification:

import { introspectionValidator } from "h3-mcp/oauth";

auth: {
  schemes: ["bearer"],
  validate: introspectionValidator({
    endpoint: "https://auth.example.com/oauth/introspect",
    audience: "https://example.com/mcp",
    clientId: process.env.OAUTH_CLIENT_ID!,
    clientSecret: process.env.OAUTH_CLIENT_SECRET!,
  }),
}

The verifier sends a form POST with token and token_type_hint=access_token. It uses clientId and clientSecret for HTTP Basic authentication. You can supply headers to use another authentication scheme.

The response must include active: true and an aud that matches a configured audience. A response without aud is rejected: RFC 7662 makes it optional, but MCP requires an audience check. The verifier also checks exp and nbf when present, and iss when both the options and response include it.

Successful checks are cached for cacheTtlMs (default one minute), but never past the token's exp. Set cacheTtlMs: 0 to disable caching. A revoked token may still be accepted until its cached result expires.

The cache holds at most cacheMax entries (default 1000). It uses SHA-256 hashes as keys, not the tokens themselves. Inactive responses are never cached.

#Your own verifier

You can use your own verifier. Apply the same checks, then return an AuthIdentity with the verified scopes. Note that aud is a string or an array of strings (RFC 7519 §4.1.3) and must equal your resource identifier exactly, as the built-in verifiers require — String.prototype.includes on a string aud is a substring search that admits a token issued for https://example.com/mcp-other:

auth: {
  schemes: ["bearer"],
  scopes: ["mcp:invoke"],
  validate: async (auth, event) => {
    const claims = await verifyJwt(auth.token); // signature, exp, iss
    if (!claims) return false;
    const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
    if (!audiences.includes("https://example.com/mcp")) return false; // exact match
    return { subject: claims.sub, scopes: claims.scope?.split(" ") ?? [], claims };
  },
}

Return the verified claims, or at least cnf. The binding rule that refuses a sender-constrained token presented as a plain bearer reads claims.cnf, and nothing else: a verifier that drops the claims admits a DPoP- or mTLS-bound token as a bearer, whatever the transport's rule says. If you cannot relay cnf, refuse any token that carries it.

Both built-in verifiers accept the bearer and dpop schemes and refuse api-key credentials: an API key is a shared secret with no issuer, signature, or audience to verify.

#What Auth Does Not Do

Important

Auth controls access to the endpoint and, with scopes, to individual operations. Your handlers must still check access to specific files, tenants, or other data. Use dynamic options to control which tools a caller can see:

defineMcpHandler((event) => ({
  name: "my-server",
  version: "1.0.0",
  auth: { validate: verifyToken },
  tools: toolsFor(event.context.tenant), // set by your own middleware, before this runs
}));

The options function runs before the auth check — it is what produces the auth config — so event.context.mcp.auth is undefined inside it. Branch on state an upstream middleware put on event.context (as tenant above), or verify the credential yourself there; do not branch on event.context.mcp.auth, which would always take the anonymous path. Inside a handler the identity is available.

Also note that on the legacy era a session id is not a credential replacement, and is bound to the credential that created it: send auth on every request, including the ones that carry Mcp-Session-Id, and a session presented with a different credential is answered as an unknown one.

Read more in Sessions.

h3-mcp  MCP servers, built on H3.