QUESTPIE
AgentsMcp oauth

Discovery and tokens

The three documents a client reads to find your authorization server, the one parameter that decides whether you get a usable token, and the checks the app runs before it trusts one.

View markdown

Three documents describe the auth layer. The core module mounts all three as ordinary routes, so they exist in every app.

DocumentPathWhat it answers
Protected resource metadata/.well-known/oauth-protected-resourcewhich server guards the MCP endpoint
Authorization server metadata/.well-known/oauth-authorization-serverwhere to register, authorize and get a token
Key set/jwksthe public keys a token is signed with

Where they mount

They sit under your handler's base path, not the site root. The starters mount the handler at /api, so the real path is /api/.well-known/oauth-protected-resource. You never have to work this out. The 401 names the exact URL.

The 401 challenge

A request to the MCP endpoint without a verified caller answers 401. The body is empty. One header does the work.

WWW-Authenticate: Bearer resource_metadata="https://your-app/api/.well-known/oauth-protected-resource"

The origin comes from app.url in your config, not from the request host. Any path on app.url is stripped. The path comes from the endpoint the client actually called, so a handler mounted under /api advertises a URL under /api.

A CORS preflight is answered before this check, so OPTIONS never gets a challenge.

The endpoints a client calls

These live under the better-auth base path, /api/auth by default.

EndpointPurpose
/oauth2/registerdynamic client registration, no session needed
/oauth2/authorizestart the flow, carries the PKCE challenge
/oauth2/consentthe decision the consent screen posts
/oauth2/tokenswap the code for an access token
/oauth2/revokehand back a refresh token or a consent

Registration creates a public client. It has no secret. The provider rejects a registration that asks to skip PKCE, and it accepts only the S256 challenge method.

Ask for a resource or you get the wrong token

The token request must carry resource=<your MCP endpoint URL>. That parameter is what makes the provider mint a signed JWT with your endpoint in aud.

Leave it out and you get an opaque token instead. The MCP route cannot verify an opaque token, so it answers 401 and the client loops. A compliant MCP client sets resource for you. Set it yourself if you drive the flow by hand.

The expected value is your app.url plus /api/mcp. That is fixed. It does not follow a handler mounted somewhere other than /api.

`APP_URL` and `app.url` must agree

The provider reads QUESTPIE_APP_URL, then APP_URL, then falls back to http://localhost:3000. Verification reads app.url from your config. The two are compared as exact strings, so a mismatch rejects every token.

What the token carries

ClaimValue
subthe user id the app loads the real user from
audyour MCP endpoint URL
scopethe approved scopes, space separated
azpthe id of the client that asked
issyour app's auth issuer
expone hour after issue

What the app checks

Verification is local. There is no database round trip on the hot path.

  • The signature must match a key published at /jwks.
  • The algorithm must be EdDSA. Any other algorithm is rejected before the signature is read.
  • aud must be your MCP endpoint. A token minted for another resource cannot be replayed here.
  • iss and exp must both hold.
  • sub must still resolve to a user.

Pass all five and the request carries an oauth principal: the real user plus the approved scopes.

The token is not MCP-only

Verification runs on every request the handler serves. A valid token authenticates any route in your app as that user. Only MCP tools apply the scope gate, so other routes see the user and their .access() rules alone.

A bad token never elevates

A malformed, expired, wrong-audience, wrong-issuer or unsigned token resolves to no principal at all. The request then falls through to the normal session path, finds nothing, and the MCP route answers 401. There is no path where a token failure produces more access than no token.

System mode is set only by a trusted transport, never derived from a token. Requests already running in system mode skip token verification entirely, so a bearer can neither raise nor lower them.

When no provider is configured

mcpModule on its own still mounts the endpoint. Without an OAuth provider there is simply nothing to discover.

SituationWhat the documents answer
No auth configuredall three answer 501
Auth without the OAuth providerthe server metadata answers 501

They answer 501 rather than 404, so a missing provider looks different from a broken route. The MCP endpoint keeps answering 401 in both cases. A first-party admin session still works, and so does a trusted local stdio worker, which uses no OAuth at all.

Changing the defaults

The provider ships as a better-auth plugin in oauthModule. Auth configs merge and plugins dedupe by id, so your own oauthProvider() in config/auth.ts replaces the shipped one. That is how you move the login page, move the consent page, or change the one-hour token lifetime.

src/questpie/server/config/auth.ts
import { oauthProvider } from "@better-auth/oauth-provider";
import { authConfig } from "questpie/app";

export default authConfig({
	plugins: [
		oauthProvider({
			loginPage: "/sign-in",
			consentPage: "/consent",
			accessTokenExpiresIn: 900,
			validAudiences: ["https://your-app/api/mcp"],
			allowDynamicClientRegistration: true,
			allowUnauthenticatedClientRegistration: true,
		}),
	],
});

Your plugin replaces every option on the shipped one, so the last four lines are not optional. Registration is off by default in the library, and an MCP client registers itself before anyone signs in. Set the audience to your app.url plus /api/mcp or tokens stop verifying.

The derived scope catalog is merged in afterwards either way. That part you do not restate.

Next

Scopes covers what a token is allowed to carry, and how to change what each operation asks for.

On this page