Skip to main content
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 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. 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. 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.
  • 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) 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. 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, 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.

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.
  • 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.
  • 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 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.
  • 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.
  • 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.
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.
  • 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.
Last modified on October 18, 2026