createTheAuthHandler in @glinr/theauth/http, so every adapter serves the same routes with the same status codes. Paths are relative to the mount path (default /api/theauth).
Routes
Generated from
listRoutes(). Call it yourself to render the table or build an OpenAPI document:
Auth levels
Status codes
Management errors use
{ "error": { "code", "message" } }. MCP endpoints use the OAuth shape { "error", "error_description" }.
Options
createTheAuthHandler(theauth, options) takes the shared security options plus:
Cache headers
Every response the handler produces carriesCache-Control: no-store and Pragma: no-cache, because auth responses hold credentials or session state: tokens, introspection results, authorize redirects, errors from guarded routes and anything that sets a cookie. The default is applied in one place; a route that sets its own Cache-Control keeps it (GET /mcp/jwks stays public, max-age=300). A 404 for a path the handler does not own is left alone.
Security headers
Off by default. PasssecurityHeaders: true (or an options object) to add:
HSTS is sent when the request URL is https, or when
trustedProxy is configured and the proxy reports X-Forwarded-Proto: https. Without trustedProxy the forwarded header is ignored, so a client cannot trigger it. The core routes return JSON, so frame and CSP headers matter for HTML that plugins return. A header the route already set is never overwritten. Each header takes an override or false (hsts, referrerPolicy, frameOptions, contentSecurityPolicy, crossOriginResourcePolicy).
For responses you build yourself:
CORS
Off by default: no CORS header is sent anywhere. (Earlier versions sentAccess-Control-Allow-Origin: * on every MCP route, token endpoint included.) Browser based MCP clients need the discovery documents, so list the origins that may read them:
- Origins are matched exactly (scheme, host, port). An origin that is not listed gets no
Access-Control-Allow-Origin; it is never reflected.Vary: Originis always set when a group has an allowlist. "*"is accepted only for public documents. It cannot be combined withcredentials: true, and cannot be used fortoken,introspect,revokeormanagement: the handler throws at startup.- A preflight (
OPTIONSwithAccess-Control-Request-Method) is answered before authentication. It lists the methods that path serves,allowedHeaders(defaultContent-Type, Authorization, DPoP) andmaxAge(default 600 seconds). A disallowed origin or method gets a bare 204. exposedHeadersdefaults toDPoP-Nonce, WWW-Authenticate.
/mcp/authorize is a browser navigation and never gets CORS. Opt a group in with groups: { token: true } (uses origins) or groups: { token: ['https://spa.example.com'] } (its own list). Token, introspection and revoke are client credential endpoints for servers; enable them for a browser only for a public client you control.
At startup the handler logs a warning through the observability logger (console when none is set) when allowUnauthenticated is on, or when CORS allows "*" or opens a credential carrying group.
Guarding your own routes
requireAgent authenticates an agent token and checks one action. It never compares reason strings, it branches on the decision code.
Node bridge
Express, Fastify and NestJS use Node’shttp objects. toWebRequest(req, { trustProxy }) and sendWebResponse(res, response) convert both ways, so those adapters are a few lines around the same handler.
Client IP and peer address
The client IP feeds per-IP rate limits,ipAllowlist permissions and audit rows. It is resolved in one place:
- With
trustedProxyset (trustedProxyCountortrustedHeader), the proxy headers decide, exactly as before. - Without it, forwarded headers are ignored and the socket’s remote address is used.
toWebRequest records the socket address on the Request out of band (never in a header, so a client cannot spoof it). Read it with peerAddressOf(request) from @glinr/theauth/http. Express, Fastify and NestJS therefore have a client IP on a direct deployment. Web-standard frameworks (Hono, Next.js and similar) never go through toWebRequest, so they still need trustedProxy to know the IP. requireAgent uses the same resolver when clientIp is not given; pass trustedProxy to it when you run behind a proxy.
Owner filters
A caller limited to their own resources gets403 with { error: { code: "FORBIDDEN", message } } when userId names someone else, on GET /audit/export, GET /audit, GET /agents and the dashboard lists. The filter is never silently dropped.