alepha@docs:~/framework/docs/guides/security$
cat 8-oauth-server.md | pretty
6 min read
Last commit:

#OAuth Authorization Server

A realm can act as an OAuth 2.1 authorization server, which is what an MCP client such as claude.ai or Claude Code signs in through. Turn it on with the realm's oauth feature:

typescript
1import { $realm } from "alepha/api/users";2 3class App {4  realm = $realm({ features: { oauth: true } });5}

That mounts the discovery documents (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource, /.well-known/openid-configuration), dynamic client registration (POST /oauth/register), the authorization endpoint with its consent screen (/oauth/authorize), the token endpoint (POST /oauth/token, with PKCE S256 and refresh), and the device grant (/oauth/device_authorization, /oauth/device). It also turns on the MCP transport's requireAuth, so an unauthenticated call to the MCP endpoint gets the 401 challenge a client starts its discovery from.

The oauthOptions atom configures it: resource (the protected endpoint's path, /mcp by default), loginPath, productName and connectionsPath for the consent screen, and scopes, which declares what each scope means and which permissions a token granted it may use.

#Tokens Are Bound to the Resource

An access token is bound to the resource it was issued for (RFC 8707), as MCP authorization requires: a server must refuse a token that was not issued for it.

  • The audience. A token minted through /oauth/token carries aud: [realm, resource], where resource is the canonical URL of the protected endpoint (for example https://lore.example.com/mcp, the same URL the protected-resource metadata publishes). Every verifier checking for the realm still passes, since the realm is still in the audience.
  • One resource. The server protects a single resource, oauthOptions.resource. A grant that names no resource is bound to it anyway; one that names it is accepted after normalisation (lowercase scheme and host, default port dropped, no trailing slash); one that names anything else is refused with invalid_target, at /oauth/authorize (as a redirect to the client), at /oauth/token and at /oauth/device_authorization.
  • Across refresh. The resource is stored on the session (sessions.resource) and signed back into every refreshed token, at /oauth/token and at /_auth/refresh alike.
  • At the endpoint. The MCP transport compares a token's resource with its own canonical URL and answers a mismatch with the same 401 and resource_metadata challenge as a missing token, so the client authorizes again for the right resource.

A token bound to no resource passes: a first-party login, and OAuth sessions created before binding existed, which keep working until they age out. A bound token still reaches the rest of the application's API, which checks the realm, not the resource: a CLI signed in through the device grant is bound to the MCP endpoint and keeps calling everything else. Applications that own the sessions table need the migration that adds its resource column.

#How Clients Register

MCP 2026-07-28 orders the ways a client gets a client_id: pre-registration, then a Client ID Metadata Document, then Dynamic Client Registration (deprecated, still served).

#Client ID Metadata Documents

A client may use an https URL as its client_id, pointing at a JSON document that describes it. The server advertises it (client_id_metadata_document_supported: true in both discovery documents), and the first time the client authorizes, fetches the document and registers the client from it, as a public client with source: "cimd". Consent, Connected apps and revocation then treat it like any other client; the consent screen shows the host the document was published by, and Connected apps names it Client Name (host).

json
1{2  "client_id": "https://app.example.com/oauth/client.json",3  "client_name": "Example App",4  "redirect_uris": ["https://app.example.com/callback"],5  "application_type": "web"6}
  • Validated. client_id must equal the URL it was fetched from, client_name and redirect_uris are required, the redirect URIs follow the application_type rules below (no wildcard; a document that names no application_type, as native CLI clients often publish, takes the general rule, so http://localhost/callback is accepted and matches any port at authorize), and a token_endpoint_auth_method other than none is refused. At authorize, the redirect must match the document.
  • Fetched carefully. https only, the default port only, and never a host that is an IP literal, localhost, or a .localhost, .local or .internal name (workerd resolves no DNS, so the host name is what is checked). No redirect is followed, the fetch times out after 5 s, and the body must be JSON of at most 5 KiB.
  • Cached on the row. The response's max-age, clamped to between 5 minutes and 24 hours, decides when the document is fetched again. A refetch that fails falls back to the stored copy for at most 24 hours past its expiry.
  • On by default. Dynamic registration is already open to anyone, so accepting a self-hosted document widens no trust. oauthOptions.clientIdMetadataDocuments: false turns it off: a URL client id is then an ordinary lookup.

A client id is up to 512 characters, in oauth_clients.clientId, sessions.clientId and the request schemas: a longer URL is an unknown client.

#Dynamic Client Registration and application_type

POST /oauth/register (RFC 7591) accepts application_type (SEP-837), stores it and applies its redirect rules:

application_type Redirect URIs accepted
web https only
native https, http to the loopback (any port at authorize), and private-use schemes such as com.example.app:/cb or myapp://cb (never javascript:, data:, file: and the like)
absent https, or http to the loopback: the rule from before the field, so older native clients keep registering

#Challenges and Step-Up

When the MCP transport refuses a request, its WWW-Authenticate challenge tells the client what to do next. The transport stays OAuth-agnostic: it asks through the mcp:auth:challenge:before hook, which the realm answers.

  • No token, or one for another resource: 401 with Bearer resource_metadata="…", scope="…". The scope lists every declared scope that is not an identity scope (permissions: []), or mcp when the application declares none.
  • A token missing a scope: 403 with Bearer error="insufficient_scope", scope="…", resource_metadata="…", error_description="…", decided before routing so a progress stream can still answer it. The client re-authorizes with the scopes named, which are the grant's own scopes plus those granting what the tool's $secure requires. It is sent only when re-authorizing can help: the token is narrowed by an OAuth grant, the user's roles allow the permission, and a declared scope grants it. Otherwise the call proceeds and the tool's $secure refuses it as a tool error, as before.

Access tokens carry the grant's scope ids as the standard scope claim (RFC 9068), which is how the challenge knows them without reading the session. Both metadata documents list the declared scopes in scopes_supported.

Every authorization response redirect, a code or an error, carries iss (RFC 9207), and the metadata advertises authorization_response_iss_parameter_supported: true: a 2026-07-28 client checks it against the issuer it discovered.