#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:
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/tokencarriesaud: [realm, resource], whereresourceis the canonical URL of the protected endpoint (for examplehttps://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 noresourceis 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 withinvalid_target, at/oauth/authorize(as a redirect to the client), at/oauth/tokenand at/oauth/device_authorization. - Across refresh. The resource is stored on the session (
sessions.resource) and signed back into every refreshed token, at/oauth/tokenand at/_auth/refreshalike. - At the endpoint. The MCP transport compares a token's resource with its own canonical URL and answers a mismatch with the same
401andresource_metadatachallenge 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).
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_idmust equal the URL it was fetched from,client_nameandredirect_urisare required, the redirect URIs follow theapplication_typerules below (no wildcard; a document that names noapplication_type, as native CLI clients often publish, takes the general rule, sohttp://localhost/callbackis accepted and matches any port at authorize), and atoken_endpoint_auth_methodother thannoneis 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,.localor.internalname (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: falseturns 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:
401withBearer resource_metadata="…", scope="…". Thescopelists every declared scope that is not an identity scope (permissions: []), ormcpwhen the application declares none. - A token missing a scope:
403withBearer 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$securerequires. 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$securerefuses 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.