> ## 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.

# Upgrading to 2.0

> Breaking changes in theAuth 2.0 for integrations built on 1.x, with what changed and what to do.

This page lists behavior changes in `@glinr/theauth` 2.0 that a working 1.x integration can see. Each section says what changed and what to change in your code.

## Typed decision codes

`authorize()`, `authorizeByToken()` and `policy.evaluate()` now return a machine-readable `code`, and `onViolation` receives `code` next to `type`. See [Errors](/errors#decision-codes) for the list.

What changed:

* `PolicyDecision` has a new required field `code`. Code that builds a `PolicyDecision` by hand with an object literal must add it. `makeDecision()` fills it in from `effect` when you leave it out.
* `AuthorizeResult.code` is optional on the type and always set by core.
* `onViolation.type` is now derived from the code, not from substrings of the reason text. The five `type` values are unchanged, but some denials classify differently. A reason that contained "rate", "ip", "time", "window" or "approval" by accident, such as an agent named `pirate-bot`, was reported as `rate_limited` and is now `permission_denied`.
* A `beforeAuthorize` block now reports `type: "permission_denied"` with `code: "hook_denied"`. Before, the type came from words in your hook's reason string.
* When a delegated permission fails with an approval problem, `authorize()` reports that failure instead of the agent's own denial. This now includes the case where the delegated permission requires approval and no approval id was given. Before, only a failed approval id was preferred.
* `classifyViolation()` is deprecated. It still works with its old logic.

What to do:

* Replace checks on `reason` text with checks on `result.code` or the `code` passed to `onViolation`.
* Replace `classifyViolation(reason)` with `violationTypeOf(code)`.
* If you route alerts on `type`, review the cases above.

## beforeAuthorize runs later, with more context, and covers every entry point

`hooks.beforeAuthorize` now runs after the agent is resolved and known to be active, and before permissions are evaluated. See [Lifecycle hooks](/hooks).

What changed:

* The hook no longer runs for an unknown agent id, an invalid token, or a revoked or expired agent. Those deny first, with their own codes. Before, the hook ran for every id, including ones that do not exist.
* The context gains `agent` (token omitted), `delegation` (root owner, path, links and inbound grants), `source`, `context` (ip and user agent), `dryRun` and `signal`. The old fields keep their names and meaning, so a hook that destructures `{ agentId, action, resource, arguments }` keeps working.
* The hook now also runs for `authorizeByToken()` and for `policy.evaluate()` with an agent subject. A `policy.evaluate()` call for a user-only subject does not run it. Hook verdicts are never cached.
* A hook can return `{ decision: "escalate" }`. `authorize()` then denies with code `approval_required`, and `policy.evaluate()` returns an escalate decision with `escalation.tier` set to `"hook"`. A valid `approvalId` passed to `policy.evaluate()` is spent, and evaluation then continues through the agent's normal permissions, so an approval never grants more than the agent already holds.
* A hook block now writes an audit row and returns its id as `auditId`. It used to return `auditId: ""` and write nothing.
* `authorizeByToken()` now honors delegated permissions and fires `afterAuthorize` and `onViolation`, matching `authorize()`. A delegated agent that was denied by token because its permissions came only from a delegation is now allowed.
* `afterAuthorize` and `onViolation` receive a `source` field.
* `DelegationHop` (internal lineage type) now carries the permissions, depth, limit and dates of each hop, so the `hops` stored in the parameters of `delegation.create` audit rows are larger.

What to do:

* If your hook relied on running for unknown agent ids (for example to count probes), move that logic to `onViolation`, which sees `agent_not_found`.
* If you call `authorizeByToken()` for delegated agents and relied on it denying them, review those agents' delegations.
* If your hook inspects the delegation chain, read it from `context.delegation` instead of loading it yourself.

## Hooks time out and fail closed

`beforeAuthorize` and `beforeAgentCreate` now run under a timeout (2000 ms by default), and failures deny. See [Hook safety](/hooks#hook-safety-timeout-and-fail-closed).

What changed:

* A `beforeAuthorize` hook that throws, returns a malformed verdict, or exceeds the timeout now denies the call with code `hook_error` or `hook_timeout`. Before, a throw propagated to the caller of `authorize()`.
* A `beforeAgentCreate` hook that throws or times out makes `agent.create()` throw. A timeout throws `HookTimeoutError`; a throw rethrows the original error as before.
* A blocked `agent.create()` now throws `HookBlockedError` (still an `Error`) instead of a plain `Error`, with the same message.

What to do:

* Set `hookSafety.onError: "throw"` to keep the old behavior of a throwing `beforeAuthorize` rejecting the call, or `"allow"` to fail open (every bypass is marked `fail_open` in the audit row).
* Raise `hookSafety.timeoutMs` (or `THEAUTH_HOOK_TIMEOUT_MS`) if your hook calls a slow service. Pass `context.signal` to `fetch()` so the request stops on timeout.

## New audit column: decision\_code

`theauth_audit_logs` gains a nullable `decision_code TEXT` column and an index on `(agent_id, decision_code)`. See [Database](/database#upgrading-the-audit-table).

* `createTables()` adds both on startup. Old rows keep a NULL code, and `audit.query()` reports them with `code` undefined.
* If you run with `database.skipMigrations: true`, apply the `decision_code` migration (generate it with `theauth db sql --dialect <yours>`, see [Schema ledger and skipMigrations](#schema-ledger-and-skipmigrations)) before deploying. Without the column, audit reads and writes fail.
* The column is not part of the audit hash, so existing chains keep verifying.
* `audit.query()` accepts `codes: DecisionCode[]`, and entries carry `code`.

## delegation docs corrected

The delegation page said a delegating agent could not re-delegate permissions it received, and that `maxDepth` was not inherited. Neither matched the code. Re-delegation of received permissions is allowed (within what the agent holds), and `maxDepth` is inherited: a hop is checked against the smallest `maxDepth` among the delegations into its parent. No behavior changed.

## authorize() now enforces expiresAt

`authorize(agentId, ...)` used to check only the stored `status`, so an agent past its `expiresAt` kept passing until something flipped its status to `expired`. It now compares `expiresAt` to the clock and denies with code `agent_expired`, writing the normal denied audit row. `authorizeByToken()`, `agent.validateToken()` and `policy.evaluate()` already did this. If you relied on an elapsed agent still passing, extend its `expiresAt` through `agent.update()`.

## A revoked or expired delegator no longer lends permissions

Delegated permissions, lineage and the chain handed to `beforeAuthorize` used to check only the delegation link, never the status or `expiresAt` of the agent that created it. An intermediate agent that was revoked, suspended or expired, without its links being revoked, still lent permissions to the agents below it. Now a link counts only while its delegator agent is `active` and unexpired and, if it was itself delegated to, at least one of its own inbound links traces back to a live delegator. Chains that passed before can now fail with the usual permission-denied code. No new decision code was added. To restore access, re-activate or extend the delegator, or delegate again from a live agent. The same check now applies to token vault use (`getAccessToken` is refused when the consent runs through a revoked or expired intermediate agent) and to `loadGrants`/`analyzeUsage` in policy-shrink. One carve-out: an active delegator whose inbound links are all dead keeps the onward links that grant only permissions it holds directly.

## Unified HTTP handler and adapter options

Every framework adapter now wraps one core handler, `createTheAuthHandler` from `@glinr/theauth/http`, so all of them serve the same routes with the same status codes. See the [route reference](/adapters/routes).

What changed:

* Each adapter's options type is now the core `TheAuthHandlerOptions` (`mcp`, `basePath`, `routes`, `maxBodyBytes`, plus the shared security options). Existing option names keep working.
* Every adapter now serves `POST /mcp/revoke`, `GET /mcp/jwks` and `POST /explain`. Request bodies over 1 MiB get 413.
* Deprecated aliases were removed: `authExpress`, `authFastify`, `AuthFastifyOptions`, `authHono`, `authNextjs`, `AuthNextjsOptions`, `AuthNextjsHandlers`, `authNuxt`, `AuthNuxtOptions`, `authAstro`, `AuthAstroOptions`, `AuthAstroHandlers`, `authSolidStart`, `AuthSolidStartOptions`, `AuthSolidStartHandlers`, `authSvelteKit`, `AuthSvelteKitOptions`, `AuthSvelteKitHandlers`, `authTanStack`, `AuthTanStackOptions`, `AuthTanStackHandlers`, `AuthModule`, `AuthModuleOptions`, `AuthNestjsOptions`, `authMiddleware` and `buildAuthRouter`.

What to do:

* Rename each removed name to its `theAuth` form, for example `authHono` to `theAuthHono` and `AuthNextjsOptions` to `TheAuthNextjsOptions`. For NestJS, use `TheAuthModule`, `TheAuthModuleOptions`, `TheAuthNestjsOptions` and `theAuthMiddleware`.
* Check any route you proxy by hand against the [route reference](/adapters/routes), since the adapter now serves more paths.

## 401 for bad agents, 403 for other denials

`POST /authorize/token` changed its status codes, and the denial body for `requireScope` changed shape.

What changed:

* The endpoint answers 401 for `invalid_token`, `agent_not_found`, `agent_revoked` and `agent_expired`, and 403 for every other denial. Before, both groups answered 403.
* A known path with the wrong method answers 405 with an `Allow` header. Unexpected errors answer a fixed 500 message, not the exception text.
* `requireScope` and `requireAgent` denial bodies are `{ error: { code, message } }`, where `code` is the decision code (for example `no_permission`). They used to be `UNAUTHORIZED` or `FORBIDDEN`.

What to do:

* If a client, retry rule or alert treats 403 as "agent is revoked", treat 401 as well.
* Replace checks on the `UNAUTHORIZED` or `FORBIDDEN` body codes with decision codes. See [Errors](/errors#decision-codes).

## Audit export refuses another user's userId

`GET /audit/export?userId=<other user>` by a caller limited to their own resources now answers 403 with `{ error: { code, message } }`. Before, it returned the caller's rows without saying so.

What changed:

* A `userId` that is not the caller's is refused. The `audit` and `agents` list routes already did this.
* `userId` set to the caller's own id, or left out, still returns 200.

What to do:

* If a script relies on the silent fallback, drop the `userId` parameter or send the caller's own id.

## Client IP comes from the socket peer

The adapters no longer take the client IP from the framework's `req.ip` or from forwarded headers by default.

What changed:

* Without `trustedProxy`, rate limits, `ipAllowlist` and `clientIp` use the socket peer address recorded when the request enters the handler. Forwarded headers are ignored.
* With `trustedProxy` set, the IP comes from the proxy headers, and it is null when they are absent.
* Fastify `requireScope` matches `ipAllowlist` against the socket peer by default.

What to do:

* Direct deployments (no proxy in front) need no change, and per-IP limits now apply.
* Behind a load balancer or reverse proxy, set `trustedProxy` so limits and allowlists see the real client IP.

## Schema ledger and skipMigrations

theAuth now records every schema change in a ledger table, `theauth_schema_migrations`, and runs pending migrations at startup. `auth.schema.status()` reports the state. The 1.x tables are migration `0000`. Later migrations add the audit and policy columns (`0001`, `0002`, `0004`), Postgres epoch timestamps (`0003`), the audit `event` column (`0005`), the MCP stores (`0006`), the MCP revocation list (`0007`) and client metadata documents (`0008`).

What changed:

* With `database.skipMigrations: true`, `createTheAuth` throws `SchemaOutdatedError` and lists the pending ids. Before, the failure showed up on the first write.
* An applied migration whose SQL no longer matches its checksum stops startup with `SchemaChecksumError`.
* Databases with 1.x tables but no ledger are adopted: the tables are stamped as applied, only missing migrations run, and rows are untouched.

What to do:

* Run `theauth db status` to list pending migrations.
* Run `theauth db up` in your deploy step, or with `skipMigrations: true`, generate the SQL with `theauth db sql --dialect <yours>` and apply it with your own tooling before deploying. On a 1.x database, add `--from 0002`. Each generated file ends by recording itself in the ledger.

## NestJS router is now middleware

`buildTheAuthRouter` no longer returns an Express `Router`.

What changed:

* `buildTheAuthRouter(auth, mcp?, options?)` returns a middleware function. `basePath` defaults to where you mount it.
* The NestJS module and middleware serve every core route, as the other adapters do.

What to do:

* Mount the result with `app.use(path, buildTheAuthRouter(...))`, as before.
* Remove any code that calls `Router` methods on the result, such as `.get()` or `.use()`.

## Audit API v2 and the event column

`audit.query()` and `GET /audit` gain an explicit order, cursor paging and an `event` field. See [Audit trail](/audit-trail).

* The default order stays newest first, so existing calls return rows in the same order. Pass `order: "asc"` for oldest first. `actions` now filters before `limit` instead of after it.
* `AuditEntry` has a new `event` field (`null` when unknown), stored in a nullable column added by migration `0005`. It is not part of the audit hash, so existing chains keep verifying. Code that builds `AuditEntry` values by hand must set it.
* Delegation rows are now `action: "delegate"` with `event: "delegation.create"` or `"delegation.revoke"`. Older rows keep the old `action`. Match on `event` to see both.
* The `query_audit` MCP tool drops `offset` and returns `{ returned, nextCursor, entries }`.
* With `skipMigrations: true`, apply `0005` first: `theauth db sql --from 0005`.

## MCP stores in the database, and the requireScopes result

`createMcpModule({ auth, config, resolveUserId })` now keeps clients, codes, tokens and refresh families in the theAuth database. See [MCP OAuth 2.1](/mcp).

* Migration `0006` adds columns to `theauth_oauth_clients` and `theauth_oauth_access_tokens`. Configs with an `mcp` key now create the OAuth tables.
* The 401 body from `requireScopes` and the helper functions is now `{ jsonrpc, error: { code: -32001, message, data: { code } }, id: null }`. The code was `-32000`, and the `www-authenticate` key in the body is gone: read the header.
* `requireScopes` failures return `status`, `error` and `rejected` as well as `response`. Code that matched `{ authorized: false; response }` exactly needs the `RequireScopesResult` type.
* An expired token now reports `TOKEN_EXPIRED`. Before, it reported `INVALID_TOKEN`.
* The old form with seven store functions still works, and a store function passed next to `auth` wins over the database one.

## Agent tokens start with ta\_

New agent tokens start with `ta_` instead of `kv_`. Tokens already issued keep validating, and nothing is rewritten.

* `agent.rotate` issues a `ta_` token for a legacy agent and retires the old one.
* A bearer string with neither prefix is rejected before any database read.
* If a proxy, log scrubber or secret scanner matches agent tokens by prefix, accept both `ta_` and `kv_`. See [Token format](/agents#token-format) for a scanning regex.

## SQLite file URLs throw

`provider: "sqlite"` runs on sql.js, which keeps the database in memory and does not reliably write the file. It now throws `THEAUTH_SQLITE_NOT_PERSISTENT` at startup for any `url` other than `:memory:`. Before, it loaded the file and lost writes on restart.

* For a database file, use `provider: "sqlite-native"` (`npm install better-sqlite3`) or Postgres.
* For a throwaway database, pass `url: ":memory:"`.
* Loading an existing file into sql.js is gone. Open the same file with `sqlite-native`.

## Postgres timestamps are epoch seconds

Postgres now works across the whole instance. Before, the first write and every JSON read failed, so a working 1.x setup was not using Postgres through core.

* Timestamp columns are `BIGINT` epoch seconds, as on SQLite. Migration `0003` converts `TIMESTAMPTZ` columns in place. Run `theauth db up`, or let the instance migrate on start.
* If you query the tables yourself, wrap timestamp columns in `to_timestamp(...)`.
* `provider: "mysql"` throws `THEAUTH_MYSQL_UNSUPPORTED` unless you pass `experimental: true`.
* `theauth_refresh_tokens.used` is now `BIGINT` on Postgres and MySQL, so the refresh reuse grace window works. SQLite is unchanged.

## Cookies are Secure by default, PBKDF2 is stronger

The `Secure` cookie default no longer reads `NODE_ENV`. It used to be off when `NODE_ENV` was unset, which is the normal state on Workers, Deno and Bun.

* Cookies are now `Secure` unless you pass `secure: false`, or pass a `request` for plain `http://localhost`, `127.0.0.1` or `[::1]`. For plain http anywhere else, set `cookieOptions.secure: false`.
* Invalid `__Host-` and `__Secure-` combinations now throw, as do `Partitioned` or `SameSite=None` without `Secure`.
* PBKDF2 defaults to 600000 iterations (was 100000), about 100 ms per hash. Set `pbkdf2Iterations` or `THEAUTH_PBKDF2_ITERATIONS` to lower it on a CPU limited plan. Old hashes verify with their own count and are upgraded at sign in.

## SAML verification is strict

If you use `sso.saml`, upgrade. The old verifier could be bypassed, and `cert` was imported as a bare public key. See [SSO](/auth/sso).

* `idpEntityId` is now required. Logins fail with `SAML_CONFIG_ERROR` until you set it.
* `wantAuthnResponseSigned: false` is a config error. Sign the Response or the Assertion.
* `cert` takes an X.509 certificate or a public key PEM. RSA keys under 2048 bits are rejected. SHA-1 signatures need `allowLegacySha1: true`.
* `getSamlAuthUrl` now sends an opaque single use `RelayState`, and the ACS route checks `InResponseTo` with it. Your own relay state comes back as `relayState`. Call `handleSamlResponse` with `{ relayState }` or `{ expectedRequestId }`.
* IdP initiated responses need `allowIdpInitiated: true`. Encrypted assertions are still unsupported.

## nextjs-auth requires a secret

`@glinr/theauth-nextjs-auth` 0.2.0 and earlier trusted an unsigned session cache cookie, so anyone could write one and be treated as any user. Upgrade.

* `createAuthConfig` now requires `secret`, at least 32 bytes, for example from `openssl rand -base64 48`. It throws at construction without one.
* The cache cookie is signed and bound to the session token. Old unsigned cookies are ignored, at the cost of one backend call.
* Cookies are `Secure` by default and no longer depend on `NODE_ENV`. Pass `secure: false` for http localhost only.
* Before upgrading, `cookieCacheMaxAgeMs: 0` removes the exposure.

## MCP tokens are bound to their delegation

Revoking a delegation, an agent, a user's sessions or a token now reaches MCP access tokens. See [MCP token revocation](/mcp-revocation).

* With `auth`, resource server validation defaults to `live` mode, and `jtiDenylist` defaults to a shared database store. Migration `0007` adds the `theauth_mcp_revocations` table and delegation indexes.
* Exchanged tokens carry a signed `theauth_dlg` claim and never outlive their links, agents or parent token. Exchange refuses a chain with no live link between two agent actors.
* Before: a revoked link left tokens valid until `exp`. After: the next request fails on every instance sharing the database.
* Action: run `theauth db up`. Without `auth` nothing changes, and the mode is `offline`.
* Tokens minted before the upgrade carry no binding and age out within `accessTokenTtl`.

## Client metadata documents need a consent page

`clientIdMetadataDocuments: { enabled: true }` now makes `createMcpModule` throw at startup unless `consentPage` is set. Any host can publish a client document, so every authorization has to be approved by a user. See [Client ID metadata documents](/mcp-client-id-metadata).

* Before: an early build of the feature could issue codes with no consent screen. After: startup fails, and a stored metadata client that reaches authorize with no `consentPage` gets `CONSENT_REQUIRED`.
* Action: set `consentPage` and call `mcp.describeClient()` on that page to show the verified host and every warning.
* Migration `0008` adds the document snapshot columns and the `theauth_mcp_client_blocks` table.

## Dynamic registration can be turned off

`registration: { enabled: false }` removes `POST /mcp/register` (404), drops `registration_endpoint` from the metadata, and makes `registerClient` return `REGISTRATION_DISABLED`. The default is unchanged. Clients registered earlier keep working. Turn it off when you serve only pre-registered and metadata document clients.

## Observability is opt in

`observability.onDecision(event)` receives one typed event per `authorize`, `authorizeByToken` and `policy.evaluate` decision, and `logger` receives hook timeout and listener warnings. Nothing changes unless you set them. Events carry no tokens or raw arguments, and `observability.redactResource` rewrites the resource string. The dashboard audit page now sends `codes` to your audit endpoint and calls `/api/explain`, so mount the HTTP handler at `/api`. See [Observability](/observability).


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