> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theauth.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Route reference

> Every HTTP route the theAuth handler serves, with its auth level and status codes. The same table backs all framework adapters.

Every framework adapter wraps one handler from core, `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`).

```typescript theme={"dark"}
import { createTheAuthHandler } from '@glinr/theauth/http';

const handler = createTheAuthHandler(theauth, { mcp, basePath: '/api/theauth' });
// handler: (request: Request) => Promise<Response>
```

## Routes

| Method | Path | Auth | Purpose |
| - | - | - | - |
| `GET` | `/agents` | admin | List agents (filters: userId, status, type) |
| `POST` | `/agents` | admin | Create an agent |
| `GET` | `/agents/:id` | admin | Get an agent |
| `PATCH` | `/agents/:id` | admin | Update an agent |
| `DELETE` | `/agents/:id` | admin | Revoke an agent |
| `POST` | `/agents/:id/rotate` | admin | Rotate an agent token |
| `POST` | `/authorize` | admin | Check whether an agent may perform an action (403 when denied) |
| `POST` | `/authorize/token` | agent | Check an action using the agent bearer token (401 bad token, 403 denied) |
| `POST` | `/delegations` | admin | Delegate permissions from one agent to another |
| `GET` | `/delegations/:id` | admin | List delegation chains of an agent (id is the agent id) |
| `DELETE` | `/delegations/:id` | admin | Revoke a delegation chain (id is the chain id) |
| `GET` | `/audit` | admin | Query audit entries |
| `GET` | `/audit/export` | admin | Export audit entries as JSON or CSV |
| `GET` | `/dashboard/stats` | admin | Dashboard aggregates |
| `GET` | `/dashboard/agents` | admin | Dashboard agent list (same as GET /agents) |
| `GET` | `/dashboard/audit` | admin | Dashboard audit list (same as GET /audit) |
| `GET` | `/.well-known/oauth-authorization-server` | public | OAuth authorization server metadata (RFC 8414) |
| `GET` | `/.well-known/oauth-protected-resource` | public | Protected resource metadata (RFC 9728) |
| `POST` | `/mcp/register` | public | Dynamic client registration (RFC 7591) |
| `GET` | `/mcp/authorize` | public | Authorization endpoint (redirects to login or the client) |
| `POST` | `/mcp/token` | client | Token endpoint |
| `POST` | `/mcp/revoke` | client | Token revocation (RFC 7009) |
| `POST` | `/mcp/introspect` | client | Token introspection (RFC 7662), 404 until `introspection.enabled` |
| `GET` | `/mcp/jwks` | public | Public signing keys (JWKS) |
| `POST` | `/explain` | admin | Explain an authorization decision without recording it |

Generated from `listRoutes()`. Call it yourself to render the table or build an OpenAPI document:

```typescript theme={"dark"}
import { listRoutes } from '@glinr/theauth/http';

for (const route of listRoutes({ routes: { explain: false } })) {
  console.log(route.method, route.path, route.auth);
}
```

### Auth levels

| Level | Meaning |
| - | - |
| `admin` | A signed-in user (cookie or `Authorization: Bearer <session token>`), or the principal your `authenticate` option returns. With the default session resolver a user only sees and changes their own agents, delegations and audit rows. |
| `agent` | An agent bearer token (`Authorization: Bearer ta_...`). |
| `client` | An OAuth client. Credentials travel in the request (client secret, `private_key_jwt` assertion). |
| `public` | No credentials. The route validates its own input. |

## Status codes

| Status | When |
| - | - |
| 200, 201, 204 | Success (create answers 201, revoke and delete answer 204) |
| 400 | Invalid JSON, failed validation, bad query value |
| 401 | No or invalid credentials. Agent identity failures (`invalid_token`, `agent_not_found`, `agent_revoked`, `agent_expired`) are 401 |
| 403 | Authenticated but not allowed: a denied authorization, a cross-owner filter, or a failed cross-site check (`CSRF_REJECTED`) |
| 404 | Unknown route, a resource you do not own (existence is not revealed), or an MCP route that is not mounted |
| 405 | Known path, wrong method (an `Allow` header lists the methods) |
| 413 | Body larger than `maxBodyBytes` |
| 429 | Rate limited (`Retry-After` is set) |

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:

| Option | Default | Purpose |
| - | - | - |
| `mcp` | none | MCP module. Without it the MCP and well-known routes answer 404. |
| `basePath` | `/api/theauth` | Mount prefix. |
| `routes.management` | `true` | Agents, delegations, audit, dashboard, authorize. |
| `routes.mcp` | `true` | MCP and well-known routes. Pass an object such as `{ revoke: false, jwks: false }` to mount a subset. Metadata lists only what is mounted. |
| `routes.plugins` | `true` | Fall through to plugin endpoints. |
| `routes.explain` | `true` | `POST /explain`. |
| `maxBodyBytes` | `1048576` | Request body limit. |
| `securityHeaders` | off | Response hardening headers. `true` for defaults or an options object. See [Security headers](#security-headers). |
| `cors` | off | Exact-origin allowlist. See [CORS](#cors). |
| `authenticate`, `allowUnauthenticated`, `trustedProxy`, `rateLimit`, `trustedOrigins`, `csrf` | | Same meaning as in [adapter security](/adapters). |

## Cache headers

Every response the handler produces carries `Cache-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. Pass `securityHeaders: true` (or an options object) to add:

| Header | Value | Sent on |
| - | - | - |
| `X-Content-Type-Options` | `nosniff` | every response |
| `Referrer-Policy` | `no-referrer` (tokens in magic-link and reset URLs never leak) | every response |
| `Cross-Origin-Resource-Policy` | `same-origin` | every response |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | https requests only |
| `X-Frame-Options` | `DENY` | HTML responses only |
| `Content-Security-Policy` | `default-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'` | HTML responses only |

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:

```typescript theme={"dark"}
import { securityHeaders, isSecureRequest } from '@glinr/theauth/http';

return new Response(html, {
  headers: {
    'Content-Type': 'text/html',
    ...securityHeaders({ html: true, secure: isSecureRequest(request, { trustedProxyCount: 1 }) }),
  },
});
```

## CORS

Off by default: no CORS header is sent anywhere. (Earlier versions sent `Access-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:

```typescript theme={"dark"}
createTheAuthHandler(theauth, {
  mcp,
  cors: { origins: ['https://app.example.com'] },
});
```

Rules, all enforced:

* Origins are matched exactly (scheme, host, port). An origin that is not listed gets no `Access-Control-Allow-Origin`; it is never reflected. `Vary: Origin` is always set when a group has an allowlist.
* `"*"` is accepted only for public documents. It cannot be combined with `credentials: true`, and cannot be used for `token`, `introspect`, `revoke` or `management`: the handler throws at startup.
* A preflight (`OPTIONS` with `Access-Control-Request-Method`) is answered before authentication. It lists the methods that path serves, `allowedHeaders` (default `Content-Type, Authorization, DPoP`) and `maxAge` (default 600 seconds). A disallowed origin or method gets a bare 204.
* `exposedHeaders` defaults to `DPoP-Nonce, WWW-Authenticate`.

| Group | Routes | Default once `origins` is set |
| - | - | - |
| `discovery` | both `/.well-known/*` documents, `/mcp/jwks` | on |
| `register` | `/mcp/register` | off |
| `token` | `/mcp/token` | off |
| `introspect` | `/mcp/introspect` | off |
| `revoke` | `/mcp/revoke` | off |
| `management` | agents, delegations, audit, dashboard, authorize/token, explain | off |

`/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.

```typescript theme={"dark"}
import { requireAgent } from '@glinr/theauth/http';

const result = await requireAgent(theauth, request, { action: 'read', resource: 'invoices' });
if (!result.ok) return result.response; // 401 or 403, body { error: { code, message } }
result.agent; // identity without the token
```

| Decision code | Status |
| - | - |
| `invalid_token`, `agent_not_found`, `agent_revoked`, `agent_expired` | 401 |
| every other denial (`no_permission`, `rate_limited`, `ip_not_allowed`, `approval_required`, ...) | 403 |

## Node bridge

Express, Fastify and NestJS use Node's `http` 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 `trustedProxy` set (`trustedProxyCount` or `trustedHeader`), 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 gets `403` 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.