agentleFS
glinckerglincker/theauth/docs/llms-full.txt

theauth

llms.txt20 starsChanged 26 days agoMITView on GitHub

Source: https://docs.theauth.dev/ Auth for AI agents and for the humans behind them. theAuth gives every agent its own identity, checks permissions at call time and writes an audit row for every decision. It also ships human auth: email and password, magic link, passkeys, TOTP and more, plus 48 OAuth providers. Use either half alone, or both. Runs on Node, edge, Workers, Deno and Bun. Devices and robots use the same model as agents. A sensor, gateway or machine is a…

  • Reads credentials
  • Installs packages
  • Sends data out

We split this llms-full.txt into the rules it gives an agent

20 rules. About 0 are common, 6 are distinctive, and 14 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈6

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 14

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every authorize() writes agent, user, resource, action, result, duration.

    line 168
  2. Distinctive:

    If you reach for password reset, email verification, or social sign-in on an agent, you want a user, not an agent.

    line 235
  3. Very specific:

    When agents.auditAll is on (the default), every authorize() and authorizeByToken() call against a known agent writes one row:

    line 281
  4. Very specific:

    Every agent can have a score from 0 to 100, computed from its audit log when you call theauth.trust.computeScore(agentId) (it is not …

    line 311
  5. Distinctive:

    After you install and start the server:

    line 397
  6. Very specific:

    Run it with npx tsx agent.ts.

    line 523
  7. Very specific:

    Run it with npx tsx human.ts.

    line 609
  8. Very specific:

    Prefer to keep Clerk, Auth.js, better-auth or your own login?

    line 632
  9. Very specific:

    Run npx wrangler d1 execute theauth --file=./theauth-schema.sql to apply the schema yourself (and set skipMigrations: true inside …

    line 781
  10. Distinctive:

    Start a local server with a demo user, a delegation and a policy check.

    line 832
  11. Distinctive:

    Every call lands in the audit log.

    line 853
  12. Very specific:

    When the dashboard build is present, the walkthrough prints a dashboard URL for browsing agents, delegations and audit entries.

    line 864
  13. Distinctive:

    All product names and logos are trademarks of their respective owners and appear here only to show compatibility.

    line 881
  14. Very specific:

    All names, logos and trademarks are the property of their respective owners and are shown only to identify compatibility.

    line 1009
  15. Very specific:

    If your provider speaks OIDC, genericOIDC covers it with a discovery URL and a client ID.

    line 1013
  16. Very specific:

    Schema ledger: Every schema change is recorded in theauth_schema_migrations, with theauth db status | up | sql.

    line 1054
  17. Very specific:

    No by default. theAuth runs its own migrations on first boot.

    line 1351
  18. Very specific:

    Not directly. theAuth issues its own cookie for sessions created via its plugins.

    line 1354
  19. Distinctive:

    Run your own authorization server for MCP tools.

    line 1371
  20. Very specific:

    When auth is omitted, theauth.auth.resolveUser() always returns null (manual user management mode).

    line 1545

≈ “Common” is an estimate: at least 1 in 5 of the 3,225 llms.txt files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
llms-full.txt
# theAuth documentation

# theAuth

Source: https://docs.theauth.dev/

**Auth for AI agents and for the humans behind them.** theAuth gives every agent its own identity, checks permissions at call time and writes an audit row for every decision. It also ships human auth: email and password, magic link, passkeys, TOTP and more, plus 48 OAuth providers. Use either half alone, or both. Runs on Node, edge, Workers, Deno and Bun.

<Note>Building in Go? See the [theAuth Go library docs](/go/getting-started/overview).</Note>

## Pick your track

<CardGroup cols={2}>
  <Card title="Add agent auth" icon="robot" href="/quickstart#track-a-add-agent-auth">
    Give agents an identity, scoped permissions, delegation and an audit trail. About five minutes.
  </Card>
  <Card title="Add human auth" icon="user" href="/quickstart#track-b-add-human-auth">
    Sign-up, sign-in, verification and reset with email and password, then add passkeys, OAuth and more. About five minutes.
  </Card>
</CardGroup>

### Agent auth in one screen

```ts
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite', url: ':memory:' },
  agents: { enabled: true },
});

// The owner must exist (human auth creates these for you).
const owner = await theauth.user.create({ email: 'owner@example.com', name: 'Owner' });

const agent = await theauth.agent.create({
  ownerId: owner.id,
  name: 'code-reviewer',
  type: 'autonomous',
  permissions: [{ resource: 'mcp:github:*', actions: ['read'] }],
});

const { allowed, auditId } = await theauth.authorize(agent.id, {
  action: 'read',
  resource: 'mcp:github:repos',
});
```

### Human auth in one screen

```ts
import { createTheAuth } from '@glinr/theauth';
import { emailPassword } from '@glinr/theauth-email';

const theauth = await createTheAuth({
  database: { provider: 'sqlite', url: ':memory:' },
  plugins: [
    emailPassword({
      appUrl: 'http://localhost:3000',
      sendVerificationEmail: async (email, _token, url) => console.log(email, url),
      sendResetEmail: async (email, _token, url) => console.log(email, url),
    }),
  ],
});

// Routes served by the email plugin (only while it is installed):
// POST /auth/sign-up, /auth/sign-in, /auth/verify-email, /auth/request-reset ...
const res = await theauth.plugins.handleRequest(request);
```

## What's in the box

<Columns cols={3}>
<div>
**Agent identity** as a first-class entity, not an extension of a user.
</div>
<div>
**Resource wildcards** with rate limits, time windows, and IP allowlists.
</div>
<div>
**Delegation chains** with depth, expiry, and revocation that takes effect on the next call.
</div>
<div>
**Audit log** of every decision, with JSON and CSV export.
</div>
<div>
**MCP OAuth 2.1** authorization server, PKCE, DCR and revocation-bound tokens.
</div>
<div>
**Trust scoring** per agent from audit history, plus budget policies.
</div>
<div>
**Ten adapters** for Node, edge, Workers, Deno, Bun.
</div>
<div>
**Databases**: SQLite and Postgres, plus Cloudflare D1. MySQL is not supported. See [what is tested](/choose-storage#what-is-tested-per-database).
</div>
<div>
**Web Crypto only** in core, no Node-specific APIs.
</div>
<div>
**Devices** with their own credentials, enrollment, live revocation and signed offline policy bundles.
</div>
<div>
**Robotics** with ROS 2 permissions exported to SROS2 and DDS Security files, plus a gate node for hazardous commands.
</div>
</Columns>

Devices and robots use the same model as agents. A sensor, gateway or machine is a principal with its own credentials, permissions and audit rows, so one policy and one audit trail cover people, agents and hardware. Start with [IoT devices](/iot-devices) or [robotics and ROS 2](/robotics-ros2).

## How it fits with your stack

```mermaid
flowchart LR
  User([Human user]) -->|signs in| HumanAuth[theAuth human auth, or Clerk / Auth.js / better-auth]
  HumanAuth -->|user ID| TheAuth[TheAuth]
  TheAuth -->|agent.create| Agents[(Agents)]
  TheAuth -->|authorize| Decision{allowed?}
  Decision -->|yes| Tools[MCP servers, APIs, databases]
  Decision -.->|logged either way| Audit[(Audit trail)]
```

<Info>
theAuth includes human auth, but you do not have to use it. If you already run Clerk, Auth.js, better-auth or your own login, keep it and pass the user ID as the agent's owner. theAuth's own methods (see [Authentication](/auth)) cover sign-in forms, password reset and social OAuth when you want one library for both.
</Info>

## Pick your framework

<CardGroup cols={4}>
  <Card title="Next.js" icon="/brand-icons/nextdotjs.svg" href="/adapters/nextjs">
    App Router and Pages.
  </Card>
  <Card title="Hono" icon="/brand-icons/hono.svg" href="/adapters/hono">
    Workers, Deno, Bun.
  </Card>
  <Card title="Express" icon="/brand-icons/express.svg" href="/adapters/express">
    Classic Node handlers.
  </Card>
  <Card title="Fastify" icon="/brand-icons/fastify.svg" href="/adapters/fastify">
    Plugins and decorators.
  </Card>
  <Card title="NestJS" icon="/brand-icons/nestjs.svg" href="/adapters/nestjs">
    Guards and decorators.
  </Card>
  <Card title="Nuxt" icon="/brand-icons/nuxt.svg" href="/adapters/nuxt">
    Server routes.
  </Card>
  <Card title="SvelteKit" icon="/brand-icons/svelte.svg" href="/adapters/sveltekit">
    Hooks and endpoints.
  </Card>
  <Card title="Astro" icon="/brand-icons/astro.svg" href="/adapters/astro">
    Server islands.
  </Card>
</CardGroup>

## The six primitives

<CardGroup cols={2}>
  <Card title="Agent identity" icon="robot" href="/agents">
    Bearer tokens (`ta_...`, a prefix kept from before the rename), rotation, expiry. SHA-256 hashed at rest.
  </Card>
  <Card title="Permission engine" icon="shield-halved" href="/permissions">
    Resource wildcards, rate limits, time windows, IP allowlists, approval gates.
  </Card>
  <Card title="Delegation" icon="link" href="/delegation">
    Orchestrator delegates a subset to a sub-agent with depth and expiry. A revoked delegator stops lending permissions.
  </Card>
  <Card title="Audit trail" icon="scroll" href="/audit">
    Every `authorize()` writes agent, user, resource, action, result, duration.
  </Card>
  <Card title="MCP OAuth 2.1" icon="globe" href="/mcp">
    Spec-compliant AS with PKCE S256, RFC 9728, RFC 7591.
  </Card>
  <Card title="Trust scoring" icon="chart-line" href="/trust">
    Score per agent computed from audit history. Budget policies you check and record yourself.
  </Card>
</CardGroup>

## Switching from another auth library

<CardGroup cols={2}>
  <Card title="From better-auth" icon="arrow-right" href="/migrate/from-better-auth">
    Concepts map, code diffs, data migration SQL.
  </Card>
  <Card title="From Clerk" icon="arrow-right" href="/migrate/from-clerk">
    Hooks, middleware, Clerk data export, rollout plan.
  </Card>
</CardGroup>

<Tip>
New releases land on [GitHub](https://github.com/glincker/theauth/releases) every week or two. Watch the repo or follow [@thegdsks](https://x.com/thegdsks) for the highlights.
</Tip>

## Related

<CardGroup cols={2}>
  <Card title="Core concepts" href="/concepts" icon="lightbulb">
    The mental model for agents, permissions, delegation, and audit.
  </Card>
  <Card title="MCP OAuth 2.1" href="/mcp" icon="globe">
    Spec-compliant authorization server for Model Context Protocol tool servers.
  </Card>
  <Card title="Database setup" href="/database" icon="database">
    SQLite, Postgres, and Cloudflare D1 configuration. MySQL is not supported.
  </Card>
  <Card title="Standards alignment" href="/standards" icon="book-open">
    IETF draft claims theAuth tracks and emits on agent JWTs.
  </Card>
</CardGroup>

---

# Core concepts

Source: https://docs.theauth.dev/concepts

A theAuth app has one loop: a user signs in, creates agents, agents call `authorize()` before acting, and every decision lands in the audit trail. Everything else is detail on top of that loop.

```mermaid
flowchart LR
  U([User]) -->|creates| A[(Agent)]
  A -->|token| C[Agent call]
  C -->|authorize| K[TheAuth]
  K -->|yes / no| Out{Decision}
  K -.->|logs| Log[(Audit)]
  K -.->|adjusts| T[Trust score]
```

## User vs agent

A **user** is a human. They have email, password, sessions, OAuth accounts. theAuth can run human auth for you (see [Authentication](/auth)) or plug into Clerk, Auth.js, better-auth.

An **agent** is a program acting on a user's behalf. One human can own many agents. Agents do not sign in. They authenticate with a bearer token (`ta_...`) that is issued once and hashed at rest. No password, no session, no OAuth.

<Info>
If you reach for password reset, email verification, or social sign-in on an agent, you want a user, not an agent. Agents are non-interactive by design.
</Info>

## Permission model

Permissions are strings that describe what the agent may do, scoped to resources it may touch.

```ts
permissions: [
  {
    resource: 'mcp:github:*',        // wildcard on tool namespaces
    actions: ['read'],
    constraints: {
      maxCallsPerHour: 100,          // rate limit
      requireApproval: true,         // authorize() denies until approval is wired up
      ipAllowlist: ['10.0.0.0/8'],   // where the agent may run from
      timeWindow: { start: '09:00', end: '18:00' },  // server local time
    },
  },
]
```

Three shapes carry most of the real work: the **resource string** (free-form, convention-driven, usually `kind:namespace:id`), the **action list** (`read`, `write`, `execute`, domain-specific verbs), and **constraints** (rate, approval, IP, time, argument patterns). `authorize()` evaluates all three. `allowedArgPatterns` are regular expressions, and every string argument must match every pattern.

<Tip>
Resource strings are conventions, not enforced syntax. Pick `mcp:github:*`, `db:users:write`, `s3:bucket:objects`, whatever reads in logs. Consistency matters more than syntax.
</Tip>

## Delegation

An agent can hand a subset of its permissions to a sub-agent. Every hop carries a depth counter, an expiry, and can be revoked independently. Revoking a delegation cascades: every delegation made onward from that link is revoked too, so those agents lose the delegated permissions the next time they call `authorize()`.

```ts
await theauth.delegate({
  fromAgent: parent.id,
  toAgent: child.id,
  permissions: [{ resource: 'mcp:github:issues', actions: ['read'] }],
  expiresAt: new Date(Date.now() + 3_600_000),
  maxDepth: 2,
});
```

An agent cannot delegate permissions it does not hold. Attempts to escalate are rejected at delegation time, not at the next `authorize()` call. `maxDepth` limits chain length: the new link's depth must be less than or equal to it (default 3).

## Audit trail

When `agents.auditAll` is on (the default), every `authorize()` and `authorizeByToken()` call against a known agent writes one row:

| Field | Meaning |
|---|---|
| `agentId` | Which agent asked |
| `userId` | The human the agent belongs to |
| `action`, `resource` | What they tried to do |
| `parameters` | The `arguments` passed to `authorize()` |
| `result` | `allowed` or `denied` |
| `reason` | Why, if denied (free text, or `agent_revoked` / `agent_expired` for a revoked or expired agent) |
| `durationMs` | Evaluation latency in ms |

The IP address and user agent are stored too, when the request carried them. Nothing in the API edits entries; `theauth.audit.cleanup({ retentionDays })` deletes entries older than the retention window. Export as JSON or CSV for compliance.

## Agent types

<AccordionGroup>
  <Accordion title="autonomous" icon="robot">
    Acts on its own. No human approval unless a permission constraint says so. Background jobs, cron, assistants that run unattended.
  </Accordion>
  <Accordion title="delegated" icon="link">
    Gets permissions from a parent agent via delegation. Use for ephemeral sub-agents created to finish one task, then discarded.
  </Accordion>
  <Accordion title="service" icon="server">
    Long-lived infrastructure identity. MCP servers, internal microservices, anything service-account-shaped.
  </Accordion>
</AccordionGroup>

## Trust scoring

Every agent can have a score from 0 to 100, computed from its audit log when you call `theauth.trust.computeScore(agentId)` (it is not recomputed on each `authorize()` call). The score starts at 50 and moves with successful calls (up), denials (down), flagged denials (down more), and agent age (up).

The score maps to a level: `untrusted` (below 40), `limited` (40-59), `standard` (60-79), `trusted` (80-94), `elevated` (95+).

See [Trust scoring](/trust) for the full formula and [Policy templates](/policies/templates) for policies you can pair with the level.

## MCP OAuth

theAuth ships an OAuth 2.1 authorization server for Model Context Protocol tool servers. Your MCP servers register as OAuth clients, and they get scoped JWT access tokens. The OAuth server comes from `createMcpModule` in `@glinr/theauth/mcp`; token validation is separate from agent `authorize()` and does not write audit entries.

Three relevant standards: **PKCE S256** (RFC 7636), **Protected Resource Metadata** (RFC 9728), **Dynamic Client Registration** (RFC 7591). Full details in [MCP OAuth 2.1](/mcp).

## What to read next

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/quickstart">
    Five minutes to a working agent.
  </Card>
  <Card title="Add to an existing app" icon="screwdriver-wrench" href="/add-to-existing-app">
    Drop theAuth next to Clerk, Auth.js, or better-auth.
  </Card>
  <Card title="Permissions" icon="shield-halved" href="/permissions">
    Resource strings, constraints, approval gates.
  </Card>
  <Card title="Delegation" icon="link" href="/delegation">
    Chains, depth limits, revocation.
  </Card>
</CardGroup>

---

# Quickstart

Source: https://docs.theauth.dev/quickstart

<Note>Building in Go? See the [theAuth Go library docs](/go/getting-started/overview).</Note>

theAuth does two jobs, and you can use either one alone or both together. Pick the track that matches what you are building. Each takes under five minutes and the code runs as pasted (SQLite, no other services).

<CardGroup cols={2}>
  <Card title="Add agent auth" icon="robot" href="#track-a-add-agent-auth">
    Identity, scoped permissions, delegation and an audit row for every AI agent.
  </Card>
  <Card title="Add human auth" icon="user" href="#track-b-add-human-auth">
    Sign-up, sign-in, email verification and password reset for your users.
  </Card>
</CardGroup>

Want a full app instead? `pnpm create @glinr/theauth-app` scaffolds a Next.js SaaS, a Hono MCP server or a Cloudflare Worker with theAuth wired up (also `npm create`, `yarn create`, `bun create`). The CLI asks for a directory, a template, a package manager and a database driver. It writes a `.env` with generated secrets and adds a `doctor` script. The generated code creates the owner with `theauth.user.create` and stores data in a SQLite file. The Hono MCP template still keeps MCP clients, codes and tokens in memory, because database backed MCP stores are not available yet.

## Fastest start: theauth init

In an existing project, `theauth init` writes a working server, creates the owner user and a demo agent, and prints the next steps. It reads `package.json` to pick the framework (Hono, Express, Fastify, Next.js, SvelteKit or Nuxt) and the package manager, and it stores data in a SQLite file so it survives restarts.

```bash title="terminal"
npx theauth init --yes
```

```text title="output"
TheAuth init
Framework: hono, database: sqlite

created     src/theauth.ts
created     src/seed.ts
created     src/server.ts
created     .env.example
created     .gitignore
created     .env (generated secrets, gitignored)

Note: Detected hono from package.json.

Next steps

  1. Install dependencies:
       npm install @glinr/theauth @glinr/theauth-hono better-sqlite3 hono @hono/node-server zod
       npm install -D tsx
  2. Start the server (it creates the owner user and a demo agent on boot):
       npx tsx --env-file=.env src/server.ts
  3. List agents through the mounted routes:
       curl -s -H "Authorization: Bearer $(grep '^ADMIN_API_KEY=' .env | cut -d= -f2)" http://localhost:3000/api/theauth/agents
  4. Check the setup, including the running server:
       theauth doctor --live http://localhost:3000/api/theauth

Docs: https://theauth.dev/docs
```

After you install and start the server:

```text title="server log"
Owner user: owner@example.com (50005a21-0612-4ca0-a085-0c6f0ea04706)
Demo agent created: 72bab466-1fc7-41ad-93f9-502e02b56c23
Agent token (shown once, store it safely): ta_x8tSbjRs7NnH21WA1_-yGKZRqmucw7a_u4Yo0wkhKjU
Listening on http://localhost:3000
```

The curl from step 3 returns the demo agent:

```json title="response"
{"data":[{"id":"72bab466-1fc7-41ad-93f9-502e02b56c23","ownerId":"50005a21-0612-4ca0-a085-0c6f0ea04706","name":"demo-agent","type":"autonomous","token":"","permissions":[{"resource":"documents","actions":["read","write"]}],"status":"active", ...}]}
```

The same call without the `Authorization` header answers 401. The seed creates the owner with `theauth.user.create` before the agent, so the foreign key on `owner_id` holds. Running `init` again skips every file that exists, so it never overwrites your edits (pass `--force` to replace the generated files; `.env` is only ever created). See [CLI tools](/cli-tools#init) for every flag. The two tracks below show the same calls by hand.

## Track A: add agent auth

<Steps>

<Step>

### Install

```bash title="terminal"
pnpm add @glinr/theauth better-sqlite3
```

`npm install` and `yarn add` work the same way. The Node SQLite driver is `better-sqlite3`; on Bun, Deno and Workers use the matching [database provider](/database).

The unscoped `theauth` package is an alias for `@glinr/theauth-cli`, so `npx theauth init` sets up agent identity and MCP OAuth 2.1 without a separate CLI install. To keep the command on your PATH, install it globally with `npm install -g theauth`.

</Step>

<Step>

### Create an instance and an owner

Every agent has an owner: a user ID that exists as a row in `theauth_users` (the `owner_id` column is a foreign key, so creating an agent for an unknown ID fails). Human auth in the next track creates these rows for you; if you already have users in another system, call `theauth.user.create()` for each one you want to own agents. It validates and lowercases the email, and returns the existing row if the email is already registered.

```ts title="agent.ts"
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: './theauth.db' }, // a file on disk, kept across runs
  agents: { enabled: true, maxPerUser: 10, auditAll: true, tokenExpiry: '24h' },
});

// Owner row. Skip this if the user came from the human auth track.
const owner = await theauth.user.create({
  id: 'user-123', // optional, generated when omitted
  email: 'owner@example.com',
  name: 'Owner',
});
```

</Step>

<Step>

### Create an agent

```ts title="agent.ts"
const agent = await theauth.agent.create({
  ownerId: 'user-123',
  name: 'github-reader',
  type: 'autonomous',
  permissions: [
    { resource: 'mcp:github:*', actions: ['read'] },
    {
      resource: 'mcp:deploy:production',
      actions: ['execute'],
      constraints: {
        requireApproval: true,   // authorize() denies until approval is wired up
        maxCallsPerHour: 5,
      },
    },
  ],
});

// agent.token is the bearer token. Agent tokens start with "ta_".
console.log(agent.token);
```

<Warning>
The token is shown once, at creation. Store it in your secrets manager or hand it straight to the agent. It cannot be recovered, only rotated.
</Warning>

New agent tokens start with `ta_`. Tokens issued by earlier versions start with `kv_` (the old Kavach name) and keep working until you rotate them (see [RENAME-MAP.md](https://github.com/glincker/theauth/blob/main/RENAME-MAP.md)).

There are three agent types:

| Type | When to use |
|---|---|
| `autonomous` | Runs without human involvement. Default for most agents. |
| `delegated` | Receives permissions from another agent via a delegation chain. |
| `service` | Long-lived service account identity. |

</Step>

<Step>

### Authorize an action and read the audit trail

`authorize` returns `{ allowed, reason?, auditId }`. `reason` explains a denial. Budget policies are not checked here; call `theauth.policies.checkBudget()` yourself if you use them.

```ts title="agent.ts"
const result = await theauth.authorize(agent.id, {
  action: 'read',
  resource: 'mcp:github:repos',
});
if (!result.allowed) throw new Error(`Denied: ${result.reason}`);

// With only the raw bearer token (from an incoming HTTP request):
const byToken = await theauth.authorizeByToken(agent.token, {
  action: 'read',
  resource: 'mcp:github:repos',
});

// Every decision is logged while auditAll is on (the default).
const logs = await theauth.audit.query({ agentId: agent.id });
const denied = await theauth.audit.query({ agentId: agent.id, result: 'denied' });
const csv = await theauth.audit.export({ format: 'csv' });
```

Run it with `npx tsx agent.ts`.

</Step>

</Steps>

## Track B: add human auth

theAuth ships 14 human auth methods and 48 OAuth providers as plugins and modules; this track uses email and password, the most common starting point. See [Authentication](/auth) for the rest.

<Steps>

<Step>

### Install

```bash title="terminal"
pnpm add @glinr/theauth better-sqlite3
```

</Step>

<Step>

### Turn on email and password

`emailPassword` is part of core. It serves `POST /auth/email/sign-up`, `/auth/email/sign-in`, `/auth/email/verify`, `/auth/email/resend-verification`, `/auth/email/forgot-password`, `/auth/email/reset-password` and `/auth/email/change-password` from the same handler as everything else. You supply the email transport; the callbacks below just log. Passwords are hashed before storage, and sessions are the same sessions the rest of theAuth reads.

```ts title="human.ts"
import { createTheAuth } from '@glinr/theauth';
import { createTheAuthHandler } from '@glinr/theauth/http';

const verifications: string[] = [];

const theauth = await createTheAuth({
  database: { provider: 'sqlite', url: ':memory:' },
  auth: { session: { secret: 'a-session-secret-of-at-least-32-characters' } },
  agents: { enabled: true, maxPerUser: 10, auditAll: true }, // for the agent step below
  emailVerification: {
    verifyUrl: 'http://localhost:3000/verify-email',
    sendVerificationEmail: async (email, token, url) => {
      verifications.push(token);
      console.log('verify', email, url);
    },
  },
  emailPassword: {}, // requireVerification is true unless you set it to false
});

const handler = createTheAuthHandler(theauth, { basePath: '/api/theauth' });
```

</Step>

<Step>

### Sign up, verify and sign in

The handler takes standard `Request` objects, so you can call it directly or mount it with a [framework adapter](/adapters).

```ts title="human.ts"
const post = (path: string, body: unknown) =>
  handler(
    new Request(`http://localhost/api/theauth${path}`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body),
    }),
  );

const creds = { email: 'ada@example.com', password: 'correct horse battery' };

console.log((await post('/auth/email/sign-up', { ...creds, name: 'Ada' })).status); // 201, no session yet
console.log((await post('/auth/email/sign-in', creds)).status); // 403 EMAIL_NOT_VERIFIED
await post('/auth/email/verify', { token: verifications[0] });

const signIn = await post('/auth/email/sign-in', creds);
const { user, session } = await signIn.json(); // { user, session: { token, expiresAt } }

const me = await handler(
  new Request('http://localhost/api/theauth/auth/session', {
    headers: { authorization: `Bearer ${session.token}` },
  }),
);
console.log((await me.json()).user.email); // ada@example.com
```

Run it with `npx tsx human.ts`. The browser clients take `emailPassword: true` and call the same routes ([React](/react), [Vue](/vue), [Svelte](/svelte), [Expo](/expo), [Electron](/electron)). Details, the error codes and what the routes reveal are on [Email and password](/auth/email-password).

</Step>

<Step>

### Give the user an agent

The sign-in response contains `user.id`. Pass it as `ownerId` in Track A and the foreign key is satisfied with no manual seeding.

```ts title="human.ts"
const agent = await theauth.agent.create({
  ownerId: user.id,
  name: 'assistant',
  type: 'autonomous',
  permissions: [{ resource: 'mcp:github:*', actions: ['read'] }],
});
```

</Step>

</Steps>

Prefer to keep Clerk, Auth.js, better-auth or your own login? Skip Track B and pass your user ID as `ownerId`, or wrap your resolver with `customAuth` ([details](/auth/email-password)).

## Delegation

An orchestrator agent can delegate a subset of its permissions to a sub-agent. The delegation has its own expiry and a `maxDepth` (default 3) to prevent unbounded chains: the new link's depth must be less than or equal to `maxDepth`.

```ts
const sub = await theauth.agent.create({
  ownerId: 'user-123',
  name: 'sub-reader',
  type: 'delegated',
  permissions: [],  // starts empty; receives permissions via delegation
});

await theauth.delegate({
  fromAgent: agent.id,
  toAgent: sub.id,
  permissions: [{ resource: 'mcp:github:issues', actions: ['read'] }],
  expiresAt: new Date(Date.now() + 3_600_000),  // 1 hour
  maxDepth: 2,
});

// Permissions this agent currently holds through active delegations
const perms = await theauth.delegation.getEffectivePermissions(sub.id);
```

<Info>
An agent cannot delegate permissions it does not hold itself. Attempts to escalate are rejected at the point of delegation, not at authorization time.
</Info>

## Full working example

<Expandable title="Self-contained script: create, authorize, rotate, delegate, export">

Covers agent creation, authorization by ID and token, token rotation, delegation, and audit export.

```ts
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite', url: ':memory:' },
  agents: { enabled: true, maxPerUser: 10, auditAll: true, tokenExpiry: '24h' },
});

// The owner must exist in theauth_users (see Track A, step 2)
await theauth.user.create({ id: 'user-123', email: 'owner@example.com', name: 'Owner' });

// Create an agent with wildcard read on github resources
const agent = await theauth.agent.create({
  ownerId: 'user-123',
  name: 'github-reader',
  type: 'autonomous',
  permissions: [
    { resource: 'mcp:github:*', actions: ['read'] },
    { resource: 'mcp:github:issues', actions: ['read', 'comment'] },
  ],
});

// Allowed: mcp:github:repos matches mcp:github:*
const r1 = await theauth.authorize(agent.id, {
  action: 'read',
  resource: 'mcp:github:repos',
});
console.log(r1.allowed);  // true

// Denied: no write permission
const r2 = await theauth.authorize(agent.id, {
  action: 'write',
  resource: 'mcp:github:repos',
});
console.log(r2.allowed);  // false

// Authorize using the raw bearer token
const r3 = await theauth.authorizeByToken(agent.token, {
  action: 'read',
  resource: 'mcp:github:issues',
});
console.log(r3.allowed);  // true

// Rotate: old token is immediately invalid
const rotated = await theauth.agent.rotate(agent.id);
console.log(rotated.token);  // new ta_... token

// Delegate a subset to a sub-agent
const sub = await theauth.agent.create({
  ownerId: 'user-123',
  name: 'sub-reader',
  type: 'delegated',
  permissions: [],
});

await theauth.delegate({
  fromAgent: agent.id,
  toAgent: sub.id,
  permissions: [{ resource: 'mcp:github:issues', actions: ['read'] }],
  expiresAt: new Date(Date.now() + 3_600_000),
  maxDepth: 2,
});

const perms = await theauth.delegation.getEffectivePermissions(sub.id);
console.log(perms);
// [{ resource: 'mcp:github:issues', actions: ['read'] }]  (delegated permissions only)

// Export the audit trail (up to the 10,000 most recent entries)
const csv = await theauth.audit.export({ format: 'csv' });
```

</Expandable>

## Cloudflare Workers

theAuth runs on Workers with no changes. Pass a D1 binding as the database and use the Hono adapter.

```typescript
import { createTheAuth } from '@glinr/theauth';
import { Hono } from 'hono';

type Env = { THEAUTH_DB: D1Database };

const app = new Hono<{ Bindings: Env }>();

app.get('/health', async (c) => {
  const theauth = await createTheAuth({
    database: { provider: 'd1', binding: c.env.THEAUTH_DB },
    agents: { enabled: true },
  });

  const agent = await theauth.agent.create({
    ownerId: 'user-1',
    name: 'my-agent',
    type: 'autonomous',
    permissions: [{ resource: 'mcp:github:*', actions: ['read'] }],
  });

  return c.json({ agent });
});

export default app;
```

Bind a D1 database in your `wrangler.toml`:

```toml
[[d1_databases]]
binding = "THEAUTH_DB"
database_name = "theauth"
database_id = "<your-database-id>"
```

Run `npx wrangler d1 execute theauth --file=./theauth-schema.sql` to apply the schema yourself (and set `skipMigrations: true` inside `database`), or leave it at its default of `false` and theAuth creates the tables with `CREATE TABLE IF NOT EXISTS` on startup.

<Info>
`createTheAuth` is always async, not only with D1. Workers and Deno both support top-level await, so you can also initialize outside the handler if you use a module worker.
</Info>

## Troubleshooting

### "Email address has not been verified" after sign-up

With the `emailPassword` config, sign-in requires email verification by default and fails with `EMAIL_NOT_VERIFIED` (HTTP 403). Either:
1. Post the token from the verification email (the one passed to your `sendVerificationEmail` callback) to `/auth/email/verify`
2. Set `requireVerification: false` in the `emailPassword` config (development only)

### "FOREIGN KEY constraint failed" when creating agents

The `ownerId` must match a row in `theauth_users`. Sign the user up with `emailPassword` (Track B), or create one with `theauth.user.create()` as shown in step 2 of Track A.

### Session not persisting after page reload

The React hooks store sessions in `localStorage`. Make sure your app is wrapped in `<TheAuthProvider>`. If using SSR (Next.js), wrap the provider in a `"use client"` component.

## Next steps

<CardGroup cols={2}>
  <Card title="Permission engine" icon="shield-halved" href="/permissions">
    Wildcards, rate limits, time windows, IP allowlists, approval gates.
  </Card>
  <Card title="Delegation chains" icon="link" href="/delegation">
    Sub-agent delegation with depth limits and cascading revocation.
  </Card>
  <Card title="MCP OAuth 2.1" icon="globe" href="/mcp">
    Set up the authorization server for MCP tool servers.
  </Card>
  <Card title="Framework adapters" icon="puzzle-piece" href="/adapters">
    Drop-in middleware for ten frameworks.
  </Card>
  <Card title="Coming from another library" icon="arrow-right-arrow-left" href="/migrate">
    Migration guides from better-auth and Clerk.
  </Card>
  <Card title="Configuration" icon="gear" href="/configuration">
    All `createTheAuth()` options and environment patterns.
  </Card>
</CardGroup>

---

# Dev mode: local agent auth server in one command

Source: https://docs.theauth.dev/dev-mode

Start a local server with a demo user, a delegation and a policy check. No config, database or account is needed.

```bash
npx theauth dev
```

The server listens on `127.0.0.1` with an in-memory SQLite database. It seeds:

1. A demo user, `demo@theauth.dev`.
2. A `planner-agent` owned by that user, with read access to repos and write access to issues.
3. A `worker-agent` that starts with no permissions of its own.
4. A delegation from the planner to the worker that grants only `read` on `mcp:github:repos`.

It then runs three guarded tool calls and prints each result:

| Call | Result | Why |
| --- | --- | --- |
| worker reads a repo | allowed | The delegation covers it |
| worker deletes a repo | denied | The delegation does not cover it |
| planner writes an issue | allowed | The planner holds that grant itself |

Every call lands in the audit log. The command verifies the audit hash chain and reports the count.

## Command-line options

| Option | Default | Notes |
| --- | --- | --- |
| `--port <n>` | `3100` | Also settable with the `THEAUTH_DEV_PORT` environment variable |
| `--smoke` | off | Run the walkthrough, check the server answers, then exit with code 0 or 1 |

## Dashboard

When the dashboard build is present, the walkthrough prints a dashboard URL for browsing agents, delegations and audit entries. Without the build, only the API URL is printed and everything else still works. Try `curl http://localhost:3100/api/agents`.

## Limits

- Dev mode turns off authentication on the management routes and binds to localhost only. Do not expose it.
- Data is in memory and is gone when the process exits. Press Ctrl+C to stop.
- For a real setup, run `npx theauth init`.

---

# Works with

Source: https://docs.theauth.dev/works-with

theAuth runs on the stack you already have. This page lists what is supported today, with a link to the guide for each.

<Note>
All product names and logos are trademarks of their respective owners and appear here only to show compatibility. Their use does not imply endorsement.
</Note>

## Server frameworks and runtimes

One adapter per framework. All of them share the same guard, rate limits and session handling.

<div className="flex flex-wrap gap-3 my-4">
  <a href="/adapters/hono" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/hono.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/hono-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Hono</span></a>
  <a href="/adapters/nextjs" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/nextjs.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/nextjs-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Next.js</span></a>
  <a href="/adapters/express" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/express.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/express-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Express</span></a>
  <a href="/adapters/fastify" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/fastify.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/fastify-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Fastify</span></a>
  <a href="/adapters/nestjs" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/nestjs.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/nestjs-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">NestJS</span></a>
  <a href="/adapters/nuxt" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/nuxt.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/nuxt-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Nuxt</span></a>
  <a href="/adapters/sveltekit" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/sveltekit.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/sveltekit-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">SvelteKit</span></a>
  <a href="/adapters/astro" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/astro.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/astro-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Astro</span></a>
  <a href="/adapters/solidstart" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/solidstart.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/solidstart-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">SolidStart</span></a>
  <a href="/adapters/tanstack" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/tanstack.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/tanstack-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">TanStack Start</span></a>
  <a href="/quickstart" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/nodejs.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/nodejs-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Node.js</span></a>
  <a href="/quickstart" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/bun.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/bun-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Bun</span></a>
  <a href="/quickstart" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/deno.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/deno-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Deno</span></a>
  <a href="/database" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/cloudflare-workers.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/cloudflare-workers-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Cloudflare Workers</span></a>
</div>

## Social and OAuth providers

48 providers ship ready to use. Each is a factory you pass to `oauth()`, with PKCE and token refresh handled for you. Anything else with an OIDC discovery document works through `genericOIDC`.

<div className="flex flex-wrap gap-3 my-4">
  <a href="/auth/github" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/github.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/github-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">GitHub</span></a>
  <a href="/auth/google" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/google.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/google-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Google</span></a>
  <a href="/auth/apple" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/apple.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/apple-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Apple</span></a>
  <a href="/auth/microsoft" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/microsoft.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/microsoft-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Microsoft</span></a>
  <a href="/auth/discord" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/discord.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/discord-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Discord</span></a>
  <a href="/auth/gitlab" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/gitlab.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/gitlab-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">GitLab</span></a>
  <a href="/auth/bitbucket" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/bitbucket.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/bitbucket-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Bitbucket</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/slack.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/slack-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Slack</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/x.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/x-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">X (Twitter)</span></a>
  <a href="/auth/linkedin" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/linkedin.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/linkedin-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">LinkedIn</span></a>
  <a href="/auth/facebook" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/facebook.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/facebook-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Facebook</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/twitch.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/twitch-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Twitch</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/spotify.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/spotify-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Spotify</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/reddit.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/reddit-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Reddit</span></a>
  <a href="/auth/dropbox" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/dropbox.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/dropbox-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Dropbox</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/zoom.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/zoom-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Zoom</span></a>
  <a href="/auth/notion" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/notion.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/notion-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Notion</span></a>
  <a href="/auth/figma" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/figma.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/figma-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Figma</span></a>
  <a href="/auth/atlassian" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/atlassian.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/atlassian-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Atlassian</span></a>
  <a href="/auth/line" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/line.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/line-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Line</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/tiktok.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/tiktok-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">TikTok</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/vk.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/vk-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">VK</span></a>
  <a href="/auth/kakao" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/kakaotalk.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/kakaotalk-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Kakao</span></a>
  <a href="/auth/naver" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/naver.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/naver-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Naver</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/wechat.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/wechat-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">WeChat</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/roblox.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/roblox-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Roblox</span></a>
  <a href="/auth/huggingface" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/huggingface.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/huggingface-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Hugging Face</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/vercel.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/vercel-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Vercel</span></a>
  <a href="/auth/linear" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/linear.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/linear-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Linear</span></a>
  <a href="/auth/railway" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/railway.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/railway-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Railway</span></a>
  <a href="/auth/kick" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/kick.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/kick-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Kick</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/patreon.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/patreon-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Patreon</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/box.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/box-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Box</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/yandex.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/yandex-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Yandex</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/wordpress.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/wordpress-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">WordPress</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/gitea.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/gitea-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Gitea</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/salesforce.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/salesforce-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Salesforce</span></a>
  <a href="/auth/paypal" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/paypal.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/paypal-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">PayPal</span></a>
  <a href="/auth/coinbase" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/coinbase.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/coinbase-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Coinbase</span></a>
  <a href="/auth/polar" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/polar.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/polar-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Polar</span></a>
</div>

## Enterprise identity providers

Sign in with your workforce identity through OIDC presets, or federate with SAML and SCIM. Exchange security events with other OpenID Shared Signals Framework transmitters and receivers through [shared signals](/auth/shared-signals) (SSF 1.0, CAEP and RISC).

<div className="flex flex-wrap gap-3 my-4">
  <a href="/auth/auth0" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/auth0.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/auth0-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Auth0</span></a>
  <a href="/auth/okta" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/okta.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/okta-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Okta</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/aws-cognito.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/aws-cognito-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Amazon Cognito</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/keycloak.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/keycloak-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Keycloak</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/authentik.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/authentik-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">authentik</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/onelogin.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/onelogin-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">OneLogin</span></a>
  <a href="/auth/more-providers" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><span className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-300 text-xs font-semibold dark:border-zinc-700">Z</span><span className="text-xs text-center leading-tight">ZITADEL</span></a>
  <a href="/auth/microsoft" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/microsoft.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/microsoft-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Microsoft Entra ID</span></a>
</div>

## Client SDKs

Browser, mobile, desktop and extension surfaces use the same server.

<div className="flex flex-wrap gap-3 my-4">
  <a href="/react" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/react.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/react-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">React</span></a>
  <a href="/vue" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/vue.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/vue-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Vue</span></a>
  <a href="/svelte" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/sveltekit.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/sveltekit-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Svelte</span></a>
  <a href="/expo" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/expo.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/expo-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Expo</span></a>
  <a href="/electron" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/electron.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/electron-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Electron</span></a>
  <a href="/browser-extension" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/chrome.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/chrome-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Browser extensions</span></a>
</div>

## Databases and ORMs

Pick the store you already run. SQLite and Postgres are supported, D1 is for Workers, and MySQL is not supported in this release. What is tested on each is on [choose storage](/choose-storage#what-is-tested-per-database).

<div className="flex flex-wrap gap-3 my-4">
  <a href="/database" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/sqlite.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/sqlite-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">SQLite</span></a>
  <a href="/database" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/postgresql.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/postgresql-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">PostgreSQL</span></a>
  <a href="/database" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/cloudflare.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/cloudflare-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Cloudflare D1</span></a>
  <a href="/database" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/drizzle.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/drizzle-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Drizzle ORM</span></a>
  <a href="/database" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/prisma.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/prisma-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Prisma</span></a>
</div>

## Protocols and languages

Built on open standards, with a TypeScript SDK and a Go library.

<div className="flex flex-wrap gap-3 my-4">
  <a href="/mcp" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/modelcontextprotocol.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/modelcontextprotocol-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Model Context Protocol</span></a>
  <a href="/quickstart" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/typescript.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/typescript-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">TypeScript SDK</span></a>
  <a href="/go/index" className="flex w-24 flex-col items-center gap-2 rounded-xl border border-zinc-200 p-3 no-underline dark:border-zinc-800"><img src="/brand-icons/works-with/go.svg" alt="" className="block dark:hidden h-8 w-8 object-contain" /><img src="/brand-icons/works-with/go-dark.svg" alt="" className="hidden dark:block h-8 w-8 object-contain" /><span className="text-xs text-center leading-tight">Go library</span></a>
</div>

## Logo credits

<div className="flex items-center gap-3 my-4">
  <a href="https://thesvg.org" className="no-underline"><img src="/brand-icons/thesvg.svg" alt="theSVG" className="block dark:hidden h-10 w-10" /><img src="/brand-icons/thesvg-dark.svg" alt="theSVG" className="hidden dark:block h-10 w-10" /></a>
  <span>Brand logos on this page come from <a href="https://thesvg.org">theSVG</a>, the open brand SVG library.</span>
</div>

All names, logos and trademarks are the property of their respective owners and are shown only to identify compatibility. theAuth is not affiliated with, endorsed by or sponsored by any of them.

## Something missing?

If your provider speaks OIDC, `genericOIDC` covers it with a discovery URL and a client ID. For a framework without an adapter, the core is plain Web `Request` and `Response`, so it mounts anywhere. See [more providers](/auth/more-providers) and the [adapter overview](/adapters).

---

# What's new

Source: https://docs.theauth.dev/whats-new

This page covers three rounds of work on `@glinr/theauth`. The first two aim to make an agent's actions checkable before and after they run. The third makes revocation reach the tokens an agent already holds. The changes sit in pending changesets in the repository and are merged to main. This page does not name a release number. Check the [GitHub releases](https://github.com/glincker/theauth/releases) for what has been published, and the changelog for breaking changes.

The pending set now includes `major` changesets (hooks, the HTTP handler, audit v2, the MCP stores, SAML, cookies and more). Read [Upgrading to 2.0](/upgrading-to-2) before you upgrade, and the "Behavior changes" list below.

## Accountability at runtime (first round)

- **Three way decisions.** `policy.evaluate` returns `allow`, `deny` or `escalate`. Risk tiers map an amount, currency or data class to no approval, human approval or step up. See [Agent accountability](/accountability) and [Policy engine](/policy-engine).
- **Delegation keeps its limits.** A delegated permission carries its constraints, narrowed against the parent, and a child that does not overlap the parent now gets a deny instead of the parent's wider value. See [Delegation](/delegation).
- **Revoking an agent cuts its delegations.** `agent.revoke()` also revokes the chains where the agent is source or target, and everything downstream. See [Cascading revocation](/delegation#cascading-revocation).
- **Richer audit rows.** Rows record the accountable human, the acting agent, the policy version, the approver and the delegation chain. Delegation and approval events join the hash chain. See [Tamper evident audit trail](/audit-trail).
- **Fail closed fixes.** `maxCallsPerHour: 0` denies, malformed time windows and IP lists deny, a revoked or suspended agent is denied by `evaluate`, and an unknown agent id is denied.

## Tools, approvals and proofs (second round)

- **Single use approvals.** `authorize()` and `policy.evaluate()` accept an `approvalId`. An approval is checked against the agent, action and resource, then spent once. See [Approval flows](/approval).
- **Tool guard.** `guardTool` and `guardTools` put the policy engine in front of any tool with an `execute` function. Walkthroughs for the [Vercel AI SDK](/vercel-ai-sdk) and the [OpenAI Agents SDK](/openai-agents-sdk), with the shared pieces in [Guard agent tools](/agent-tools). LangChain appears as a hand wrapped example, not a supported integration.
- **Taint guard.** Tool arguments can carry labels for where they came from, and policy decides which labels may reach which tools. The labels are written to the audit row. See [Taint aware tool guard](/taint-guard).
- **Policy shrink.** `analyzeUsage`, `suggestShrink` and `verifyShrink` compare what an agent may do with what it did, and report which recorded decisions would change. They never edit permissions. See [Policy shrink](/policy-shrink).
- **Audit checkpoints and receipts.** Signed checkpoints over the audit log, with inclusion and consistency proofs and receipts that verify offline with a public key. See [Audit checkpoints and receipts](/audit-checkpoints).
- **MCP live delegation check.** With `delegationStore` set, a token's `act` chain is re-checked against live links, and `resolveActorAgentId` maps actors to agent ids. The third round replaces this with a signed binding that needs no config. See [MCP OAuth 2.1](/mcp#security-hardening).
- **Session and audit fixes.** Session refresh and `maxConcurrent` no longer race, a failing audit write no longer makes `authorize()` throw, and `canonicalJson` rejects cycles.

## Revocation, remote guards and storage (third round)

- **Revocation reaches MCP tokens.** Exchanged tokens carry a signed `theauth_dlg` binding to their delegation links and agents. With `auth`, revoking a link, an agent, a user's sessions or a token fails the next request on every instance that shares the database. Modes are `live`, `cached` and `offline`. See [MCP token revocation](/mcp-revocation).
- **Introspection.** `POST /mcp/introspect` (RFC 7662) gives resource servers the live answer. Only the token's client and listed resource servers learn the inactive reason.
- **Client ID metadata documents.** A client can use an `https` URL as its `client_id`, behind a required consent page, host rules, size and time limits and an admin block list. See [Client ID metadata documents](/mcp-client-id-metadata).
- **Database backed MCP server.** Clients, codes, tokens and refresh families live in the theAuth database, and an OAuth client can be bound to an agent.
- **One HTTP handler.** `createTheAuthHandler` serves every route for every framework adapter, with identical status codes. See the [route reference](/adapters/routes).
- **Explain and policy tests.** `explain()` returns the matched grant, the delegation chain and near misses. `@glinr/theauth/testing` and `theauth policy test` run policy suites. See [Explain a decision](/explain) and [Policy testing](/policy-testing).
- **Remote guards.** `httpGuard` and `guardHook` put a signed, fail closed external check in `beforeAuthorize`. See [Remote guards](/guards).
- **Observability.** One typed event per decision, an OpenTelemetry mapping and a logger interface. See [Observability](/observability).
- **Audit API v2.** Explicit order, cursor paging, an `event` column and `decision_code` filters.
- **Schema ledger.** Every schema change is recorded in `theauth_schema_migrations`, with `theauth db status | up | sql`.
- **Typed decision codes and typed permissions.** `authorize()` returns a `code`, and `definePermissions` turns resource and action typos into compile errors.
- **Security fixes.** Strict SAML signature verification, a signed session cache in `@glinr/theauth-nextjs-auth`, `Secure` cookies by default and PBKDF2 at 600000 iterations, and revoked or expired delegators no longer lend permissions.
- **Storage.** Postgres works across the instance with epoch second timestamps, and `provider: "sqlite"` refuses a file URL (use `sqlite-native`).
- **Tooling.** `theauth init` and `theauth doctor` are rebuilt, and `theauth.user.create` replaces hand written inserts.

## Behavior changes to check

- ABAC `requireApproval` now escalates instead of returning a plain deny. `allowed` is still false.
- `policy.evaluate` writes an audit row on every call, and the row joins the hash chain.
- A permission with `requireApproval: true` is denied until the call carries a valid `approvalId`.
- Delegated constraints that do not overlap the parent now deny every call.
- `checkLiveDelegation` takes an options object as its fourth argument in place of the `now` date.
- Rows written before the new audit columns verify as erased history, not as tampered.
- With `auth`, MCP validation reads the database on every request by default (`live` mode). Set `revocation.mode: "cached"` above roughly a thousand validations per second per instance.
- Agent tokens start with `ta_`. Existing `kv_` tokens keep working.

The rest, with the action to take for each, is in [Upgrading to 2.0](/upgrading-to-2).

## Tutorials

- [Stop prompt injection from making an agent send email](/tutorials/prompt-injection-tool-guard)
- [Find and remove unused agent permissions](/tutorials/remove-unused-agent-permissions)
- [Give a counterparty a receipt they can verify offline](/tutorials/verifiable-agent-receipts)

## Limits that still apply

- `input_provenance` holds the labels you supply. The SDK does not infer where an input came from.
- The approver is stored on the approval's audit row and on the decision that spends it, not on every decision row.
- `CheckpointStore` ships with an in memory implementation. There is no database backed store.
- The MCP adapters do not serve the `/mcp/revoke` or `/mcp/jwks` routes that the metadata advertises.

---

# Add to an existing app

Source: https://docs.theauth.dev/add-to-existing-app

You already have users. You just added agents. This is the ten-minute recipe.

<Info>
theAuth does not replace your human auth. It runs alongside it. This guide assumes you already have a way to get a stable `userId` for every request.
</Info>

## What you need before you start

<CardGroup cols={2}>
  <Card title="A user ID on every request" icon="user-check">
    Your existing auth must expose a stable string ID, anything from Clerk's `userId`, Auth.js's `session.user.id`, or a row id from your own users table. theAuth needs a matching row in its `theauth_users` table (see step 4).
  </Card>
  <Card title="A database" icon="database">
    Postgres, MySQL, SQLite, or Cloudflare D1. theAuth creates its own tables, prefixed `theauth_`, alongside yours.
  </Card>
</CardGroup>

<Tip>
Want the files below generated for you? Run `npx theauth init --framework nextjs` (or `hono`, `express`, `fastify`, `sveltekit`, `nuxt`) in your project. It writes `src/theauth.ts`, a seed script that creates the owner user and a demo agent, the route file, and a `.env` with generated secrets, then prints the install command for your package manager and a `curl` against the mounted routes. For Next.js it detects an existing `src/` folder and writes `src/app/api/theauth/[...theauth]/route.ts` (or `app/...` without one):

```text title="npx theauth init --yes (Next.js, pnpm)"
created     src/theauth.ts
created     src/seed.ts
created     src/app/api/theauth/[...theauth]/route.ts
created     .env.example
created     .gitignore
created     .env (generated secrets, gitignored)

Next steps

  1. Install dependencies:
       pnpm add @glinr/theauth @glinr/theauth-nextjs better-sqlite3 zod
       pnpm add -D tsx
  2. Create the owner user and a demo agent:
       pnpm exec tsx --env-file=.env src/seed.ts
  3. Start your app:
       pnpm dev
  4. List agents through the mounted routes:
       curl -s -H "Authorization: Bearer $(grep '^ADMIN_API_KEY=' .env | cut -d= -f2)" http://localhost:3000/api/theauth/agents
  5. Check the setup, including the running server:
       theauth doctor --live http://localhost:3000/api/theauth
```

The generated `authenticate` checks a bearer `ADMIN_API_KEY`. In an app that already has human auth, replace it with your own check as shown in step 3. Re-running `init` never overwrites a file that exists.
</Tip>

## Steps

<Steps>

<Step title="Install">

```bash
pnpm add @glinr/theauth @glinr/theauth-nextjs
```

Substitute the adapter for your framework: `@glinr/theauth-hono`, `@glinr/theauth-express`, `@glinr/theauth-fastify`, and so on. See [Framework adapters](/adapters).

</Step>

<Step title="Create the theAuth instance">

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';

let instance: Awaited<ReturnType<typeof createTheAuth>> | null = null;

export async function getTheAuth() {
  if (!instance) {
    instance = await createTheAuth({
      database: {
        provider: 'postgres',
        url: process.env.DATABASE_URL!,
      },
      agents: { enabled: true },
      secret: process.env.THEAUTH_SECRET!,
    });
  }
  return instance;
}
```

The singleton makes sure the instance (and its database connection) is created once per process. Passing `agents` is what makes theAuth create the agent, permission, delegation, and audit tables; without it those tables do not exist and `agent.create` fails.

</Step>

<Step title="Wire the route handler">

```ts title="app/api/theauth/[...theauth]/route.ts"
import { theAuthNextjs } from '@glinr/theauth-nextjs';
import { getTheAuth } from '@/lib/theauth';

const theauth = await getTheAuth();

// Return { id } for callers allowed to use the management routes, or null for a 401.
// Replace this with a check against your existing auth (Clerk, Auth.js, ...).
const authenticate = async (request: Request) => {
  const user = await theauth.auth.resolveUser(request);
  return user ? { id: user.id } : null;
};

export const { GET, POST, PATCH, DELETE, OPTIONS } = theAuthNextjs(theauth, { authenticate });
```

`theAuthNextjs` takes the resolved instance (not a function) and returns one handler per HTTP method. Export all five: the REST API uses `PATCH` and `DELETE` as well. Its default `basePath` is `/api/theauth`; pass `{ basePath }` if you mount it elsewhere. The agent, delegation, audit and dashboard routes require an authenticated caller: pass `authenticate` (as above), or configure `auth.session` to accept any signed-in user. With neither, `theAuthNextjs` throws at startup. See [Framework adapters](/adapters#authenticate-the-management-routes). The `authenticate` function above is a placeholder; with Clerk or Auth.js, call their server helpers and return `{ id: userId }`.

Pick a path that does not collide with your existing auth. `/api/theauth/*` lives next to `/api/auth/*` (Clerk / Auth.js) without stepping on it.

</Step>

<Step title="Create an agent when a user signs up or activates AI features">

Hook into your existing post-sign-in flow. For Clerk, that is a webhook or a server action. For Auth.js, the `signIn` event. For better-auth, the `onSignIn` hook.

<Warning>
`theauth_agents.owner_id` is a foreign key to `theauth_users.id`, and `agent.create` does not create the user for you. Call `theauth.user.create` with your provider's user ID before the first agent. Otherwise the insert fails with a foreign key error. `create` is idempotent on email, so calling it on every sign-in is safe. If the email already belongs to a different id, it throws `USER_EMAIL_CONFLICT` instead of returning the wrong row.
</Warning>

```ts title="wherever you create user-scoped resources"
import { getTheAuth } from '@/lib/theauth';

export async function createDefaultAgent(userId: string, email: string) {
  const theauth = await getTheAuth();

  // Ensures the owner row exists. Returns the existing row on later calls.
  await theauth.user.create({ id: userId, email });

  const agent = await theauth.agent.create({
    ownerId: userId,                     // stable ID from your auth provider
    name: 'default',
    type: 'autonomous',
    permissions: [
      { resource: 'app:read:*', actions: ['read'] },
    ],
  });

  // Persist agent.token somewhere the user can access, encrypted at rest.
  // It is returned once and cannot be recovered.
  return { agentId: agent.id, token: agent.token };
}
```

<Warning>
The token is shown once. Store it in your secrets store or hand it directly to the agent process. If you lose it, rotate with `theauth.agent.rotate(agentId)` to issue a new one. Agents also expire after `agents.tokenExpiry` (default `24h`) unless you pass `expiresAt` to `agent.create`.
</Warning>

</Step>

<Step title="Authorize in your handlers">

Anywhere your agent code runs, check authorization before the call.

```ts title="app/api/agent-action/route.ts"
import { getTheAuth } from '@/lib/theauth';

export async function POST(req: Request) {
  const token = req.headers.get('Authorization')?.replace('Bearer ', '');
  if (!token) return new Response('Missing token', { status: 401 });

  const theauth = await getTheAuth();

  const { allowed, reason, auditId } = await theauth.authorizeByToken(token, {
    action: 'read',
    resource: 'mcp:github:repos',
  });

  if (!allowed) {
    return Response.json({ error: reason, auditId }, { status: 403 });
  }

  // allowed. Do the work.
  const repos = await fetchGithubRepos();
  return Response.json({ repos, auditId });
}
```

</Step>

</Steps>

## Fitting in with Clerk, Auth.js, and better-auth

<Tabs>
<Tab title="Clerk">

```ts
import { auth } from '@clerk/nextjs/server';
import { getTheAuth } from '@/lib/theauth';

export async function POST(req: Request) {
  const { userId } = await auth();
  if (!userId) return new Response('Unauthorized', { status: 401 });

  const theauth = await getTheAuth();
  const agent = await theauth.agent.create({
    ownerId: userId,
    name: 'default',
    type: 'autonomous',
    permissions: [{ resource: 'app:read:*', actions: ['read'] }],
  });

  return Response.json({ agentId: agent.id, token: agent.token });
}
```

</Tab>
<Tab title="Auth.js">

```ts
import { auth } from '@/auth';
import { getTheAuth } from '@/lib/theauth';

export async function POST() {
  const session = await auth();
  if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });

  const theauth = await getTheAuth();
  const agent = await theauth.agent.create({
    ownerId: session.user.id,
    name: 'default',
    type: 'autonomous',
    permissions: [{ resource: 'app:read:*', actions: ['read'] }],
  });

  return Response.json({ agentId: agent.id, token: agent.token });
}
```

</Tab>
<Tab title="better-auth">

```ts
import { auth } from '@/lib/auth';
import { getTheAuth } from '@/lib/theauth';
import { headers } from 'next/headers';

export async function POST() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });

  const theauth = await getTheAuth();
  const agent = await theauth.agent.create({
    ownerId: session.user.id,
    name: 'default',
    type: 'autonomous',
    permissions: [{ resource: 'app:read:*', actions: ['read'] }],
  });

  return Response.json({ agentId: agent.id, token: agent.token });
}
```

</Tab>
</Tabs>

## Common first-day questions

<AccordionGroup>
<Accordion title="Do I need to run theAuth migrations manually?" icon="database">
No by default. theAuth runs its own migrations on first boot. Set `skipMigrations: true` inside the `database` config if you want to run them yourself with `npx theauth migrate` (from `@glinr/theauth-cli`).
</Accordion>
<Accordion title="Can I share the session cookie with my existing auth?" icon="cookie-bite">
Not directly. theAuth issues its own cookie for sessions created via its plugins. If you never use theAuth's human-auth plugins (because you have Clerk / Auth.js / better-auth), you never see that cookie and there is nothing to share.
</Accordion>
<Accordion title="What if my user IDs change shape later?" icon="arrows-rotate">
theAuth stores `ownerId` as a string and never interprets it, but `theauth_agents.owner_id` references `theauth_users.id`. Migrating from numeric IDs to UUIDs later means updating `theauth_users.id` and every referencing `owner_id` together, in one transaction.
</Accordion>
<Accordion title="Does theAuth emit events I can subscribe to?" icon="rss">
Yes, via [event streaming](/event-streaming) and [webhooks](/webhooks). Wire them to your analytics or to Stripe / PostHog / Slack.
</Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Permissions" icon="shield-halved" href="/permissions">
    Define what agents can and cannot do.
  </Card>
  <Card title="MCP OAuth 2.1" icon="globe" href="/mcp">
    Run your own authorization server for MCP tools.
  </Card>
  <Card title="Delegation" icon="link" href="/delegation">
    Let agents spawn sub-agents with scoped permissions.
  </Card>
  <Card title="Audit trail" icon="scroll" href="/audit">
    Query and export every authorization decision.
  </Card>
</CardGroup>

---

# Configuration

Source: https://docs.theauth.dev/configuration

`createTheAuth()` accepts a `TheAuthConfig` object. The only required field is `database`. Everything else is optional and enables features incrementally.

```typescript
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
});
```

## Common development setup

A minimal config for local development with username and password sign-in, theAuth-managed sessions, and no email sending:

```typescript
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: './theauth.db' },
  agents: { enabled: true },
  auth: {
    session: { secret: process.env.SESSION_SECRET! }, // at least 32 characters
  },
  username: {}, // enables theauth.username (signUp, signIn, ...)
});
```

## Top-level options

<ParamField path="database" type="DatabaseConfig" required>Database connection config. Required.</ParamField>
<ParamField path="agents" type="AgentConfig">Agent identity settings.</ParamField>
<ParamField path="auth" type={`{ adapter?: AuthAdapter; session?: SessionConfig }`}>Human auth adapter and session config.</ParamField>
<ParamField path="approval" type="ApprovalConfig">CIBA async approval flow config. See [Approval flows](/approval).</ParamField>
<ParamField path="hooks" type="TheAuthHooks">Lifecycle hooks for sandboxing and custom validation. See [Hooks](/hooks).</ParamField>
<ParamField path="hookSafety" type="HookSafetyConfig">Timeout and failure policy for `beforeAuthorize` and `beforeAgentCreate`. Defaults: 2000 ms, deny on timeout or error for `beforeAuthorize`, throw for `beforeAgentCreate`. Environment variables `THEAUTH_HOOK_TIMEOUT_MS`, `THEAUTH_HOOK_ON_TIMEOUT` and `THEAUTH_HOOK_ON_ERROR` set the defaults. See [Hook safety](/hooks#hook-safety-timeout-and-fail-closed).</ParamField>
<ParamField path="policy" type="PolicyEngineConfig">Unified policy engine cache, combine strategy, and audit sampling. See [Policy engine](/policy-engine).</ParamField>
<ParamField path="did" type="{ web?: DidWebConfig }">did:web domain and path. See [DIDs](/did).</ParamField>
<ParamField path="plugins" type="TheAuthPlugin[]">Plugin factories such as `gdpr()`, `rateLimit()`, `twoFactor()`. See [Plugins](#plugins).</ParamField>
<ParamField path="baseUrl" type="string">Base URL for the auth server (e.g. https://auth.example.com). Read by plugins that build callback URLs or decide on secure cookies (OAuth, passkey, OAuth proxy).</ParamField>
<ParamField path="secondaryStorage" type={`"memory" | "database" | SecondaryStorage | { default?, rateLimit?, deviceCodes?, ... }`} default={`"memory"`}>Where short-lived state lives: rate limit counters, device login codes and similar. Memory is lost on restart and not shared between instances, so use `"database"` or Redis when you run more than one server. See [Secondary storage](/secondary-storage).</ParamField>
<ParamField path="trustedProxy" type="{ trustedProxyCount?: number; trustedHeader?: string }">How plugin endpoint rate limits find the real client IP. Forwarded headers are ignored unless you set the number of proxies you run or a header your edge overwrites (for example `cf-connecting-ip`). Without either, all clients share one rate limit bucket. See [Rate limiting](/rate-limiting).</ParamField>
<ParamField path="sessionFreshness" type="SessionFreshnessConfig">See [Session freshness config](#session-freshness-config).</ParamField>
<ParamField path="webhooks" type="WebhookConfig[]">Webhook endpoints to notify on auth events. See [Webhooks](/webhooks).</ParamField>
<ParamField path="passwordReset" type="PasswordResetConfig">Password reset flow. See [Password reset config](#password-reset-config).</ParamField>

Other optional top-level modules, each of which creates the matching `theauth.<name>` instance property when provided: `magicLink`, `emailOtp`, `emailVerification`, `totp`, `passkey`, `org`, `sso`, `admin`, `apiKeys`, `username`, `phone`, `captcha`, and `redirects`. Several of these require `auth.session` as noted in their own pages.

Options that `TheAuthConfig` declares but `createTheAuth()` does not act on:

- `mcp`: `createTheAuth()` does not read it. The OAuth 2.1 authorization server is built with `createMcpModule` from `@glinr/theauth/mcp`, which takes a `McpConfig` (see [MCP config](#mcp-config)). `theauth.mcp` on the instance is only the MCP tool server registry.
- `secret`: not read by `createTheAuth()`. Set the session secret in `auth.session.secret` and the MCP signing secret in `McpConfig.signingSecret`.
- `emitAgenticJwtClaims`: not read by `createTheAuth()`. See [Standards](/standards) for the places it does work.

There is no `trust` or `telemetry` option. The trust module is always created with its defaults.

## Database config

<ParamField path="provider" type={`"sqlite" | "sqlite-native" | "postgres" | "mysql" | "d1"`} required>Database driver to use.</ParamField>
<ParamField path="url" type="string">File path for SQLite, connection string for Postgres/MySQL. Required for every provider except D1.</ParamField>
<ParamField path="binding" type="D1Database">D1Database binding from the Worker environment. Required when provider is "d1".</ParamField>
<ParamField path="skipMigrations" type="boolean">Skip automatic CREATE TABLE IF NOT EXISTS on init. Use when you manage migrations externally (Flyway, drizzle-kit push, etc.). Defaults to false.</ParamField>

```typescript
// Cloudflare D1 (edge)
const theauth = await createTheAuth({
  database: {
    provider: 'd1',
    binding: env.THEAUTH_DB,  // D1Database from Worker env
  },
});

// SQLite (Node.js)
const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: './theauth.db' },
});

// PostgreSQL
const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL },
});

// MySQL
database: { provider: 'mysql', url: process.env.DATABASE_URL }

// In-memory SQLite (tests)
database: { provider: 'sqlite', url: ':memory:' }
```

## Agent config

Controls the agent identity lifecycle.

<ParamField path="enabled" type="boolean" required>Required by the type, but not read: agent management is always available. Set it to `true`.</ParamField>
<ParamField path="maxPerUser" type="number" default="10">Maximum number of active agents per owner. `agent.create()` throws when the owner is at the limit.</ParamField>
<ParamField path="defaultPermissions" type="string[]">Accepted but not applied: new agents get only the permissions you pass to `agent.create()`.</ParamField>
<ParamField path="auditAll" type="boolean" default="true">Write every `authorize()` outcome (allowed and denied) to the audit log. This includes attempts by revoked or expired agents (reason `agent_revoked` or `agent_expired`). Unknown agent ids and unknown tokens write no row. When `false`, no rows are written by the permission engine at all.</ParamField>
<ParamField path="tokenExpiry" type="string" default={`"24h"`}>Default agent lifetime when `agent.create()` is called without `expiresAt`. Format is a number followed by `s`, `m`, `h`, or `d` (for example `"30m"`, `"24h"`, `"7d"`); anything else makes `create()` throw. Every agent therefore has an expiry unless you pass `expiresAt`.</ParamField>

```typescript
agents: {
  enabled: true,
  maxPerUser: 10,
  defaultPermissions: [],
  auditAll: true,
  tokenExpiry: '30d',
},
```

## MCP config

Options for the OAuth 2.1 authorization server for MCP-compliant tool access. These are the fields of `McpConfig`, passed to `createMcpModule` from `@glinr/theauth/mcp` (see [MCP](/mcp)). Passing them as the `mcp` option of `createTheAuth()` has no effect today.

<ParamField path="enabled" type="boolean" required>Enable the MCP authorization server.</ParamField>
<ParamField path="issuer" type="string">Token issuer URL. Appears as the iss claim in JWTs.</ParamField>
<ParamField path="baseUrl" type="string">Base path for MCP endpoints.</ParamField>
<ParamField path="signingSecret" type="string" required>Secret used to sign JWTs. At least 32 characters; `createMcpModule` throws without it. It does not fall back to any other secret.</ParamField>
<ParamField path="accessTokenTtl" type="number">Access token lifetime in seconds. Defaults to 3600 (1 hour).</ParamField>
<ParamField path="refreshTokenTtl" type="number">Refresh token lifetime in seconds. Defaults to 604800 (7 days).</ParamField>
<ParamField path="codeTtl" type="number">Authorization code lifetime in seconds. Defaults to 600 (10 minutes).</ParamField>
<ParamField path="enforceAuth" type="boolean">Reject all MCP requests without a valid Bearer token.</ParamField>
<ParamField path="scopes" type="string[]">Custom OAuth scopes supported by this server.</ParamField>
<ParamField path="allowedResources" type="string[]">Allowed resource URIs for RFC 8707 resource indicators.</ParamField>
<ParamField path="loginPage" type="string">URL of your login page. Users are redirected here when unauthenticated.</ParamField>
<ParamField path="consentPage" type="string">URL of your consent page. Users are redirected here to approve scopes.</ParamField>
<ParamField path="preRegisteredClients" type={`Array<{ clientId, clientSecret?, redirectUris, clientName?, scope? }>`}>OAuth clients registered at startup (first-party apps, CLIs, test fixtures).</ParamField>
<ParamField path="getAdditionalClaims" type={`(userId: string, scopes: string[]) => Promise<Record<string, unknown>>`}>Async function to add custom claims to issued tokens.</ParamField>

<ParamField path="emitAgenticJwtClaims" type="boolean">Emit `agent_id`, `agent_type`, and `trust_tier` on access tokens, using `getAgenticContext`. See [Standards](/standards).</ParamField>
<ParamField path="getAgenticContext" type="(userId: string) => Promise<{ agentId?, agentType?, trustTier? }>">Supplies the values for those claims.</ParamField>

```typescript
const mcpConfig = {
  enabled: true,
  issuer: 'https://auth.example.com',
  baseUrl: 'https://auth.example.com',
  signingSecret: process.env.THEAUTH_SIGNING_SECRET!,
  accessTokenTtl: 3600,
  refreshTokenTtl: 604800,
  enforceAuth: true,
  loginPage: 'https://example.com/login',
  consentPage: 'https://example.com/consent',
};
```

## Auth config

Connects theAuth to your existing auth provider so it can resolve the human user behind incoming requests.

```typescript
auth: {
  adapter: betterAuthAdapter(auth),   // resolves user from request
  session: {                          // optional: TheAuth-managed sessions
    secret: process.env.SESSION_SECRET,
    maxAge: 60 * 60 * 24 * 30,       // 30 days in seconds
  },
},
```

When `auth` is omitted, `theauth.auth.resolveUser()` always returns `null` (manual user management mode).

Built-in adapters exported from `@glinr/theauth/auth` include `betterAuthAdapter`, `authJsAdapter`, `clerkAdapter`, `bearerAuth`, `cookieAuth`, `headerAuth`, `apiKeyAdapter`, and `customAuth`.

## Secret rotation

Secrets that sign or encrypt data accept a list so you can rotate without invalidating what is already issued: `secret: [current, ...previous]`. The first entry signs and encrypts, every entry verifies and decrypts. Add the new secret at the front and deploy, then remove the old one after the longest lifetime of anything it signed. Entries can also be `{ id, secret }`, and the id is emitted with the signature (`X-TheAuth-Key-Id` for webhooks, a cookie field for sessions) so verifiers try that key first. Entries must be at least 32 bytes, and empty or duplicate entries throw at startup. The list form is supported by webhook endpoint `secret` (see [Webhooks](/webhooks)), `createStatelessSessions` and `createSessionCookieCache` (see [Session hardening](/session-hardening)). `auth.session.secret` and `McpConfig.signingSecret` remain single strings.

## Session config

<ParamField path="secret" type="string" required>Signing secret for session JWTs. Min 32 characters.</ParamField>
<ParamField path="maxAge" type="number" default="604800 (7 days)">Session lifetime in seconds.</ParamField>
<ParamField path="cookieName" type="string" default="'theauth_session'">Declared on the type but not read: plugins that set the session cookie (OAuth, passkey) and the default token lookup always use `theauth_session`. To rename the cookie, use `sessionName` on `createCookieSessionManager` (see [Cookie options](/cookies)).</ParamField>

<ParamField path="maxConcurrent" type="number" default="no limit">Maximum non-expired sessions per user. Enforced inside `sessionManager.create`. Impersonation sessions are not counted.</ParamField>
<ParamField path="onLimit" type={`'evict-oldest' | 'reject'`} default="'evict-oldest'">What to do when `maxConcurrent` is reached. `reject` makes `create` throw `MultiSessionLimitError`.</ParamField>
<ParamField path="onRevoke" type="(revoked: Session[]) => Promise<void> | void">Called after sessions are revoked by `revoke`, `revokeAll` or limit eviction. Errors are swallowed. Used to fan out [OIDC back-channel logout](/auth/oidc-logout).</ParamField>

## Sign-in gate config

A top-level `signInGate` option. It controls forced password changes, required second factors and trusted devices on every session-issuing path. Without it only the forced password change is enforced. See [Sign-in gate](/sign-in-gate) for the response shapes and routes.

<ParamField path="require2faFor" type={`Array<'password' | 'social' | 'magic_link' | 'passkey'>`} default="['password', 'social', 'magic_link']">Methods that must pass a second factor when the user has one enabled. The default applies once the `signInGate` block is present. With no block, no method requires one.</ParamField>
<ParamField path="secret" type="string">HMAC secret for challenges and device cookies. Falls back to `auth.session.secret`.</ParamField>
<ParamField path="challengeTtlSeconds" type="number" default="300">Challenge lifetime.</ParamField>
<ParamField path="maxChallengeAttempts" type="number" default="5">Wrong codes allowed per challenge.</ParamField>
<ParamField path="trustedDevice" type="object">`{ enabled, trustDurationSeconds, maxDevices, cookieName }`. Off unless `enabled` is true. Defaults are 30 days, 10 devices and `theauth_trusted_device`.</ParamField>
<ParamField path="factors" type="TwoFactorFactor[]">Extra second factors besides TOTP.</ParamField>

## Password reset config

A top-level `passwordReset` option (not nested under `auth`). It requires `auth.session`, and its flow relies on the `username` module, so configure `username` too. If `auth.session` is missing, `theauth.passwordReset` is `null`. The caller provides an email-sending callback.

<ParamField path="sendResetEmail" type={`(email: string, token: string, url: string) => Promise<void>`} required>Callback to deliver the reset email. Receives email, raw token, and constructed URL.</ParamField>
<ParamField path="resetUrl" type="string" required>Base URL for the reset page. Token is appended as ?token=...</ParamField>
<ParamField path="tokenTtlSeconds" type="number" default="3600 (1h)">Reset token lifetime in seconds.</ParamField>
<ParamField path="revokeSessionsOnReset" type="boolean" default="true">Revoke all sessions when the password is successfully reset.</ParamField>
<ParamField path="minPasswordLength" type="number" default="8">Minimum new password length.</ParamField>
<ParamField path="maxPasswordLength" type="number" default="128">Maximum new password length.</ParamField>

<Warning>
  Always use an HTTPS URL for `resetUrl` in production. Reset tokens in plain HTTP links can be intercepted in transit or leaked via `Referer` headers.
</Warning>

```typescript
auth: {
  adapter: betterAuthAdapter(auth),
  session: {
    secret: process.env.SESSION_SECRET!,
    maxAge: 60 * 60 * 24 * 7, // 7 days
  },
},
username: {},
passwordReset: {
  resetUrl: 'https://example.com/reset-password',
  tokenTtlSeconds: 3600,
  revokeSessionsOnReset: true,
  minPasswordLength: 10,
  sendResetEmail: async (email, token, url) => {
    await mailer.send({
      to: email,
      subject: 'Reset your password',
      html: `<a href="${url}">Reset password</a>`,
    });
  },
},
```

## Session freshness config

Controls when sessions are considered "fresh" for sensitive operations. Pass it as the top-level `sessionFreshness` option; it backs `theauth.sessionFreshness`.

<ParamField path="freshAge" type="number" default="300 (5 minutes)">Maximum session age in seconds to be considered fresh.</ParamField>

## Plugins

Plugins are factories passed in the `plugins` array. They register HTTP endpoints and lifecycle hooks. All of these are exported from `@glinr/theauth/auth`:

| Plugin | What it does |
|--------|--------------|
| `passkey(config)` | WebAuthn/FIDO2 endpoints (requires a `PasskeyConfig`) |
| `magicLink(config)` | Passwordless email links |
| `emailOtp(config)` | One-time password codes via email |
| `twoFactor(config?)` | TOTP two-factor endpoints |
| `organization(config?)` | Organization endpoints with RBAC |
| `apiKeys(config?)` | Static API key endpoints |
| `admin(config?)` | User management, banning, impersonation |
| `stripe(config)` | Stripe billing integration |
| `polar(config)` | Polar payment integration |
| `gdpr()` | Data export, delete, and anonymize endpoints |
| `rateLimit(config?)` | Per-IP limits on `/auth/*` endpoints |
| `oauth(config)` | Social OAuth sign-in endpoints |

Email and password is the top-level `emailPassword` option (with `emailVerification` and `passwordReset` alongside it), see [Email and password](/auth/email-password). The `emailPassword()` plugin in the separate `@glinr/theauth-email` package is deprecated. Username and password sign-in is the top-level `username` option. `deviceAuth()` (see [Device authorization](/auth/device)) and `agentRegistration()` (see [Agent registration tokens](/agent-registration-tokens)) are exported from `@glinr/theauth/auth` too. Multi-session support lives in the session module, see [Multi-session](/multi-session).

## Environment variables pattern

Never hardcode secrets in config. Pass them through environment variables:

```typescript
const theauth = await createTheAuth({
  database: {
    provider: 'postgres',
    url: process.env.DATABASE_URL!,
  },
  auth: {
    session: { secret: process.env.THEAUTH_SESSION_SECRET! },
  },
});
```

## Dev vs production example

```typescript
// config/theauth.ts
const isDev = process.env.NODE_ENV !== 'production';

export const theauth = await createTheAuth({
  database: isDev
    ? { provider: 'sqlite-native', url: './theauth-dev.db' }
    : { provider: 'postgres', url: process.env.DATABASE_URL! },

  baseUrl: process.env.THEAUTH_BASE_URL ?? 'http://localhost:3000',

  auth: {
    session: { secret: process.env.THEAUTH_SESSION_SECRET! },
  },

  agents: {
    enabled: true,
    maxPerUser: isDev ? 100 : 25,
    auditAll: true,
    tokenExpiry: '30d',
  },
});
```

<Warning>
  `auth.session.secret` and the MCP `signingSecret` must be at least 32 characters. In production, generate them with `openssl rand -base64 32`.
</Warning>

## Next steps

<CardGroup cols={2}>
  <Card title="Migration guides" href="/migrate">
  Switch from better-auth, Clerk, or other providers.
</Card>
  <Card title="Database setup" href="/database">
  SQLite, Postgres, or MySQL configuration.
</Card>
  <Card title="Framework adapters" href="/adapters">
  Mount theAuth on your framework.
</Card>
</CardGroup>

---

# Database setup

Source: https://docs.theauth.dev/database

theAuth uses [Drizzle ORM](https://orm.drizzle.team) under the hood. You pick a provider and pass the connection URL; theAuth handles the rest.

## Choosing a provider

| Provider | Best for |
|----------|----------|
| `sqlite` | Local dev and small single-process deploys. Runs on `sql.js` (SQLite compiled to WebAssembly), no native build step |
| `sqlite-native` | Node.js servers that want the fastest SQLite. Uses `better-sqlite3`, which needs a native build |
| `postgres` | Production, high-concurrency, multi-tenant. Also Supabase and, with a bring your own handle, Neon |
| `libsql` | Turso and other libSQL servers, or a `file:` URL. See [Turso, Neon and Supabase](/hosted-databases) |
| `mysql` | Not supported in this release. Throws unless `experimental: true` is set |
| `d1` | Cloudflare Workers, using a D1 binding |

## Setup

<Tabs>
  <Tab title="SQLite">
    The `sqlite` provider uses `sql.js` (SQLite compiled to WebAssembly), which ships with `@glinr/theauth`. It needs no native build and runs on Node.js, Bun, Deno, and edge runtimes. It is in-memory only: it never writes a database file, so use it for tests, CI and throwaway demos. For a database file that survives a restart, use the **SQLite (native)** tab (`sqlite-native`) or Postgres.

    For in-memory SQLite (tests and CI), use `:memory:` as the URL:

    ```typescript
    const theauth = await createTheAuth({
      database: {
        provider: 'sqlite',
        url: ':memory:',
      },
    });
    ```

    `sql.js` keeps the database in memory and nothing is saved when the process exits. Passing a file path (for example `url: './theauth.db'`) throws `THEAUTH_SQLITE_NOT_PERSISTENT` at startup, because the file would never be written and your data would disappear on restart. theAuth turns on foreign keys (`PRAGMA foreign_keys = ON`).
  </Tab>
  <Tab title="SQLite (native)">
    The `sqlite-native` provider uses `better-sqlite3`. Install it yourself, it is an optional peer dependency and needs C++ build tools:

    ```bash
    npm install better-sqlite3
    ```

    ```typescript
    import { createTheAuth } from '@glinr/theauth';

    const theauth = await createTheAuth({
      database: {
        provider: 'sqlite-native',
        url: './theauth.db',
      },
    });
    ```

    theAuth enables WAL mode and foreign keys automatically for this provider:
    ```sql
    PRAGMA journal_mode = WAL;
    PRAGMA foreign_keys = ON;
    ```
  </Tab>
  <Tab title="Postgres">
    Install the `pg` peer dependency:

    ```bash
    npm install pg
    npm install --save-dev @types/pg
    ```

    ```typescript
    import { createTheAuth } from '@glinr/theauth';

    const theauth = await createTheAuth({
      database: {
        provider: 'postgres',
        url: process.env.DATABASE_URL!,
        // postgresql://user:password@host:5432/dbname
      },
    });
    ```

    theAuth uses `drizzle-orm/node-postgres` with a connection pool via `pg.Pool`. The `pg` package is loaded with a dynamic import, so it stays an optional peer dep.

    Timestamps are stored as integer epoch seconds, the same as SQLite. When you query the tables yourself, wrap timestamp columns in `to_timestamp(...)`, for example `SELECT id, to_timestamp(created_at) AS created FROM theauth_users;`. Other Postgres drivers (postgres.js, Neon serverless, PGlite) are not supported yet.
  </Tab>
  <Tab title="MySQL">
    MySQL is not supported in this release. `provider: 'mysql'` throws `THEAUTH_MYSQL_UNSUPPORTED` unless you pass `experimental: true`, and even then the baseline schema does not apply on a real server. Do not use it in production.
  </Tab>
  <Tab title="Cloudflare D1">
    Pass the D1 binding from your Worker environment. The `drizzle-orm/d1` driver is loaded with a dynamic import.

    ```typescript
    import { createTheAuth } from '@glinr/theauth';

    export default {
      async fetch(request: Request, env: { DB: D1Database }) {
        const theauth = await createTheAuth({
          database: {
            provider: 'd1',
            binding: env.DB,
          },
        });
        // ...
      },
    };
    ```

    The D1 provider takes a `binding` instead of a `url`. Set `skipMigrations: true` if you create the tables yourself.
  </Tab>
</Tabs>

## Auto-migration

By default, theAuth migrates the database at startup: it creates the tables your config needs (`CREATE TABLE IF NOT EXISTS`) and applies any pending versioned migration, recording each one in the `theauth_schema_migrations` ledger (see [Schema migrations](#schema-migrations)). This is safe to run on every start, and there is no manual migration step for a new project or an upgrade.

Tables are created per feature, so a small config gives you a small schema. Users and the secondary storage table are always created. Everything else depends on the config:

| Config | Tables created |
|--------|----------------|
| `agents` (or `did`) | agents, permissions, delegation chains, agent cards, approval requests, trust scores, registration tokens, DIDs, ephemeral sessions, tenants, audit logs, cost and stream events, rate limits, budget policies, ReBAC |
| `auth.session` | sessions, trusted devices, login history, JWT refresh tokens and token families |
| `mcp` | MCP servers |
| `oauth()` plugin | OAuth accounts and states, plus OAuth client, token and code tables |
| `magicLink`, `emailOtp`, `totp`, `passkey`, `username`, `phone`, `org`, `sso`, `apiKeys` | The matching table(s), whether you use the config key or the plugin form (for example `plugins: [magicLink(...)]`) |
| `passwordReset`, `magicLink`, `emailOtp` | One-time tokens |

If you add a feature later, restart once and the new tables appear. Add `agents: { enabled: true }` if you use agents, since agent tables are not created without it. The `@glinr/theauth-email` plugin creates its own `theauth_email_accounts` table.

To disable this (e.g. when you manage migrations externally with Flyway, Liquibase, or `drizzle-kit push`), set `skipMigrations: true`:

```typescript
database: {
  provider: 'postgres',
  url: process.env.DATABASE_URL!,
  skipMigrations: true,
},
```

<Warning>
  When `skipMigrations: true`, you apply the schema yourself, and theAuth checks the ledger at startup. If migrations are pending it throws `SchemaOutdatedError` listing their ids, instead of failing on the first write. See [Using skipMigrations](#using-skipmigrations).
</Warning>

## Schema migrations

theAuth keeps a ledger table, `theauth_schema_migrations`, with one row per applied migration: `id`, `name`, `checksum` and `applied_at`. Migrations are numbered and append only:

| Id | Name | What it does |
|----|------|--------------|
| `0000` | `baseline` | Every table theAuth had when the ledger was introduced (the 1.x schema). |
| `0001` | `relation-and-cache-hit` | `theauth_permissions.relation` and `theauth_audit_logs.cache_hit`. |
| `0002` | `audit-decision-code` | `theauth_audit_logs.decision_code` and the index `theauth_audit_logs_code`. |
| `0003` | `audit-recorded-at-ms` | `theauth_audit_logs.recorded_at_ms`, a backfill from `timestamp`, and the index `theauth_audit_logs_order`. |
| `0005` | `audit-event` | `theauth_audit_logs.event`, a backfill for rows it can classify, and the index `theauth_audit_logs_event` on `(agent_id, event, recorded_at_ms)`. |

On startup theAuth runs every migration that is not in the ledger yet, in order, and records it. Rules that make this safe on an existing database:

- **A database from before the ledger is adopted, not rebuilt.** If the tables exist but the ledger does not, theAuth probes the columns, treats `0000` (and `0001`, when its columns are present) as already applied, records them, and runs the rest. Your rows are untouched.
- **Reruns are no-ops.** Applied migrations are skipped. Statements tolerate "already exists", so a half finished upgrade can be resumed.
- **Applied migrations cannot change.** The ledger stores a checksum of each migration's SQL. If it no longer matches the code, startup fails with `SchemaChecksumError` rather than guessing.
- **Features still decide which tables exist.** A migration that touches the audit table only applies when your config creates it. Enable the feature later and the next start creates the table and applies the migrations that were waiting.
- **The audit hash does not change.** `decision_code`, `recorded_at_ms` and `event` are not part of the hashed payload, so existing chains keep verifying.

Read the ledger from code:

```typescript
const status = await theauth.schema.status();
// { applied: [{ id: '0000', name: 'baseline', appliedAt: Date }, ...],
//   pending: [], baselineStamped: false, mismatched: [], unknown: [] }
```

Or from the shell, with the CLI:

```bash
theauth db status                      # applied and pending, exit 1 when behind
theauth db up                          # apply pending migrations
theauth db up --dry-run                # print the statements, change nothing
theauth db sql --dialect postgres      # print the SQL for a dialect
theauth db sql --dialect postgres --from 0002
```

`--db <url>` (or `DATABASE_URL`) picks the database; `--config <file>` loads your config module so only the tables your features need are considered. Add `--json` for machine readable output.

### Using skipMigrations

With `skipMigrations: true` theAuth never changes the schema, but it still reads the ledger and throws `SchemaOutdatedError` when migrations are pending. To keep the schema in step with the SDK:

1. Generate the SQL for your dialect: `theauth db sql --dialect postgres > theauth-schema.sql`. On a database that already has the 1.x tables, start at the first pending id instead, for example `--from 0002`. That output first creates the ledger and stamps the earlier migrations as applied.
2. Review it and run it with your own tooling (Flyway, Liquibase, `psql`). Each migration ends with an `INSERT` into the ledger, so theAuth sees it as applied.
3. Deploy the new SDK version. If anything is still pending the process fails at startup and names the ids.

The same SQL ships in the repository as generated files, `packages/core/src/db/migrations/sql/<dialect>/<id>.sql`. A test fails when they drift from the TypeScript migrations.

<Warning>
  Upgrading to 2.0 with `skipMigrations: true`: apply `0002` and `0003` (or run `theauth db sql --dialect <yours> --from 0002`) before deploying. The audit queries select `decision_code` and `recorded_at_ms`, and startup now fails with `SchemaOutdatedError` until the ledger shows them applied.
</Warning>

## Schema overview

The full set of tables theAuth can create is below. Which ones exist in your database depends on your config, see above.

| Table | Purpose |
|-------|---------|
| `theauth_secondary_storage` | Key/value rows used when `secondaryStorage: 'database'` (counters, device codes) |
| `theauth_users` | Human user identities (also holds ban, Stripe and Polar billing fields) |
| `theauth_tenants` | Multi-tenant isolation |
| `theauth_agents` | AI agent identities (the core entity) |
| `theauth_permissions` | Per-agent resource+action permissions with constraints |
| `theauth_delegation_chains` | Agent-to-agent delegation records |
| `theauth_audit_logs` | Log of every agent action. The nullable `decision_code` column holds the machine-readable outcome and the nullable `event` column holds what happened (both added in 2.0, neither part of the audit hash) |
| `theauth_rate_limits` | Per-agent call-rate counters |
| `theauth_mcp_servers` | Registered MCP servers |
| `theauth_sessions` | theAuth-managed human user sessions |
| `theauth_oauth_clients`, `theauth_oauth_access_tokens`, `theauth_oauth_authorization_codes` | OAuth 2.1 client registrations, issued tokens, and short-lived PKCE codes |
| `theauth_agent_cards` | A2A capability discovery cards |
| `theauth_agent_registration_tokens` | One-time tokens for [agent registration](/agent-registration-tokens) |
| `theauth_oauth_accounts`, `theauth_oauth_states` | Linked social accounts and in-flight OAuth state (`oauth()` plugin) |
| `theauth_approval_requests` | CIBA async approval flow records |
| `theauth_approval_votes` | One row per approver on a quorum approval request |
| `theauth_trust_scores` | Graduated autonomy trust scores per agent |
| `theauth_budget_policies` | Token and call budget caps per agent/user/tenant |
| `theauth_magic_links`, `theauth_email_otps`, `theauth_phone_verifications`, `theauth_totp` | Magic link, email OTP, phone OTP, and TOTP records |
| `theauth_username_accounts`, `theauth_passkey_credentials`, `theauth_passkey_challenges` | Username/password and passkey records |
| `theauth_organizations`, `theauth_org_members`, `theauth_org_invitations`, `theauth_org_roles`, `theauth_sso_connections`, `theauth_api_keys` | Organizations, SSO connections, and API keys |
| `theauth_trusted_devices`, `theauth_one_time_tokens`, `theauth_login_history`, `theauth_jwt_refresh_tokens`, `theauth_refresh_tokens`, `theauth_refresh_token_families` | Trusted devices, one-time tokens, login history, and refresh tokens |
| `theauth_ephemeral_sessions`, `theauth_agent_dids`, `theauth_cost_events`, `theauth_stream_events` | Ephemeral sessions, agent DIDs, cost attribution, and event streaming |
| `theauth_oidc_clients`, `theauth_oidc_auth_codes`, `theauth_oidc_refresh_tokens` | OIDC provider records |
| `theauth_rebac_resources`, `theauth_rebac_relationships` | ReBAC graph |
| `theauth_federation_instances`, `theauth_federation_tokens` | Federation records |

### Upgrading the audit table

Migrations `0002` and `0003` add `decision_code TEXT` (with the index `theauth_audit_logs_code`) and `recorded_at_ms` (`INTEGER` on SQLite, `BIGINT` on Postgres, with the index `theauth_audit_logs_order`). Old rows keep a NULL code, and `recorded_at_ms` is backfilled from the second-resolution `timestamp`. Audit reads order by `recorded_at_ms`, so rows written in the same second keep their write order. Migration `0005` adds a nullable `event TEXT` column (with the index `theauth_audit_logs_event`). It backfills `event` from `action` for rows whose action is itself an event name (`delegation.create`, `delegation.revoke`, `agent.cascade.revoked`, `approval.*`, `vault.read`, `audit.*`), marks policy engine rows `policy.evaluate`, marks the remaining rows that have a decision code `authorize`, and leaves every other row NULL. None of these columns is covered by the audit hash, so existing hash chains keep verifying. If you use `skipMigrations`, see [Using skipMigrations](#using-skipmigrations).

<Warning>
  Do not run TheAuth and `theauth-go` in the same database or schema. Both create `theauth_audit_secrets` with different column types. TheAuth detects the foreign layout before it migrates (and on every start with `skipMigrations`) and throws `AUDIT_SECRETS_LAYOUT_MISMATCH` without changing the table. Use a separate database or schema for each library. See [Audit receipts and chain vectors](/audit-receipts).
</Warning>

All table and column names use `snake_case`. IDs are `text`. Timestamps are integer epoch seconds on both SQLite and Postgres.

## Peer dependencies

| Provider | Required package |
|----------|-----------------|
| `sqlite` | `sql.js` (installed with core) |
| `sqlite-native` | `better-sqlite3` |
| `postgres` | `pg` |
| `libsql` | `@libsql/client` |
| `mysql` (not supported in this release) | `mysql2` |
| `d1` | `drizzle-orm/d1` (a Cloudflare Workers binding, no extra install) |

theAuth uses dynamic imports for the SQLite and Postgres drivers so they remain optional. You will get a clear error message at startup if the required package is missing, for example:

```
TheAuth: provider "postgres" requires the "pg" package.
Install it with: npm install pg
```

## Testing with in-memory SQLite

Use `:memory:` for fast, isolated tests that need no setup or teardown:

```typescript
import { createTheAuth } from '@glinr/theauth';
import { describe, beforeEach, it } from 'vitest';

let theauth: Awaited<ReturnType<typeof createTheAuth>>;

beforeEach(async () => {
  theauth = await createTheAuth({
    database: { provider: 'sqlite', url: ':memory:' },
    agents: { enabled: true },
  });
});

it('creates an agent', async () => {
  const agent = await theauth.agent.create({
    ownerId: 'user_1',
    name: 'Test Agent',
    type: 'autonomous',
    permissions: [],
  });

  expect(agent.id).toBeDefined();
});
```

Each `createTheAuth()` call with `:memory:` gets a completely isolated database, so tests never share state.

## Related

<CardGroup cols={2}>
  <Card title="Prisma adapter" href="/prisma" icon="database">
    Read theAuth tables through an existing PrismaClient.
  </Card>
  <Card title="Configuration" href="/configuration" icon="sliders">
    Full createTheAuth() options including database and secrets.
  </Card>
  <Card title="Test utilities" href="/test-utils" icon="code">
    In-memory mock server and factories for auth-dependent tests.
  </Card>
  <Card title="Terraform" href="/terraform" icon="server">
    Provision agents and permissions as infrastructure-as-code.
  </Card>
</CardGroup>

---

# Bring your own Drizzle instance

Source: https://docs.theauth.dev/bring-your-own-drizzle

Most apps that already use Drizzle have a connection pool, credentials and a driver choice that theAuth should not duplicate. Pass that instance in and theAuth queries it directly.

```ts
import { createTheAuth } from "@glinr/theauth";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const auth = await createTheAuth({
  database: { drizzle: drizzle(pool), dialect: "postgres" },
});
```

`dialect` is `"sqlite"`, `"postgres"` or `"mysql"`. It picks the migration SQL, the same way `provider` does for the URL form. Everything else about the database section is unchanged: tables are created on boot, and `skipMigrations: true` makes boot fail with `SchemaOutdatedError` when the schema is behind (see [Database setup](/database#using-skipmigrations)).

## What you own

theAuth does not open this connection, so it never closes it. Shut the pool down yourself when the process exits.

Connection settings that `provider` applies for you become your job:

| Dialect | Set this on your own handle |
|---|---|
| `sqlite` | `PRAGMA foreign_keys = ON`. For a file, `PRAGMA journal_mode = WAL` is a good idea if more than one process opens it. |
| `mysql` | Create the pool with `timezone: "Z"`. The migration ledger stores UTC. Use a `utf8mb4` database with a binary collation (`utf8mb4_bin`) so ids compare case sensitively. |
| `postgres` | Nothing extra. Use a pooled connection string on serverless hosts. |

## Requirements for the handle

- It must expose the driver client at `db.$client`, because migrations and the schema check send raw SQL through it. The node-postgres, mysql2 and better-sqlite3 drivers do, and those are the ones this release tests. A driver that hides its client can still work if you run the migrations yourself (`theauth db sql`) and every query path goes through Drizzle, but boot calls the schema check even with `skipMigrations`, so that is untested territory.
- Do not pass a `schema` that renames or replaces theAuth's tables. theAuth uses its own table objects and never reads `db.query`, so a plain `drizzle(pool)` is enough.
- Reads must see your own writes. Single use tokens, refresh rotation and the audit chain read right after a write. If you route reads to a replica, pin auth traffic to the primary.

`createTheAuth` returns your handle as `auth.db`, and plugins receive it as `ctx.db`, so it is the same object you passed in.

## Hot path indexes

Migration `0014` adds seven indexes that agent token lookup, the delegation walks, the hourly rate limit read and the pending approval list depend on. Without them each of those scans its whole table. If you own the schema and skip theAuth migrations, create them yourself. Keep the names, so a later run of the migration finds them and leaves them alone.

| Index | Table | Columns |
| --- | --- | --- |
| `theauth_agents_token_hash` | `theauth_agents` | `token_hash` |
| `theauth_agents_owner_status` | `theauth_agents` | `owner_id, status` |
| `theauth_delegation_chains_to` | `theauth_delegation_chains` | `to_agent_id, status` |
| `theauth_delegation_chains_from` | `theauth_delegation_chains` | `from_agent_id, status` |
| `theauth_rate_limits_lookup` | `theauth_rate_limits` | `agent_id, resource, window_start` |
| `theauth_approval_requests_status` | `theauth_approval_requests` | `status, created_at` |
| `theauth_permissions_agent` | `theauth_permissions` | `agent_id` (SQLite and Postgres only) |

The SQL for each dialect:

```sql
-- SQLite and Postgres
CREATE INDEX IF NOT EXISTS theauth_agents_token_hash ON theauth_agents (token_hash);
CREATE INDEX IF NOT EXISTS theauth_agents_owner_status ON theauth_agents (owner_id, status);
CREATE INDEX IF NOT EXISTS theauth_delegation_chains_to ON theauth_delegation_chains (to_agent_id, status);
CREATE INDEX IF NOT EXISTS theauth_delegation_chains_from ON theauth_delegation_chains (from_agent_id, status);
CREATE INDEX IF NOT EXISTS theauth_rate_limits_lookup ON theauth_rate_limits (agent_id, resource, window_start);
CREATE INDEX IF NOT EXISTS theauth_approval_requests_status ON theauth_approval_requests (status, created_at);
CREATE INDEX IF NOT EXISTS theauth_permissions_agent ON theauth_permissions (agent_id);

-- MySQL (TEXT columns need a prefix length; InnoDB already indexes the foreign key columns)
CREATE INDEX theauth_agents_token_hash ON theauth_agents (token_hash(191));
CREATE INDEX theauth_agents_owner_status ON theauth_agents (owner_id, status);
CREATE INDEX theauth_delegation_chains_to ON theauth_delegation_chains (to_agent_id(191), status(16));
CREATE INDEX theauth_delegation_chains_from ON theauth_delegation_chains (from_agent_id(191), status(16));
CREATE INDEX theauth_rate_limits_lookup ON theauth_rate_limits (agent_id, resource(191), window_start);
CREATE INDEX theauth_approval_requests_status ON theauth_approval_requests (status, created_at);
```

If you copy the table objects from `@glinr/theauth` into your own Drizzle schema, they already declare these indexes (SQLite dialect), so `drizzle-kit generate` emits them.

## Typed unique conflicts

Several flows use a unique index as the tiebreaker: creating a user, appending to the audit chain, a second vote from the same approver, banning twice, MCP client and revocation inserts, erasure tombstones. They now map a driver error through `toStoreConflict`, which returns a `StoreConflictError` (exported from `@glinr/theauth`, with `code: "STORE_CONFLICT"`, `model` and the driver error as `cause`).

Detection reads structured codes first: Postgres `23505`, MySQL `ER_DUP_ENTRY` or errno `1062`, SQLite `SQLITE_CONSTRAINT_UNIQUE` and `SQLITE_CONSTRAINT_PRIMARYKEY`, SQL Server `2627` and `2601`, Prisma `P2002`, MongoDB `11000`. The old text match stays as a fallback for drivers that wrap the code away, so behavior on the existing providers is unchanged.

## Run the contract suite against your backend

`@glinr/theauth-test-utils/store-contract` holds the checks that make a backend safe for auth: 24 parallel callers fighting over one single use row with exactly one winner (magic link, email OTP, approval decision and consumption, refresh rotation), the attempt limit on OTP guesses, unique conflicts, and an audit chain that stays gapless and verifies when two processes append for one agent.

```ts
import { createTheAuth } from "@glinr/theauth";
import { runStoreContract } from "@glinr/theauth-test-utils/store-contract";

const url = process.env.TEST_DATABASE_URL;

runStoreContract(
  "my postgres",
  async () => {
    const mail = { magicTokens: [] as string[], otpCodes: [] as string[] };
    const auth = await createTheAuth({
      database: { drizzle: makeMyDrizzle(url), dialect: "postgres" },
      agents: { enabled: true, maxPerUser: 500 },
      audit: { tamperEvident: true, hmacKey: "test-key" },
      magicLink: {
        appUrl: "https://app.test",
        sendMagicLink: async (_email, token) => void mail.magicTokens.push(token),
      },
      emailOtp: { sendOtp: async (_email, code) => void mail.otpCodes.push(code) },
    });
    return { auth, mail, close: async () => {} };
  },
  { skip: !url },
);
```

Add `peer` (a second instance with its own handle on the same database) to include the two process audit check, `duplicateInsert` to check conflict typing with your driver, and `tokenFamilies` for the refresh rotation race. The repository runs this suite on sql.js, better-sqlite3 through both config forms, Postgres and MySQL; set `THEAUTH_TEST_PG_URL` and `THEAUTH_TEST_MYSQL_URL` to include the last two.

<Note>
  The suite needs `vitest` as a dev dependency. It runs against the instance's public surface, so it describes how today's Drizzle code behaves, not a future storage interface.
</Note>

---

# Turso, Neon and Supabase

Source: https://docs.theauth.dev/hosted-databases

These three hosted databases are not special cases in the schema. Turso speaks SQLite, Neon and Supabase speak Postgres, and theAuth already runs on both dialects. What changes is how you connect.

| Host | How to connect | Needs |
|---|---|---|
| Turso or any libSQL | `provider: "libsql"` | `@libsql/client` |
| Supabase | `provider: "postgres"` with a Supabase URL | `pg` |
| Neon | `database: { drizzle, dialect: "postgres" }` with Neon's WebSocket `Pool` | `@neondatabase/serverless`, `ws` on older Node |

## Turso and libSQL

```bash
pnpm add @glinr/theauth @libsql/client
```

```ts
import { createTheAuth } from "@glinr/theauth";

const auth = await createTheAuth({
  database: {
    provider: "libsql",
    url: process.env.TURSO_DATABASE_URL!, // libsql://my-db-my-org.turso.io
    authToken: process.env.TURSO_AUTH_TOKEN!,
  },
});
```

`url` also takes `https://`, `wss://` and `file:./local.db`, so the same code runs against a local file in development. `authToken` is not needed for a `file:` URL. Tables are created on boot, and `skipMigrations: true` makes boot fail with `SchemaOutdatedError` if the schema is behind, like the other providers.

libSQL is SQLite on the wire, so the schema is the SQLite one: ids are text and timestamps are integer epoch seconds. Foreign keys are on by default in the client. The `libsql` provider does not set `journal_mode` or `busy_timeout`, because those belong to the server. If you open a `file:` database from more than one process, set them yourself with a bring your own handle ([Bring your own Drizzle instance](/bring-your-own-drizzle)).

Want your own client, for example an embedded replica with `syncUrl`? Pass it in:

```ts
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";

const client = createClient({ url: "file:replica.db", syncUrl: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN! });
const auth = await createTheAuth({ database: { drizzle: drizzle(client), dialect: "sqlite" } });
```

The embedded replica form was not run. Writes on a replica go to the primary, and theAuth reads straight after writing (single use tokens, refresh rotation), so check that your replica sync mode gives read your writes before you rely on it.

## Supabase

Supabase is Postgres. Use the `postgres` provider with a connection string from the dashboard's Connect dialog. Which string you use matters:

| Mode | Port | Use it for |
|---|---|---|
| Direct | 5432 | A long running server with IPv6 or the IPv4 add-on. Run migrations here. |
| Session pooler | 5432 | A long running server on an IPv4 only network. |
| Transaction pooler | 6543 | Serverless and edge functions. |

```ts
const auth = await createTheAuth({
  database: { provider: "postgres", url: process.env.SUPABASE_DB_URL! },
});
```

Supabase asks for SSL, because many drivers default to `prefer`, which can fall back to plain text. With the `pg` driver the exact spelling matters. In `pg` 8.20, the version this repository tests with, `sslmode=require` is an alias for `verify-full`, so `pg` checks the server certificate against your system certificate store, and a Supabase certificate chain can fail that check. Pick one:

- Verify properly: download the root certificate from Database settings in the Supabase dashboard and use `?sslmode=verify-full&sslrootcert=/path/to/supabase-root.crt`.
- Encrypt without verifying, which is what Supabase's own `require` does: `?uselibpqcompat=true&sslmode=require`.

I did not run either against a real Supabase project. The alias behavior comes from running `pg-connection-string` 2.14.1 on those three URLs. The transaction pooler does not support prepared statements. theAuth never creates a named prepared statement, so nothing needs turning off. The suite below ran against a transaction mode pooler with prepared statements disabled and passed.

Two things to know:

- On serverless, keep the pool small. `provider: "postgres"` opens a `pg` pool with its default size, so a function that scales to many instances multiplies it. The transaction pooler absorbs that. If you need a size of one, use bring your own Drizzle with `new Pool({ connectionString, max: 1 })`.
- Do not point theAuth at the `auth` schema or reuse Supabase Auth tables. theAuth creates its own `theauth_*` tables in `public`. Row Level Security is off on them, so do not expose them through the Data API: turn on RLS with no policies, or revoke access for the `anon` and `authenticated` roles. Your server connects as a role that bypasses RLS.

## Neon

Neon serves Postgres over TCP and over WebSocket. The WebSocket `Pool` from `@neondatabase/serverless` is a drop in for `pg`, and supports the interactive transactions theAuth uses. The HTTP driver (`neon-http`) does not support them, so do not use it.

```bash
pnpm add @glinr/theauth @neondatabase/serverless drizzle-orm
```

```ts
import { createTheAuth } from "@glinr/theauth";
import { Pool } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-serverless";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const auth = await createTheAuth({
  database: { drizzle: drizzle({ client: pool }), dialect: "postgres" },
});
```

On Node 21 and older there is no global `WebSocket`. Install `ws` and set `neonConfig.webSocketConstructor = ws` before you create the pool. Node 22 has one built in.

On an edge runtime a WebSocket cannot outlive the request. Create the pool and the instance inside the request handler and call `pool.end()` before you return, as Neon's driver docs describe. On a Node server, create them once.

If you are on a Node server and do not need Neon's WebSocket driver, `provider: "postgres"` with the normal Neon connection string works as well. Use the pooled (`-pooler`) host on serverless.

## What was run

- **libSQL.** The store contract suite (`runStoreContract`, 14 checks) against a real libSQL file database, with the `libsql` provider and with a bring your own `drizzle-orm/libsql` handle. It covers 24 parallel callers on one single use row, unique conflicts, an audit chain written by two handles and the hourly budget. Plus a restart that keeps data, and `skipMigrations` on an existing and an empty database. Not run: a remote Turso database, `libsql://` and `wss://` URLs, embedded replicas.
- **Supabase.** The same suite against local Postgres 14 behind PgBouncer 1.26 in transaction pooling mode with `max_prepared_statements = 0`, three runs, ten parallel callers. Not run: a real Supabase project, Supavisor, `sslmode=require` against Supabase's certificates.
- **Neon.** A scratch project with the packed `@glinr/theauth`, `@neondatabase/serverless` 1.2.0 and `ws`, using the WebSocket `Pool` through a small local bridge from WebSocket frames to a local Postgres 14 (Neon's wire protocol is plain Postgres messages inside the frames). Migrations ran, a user, an agent and allow and deny decisions worked, and 24 parallel calls against a budget of 10 admitted no more than 10. Not run: a real Neon endpoint, so the `-pooler` host, cold starts and Neon's own proxy are untested. The Neon driver is not a dependency of this repository, so this is a manual check and not part of the test suite.

The pooler suite is in `packages/core/tests/store-contract/pg-pooler.test.ts` and runs when `THEAUTH_TEST_PG_POOLER_URL` points at an empty database behind a transaction mode pooler.

One behavior to expect behind any pooler: the hourly call budget reserves before it checks, so when a burst is larger than the budget it can refuse calls that would have fit. It never admits more than the budget. Under a pooler that interleaves the reservations, a burst of 24 calls against a budget of 10 admitted between one and three.

## Run the suite against your own database

The suites above run on a local file and a local Postgres. To check a real Turso, Neon or Supabase database, point the opt-in tests at it. They live in `packages/core/tests/store-contract/hosted.test.ts` and are skipped unless their variable is set, so a plain `vitest run` never opens a network connection.

| Variable | Value |
|---|---|
| `THEAUTH_TEST_TURSO_URL`, `THEAUTH_TEST_TURSO_AUTH_TOKEN` | `libsql://...` URL and token of a throwaway Turso database |
| `THEAUTH_TEST_NEON_URL` | Neon connection string (the `-pooler` host works too) |
| `THEAUTH_TEST_SUPABASE_URL` | Supabase connection string: direct, session pooler or transaction pooler |
| `THEAUTH_TEST_HOSTED_RESET=1` | Drop the `theauth_*` tables of each configured database before and after its suite |

```bash
cd packages/core
THEAUTH_TEST_SUPABASE_URL='postgres://...:6543/postgres?uselibpqcompat=true&sslmode=require' \
  npx vitest run tests/store-contract/hosted.test.ts
```

Use an empty database you can throw away. The store contract suite (the same 14 checks as above, ten parallel callers) writes users, agents, sessions and audit rows with random ids, and does not clean up after itself. `THEAUTH_TEST_HOSTED_RESET=1` removes tables whose names start with `theauth_` and nothing else. Each configured host also gets a restart check: data written by one instance is read by a second one booted with `skipMigrations: true`, and the schema reports nothing pending.

If `@neondatabase/serverless` is installed in `packages/core`, a second Neon suite runs through its WebSocket `Pool` (the path from the Neon section). The package is not a dependency of this repository, so add it with `pnpm add -D @neondatabase/serverless` while you try it and drop the change afterwards.

I ran the file against a local libSQL file (as `THEAUTH_TEST_TURSO_URL`) and a local Postgres 14 (as `THEAUTH_TEST_NEON_URL`) to check the wiring: the suites ran and passed, and every other suite was skipped. I did not run it against a real Turso, Neon or Supabase endpoint.

---

# Which storage should I pick

Source: https://docs.theauth.dev/choose-storage

theAuth has two separate storage decisions, and people often mix them up.

1. **The database** holds users, sessions, agents, delegations and the audit log. It has to be durable. You choose it with `database.provider`.
2. **Secondary storage** holds small, short-lived, high-churn values: rate-limit counters, device login codes. It only needs fast reads, writes and an atomic counter. You choose it with `secondaryStorage`.

The default for the second one is process memory, which is correct on a laptop and quietly wrong almost everywhere else. Most of the "it works locally" problems in [Troubleshooting](/troubleshooting) come from that default.

## Start from where you deploy

| You run | Database | Secondary storage |
|---|---|---|
| A laptop or CI job | `sqlite` with `:memory:` | memory (default) |
| One long-lived Node server, one process | `postgres`, or `sqlite-native` for a small app | memory is fine; `"database"` if you want counters to survive a restart |
| Several instances behind a load balancer | `postgres` | `"database"` (no new infrastructure) or Redis (more throughput) |
| Vercel or another serverless host | `postgres` through a pooled connection string (Supabase and Neon: see [Turso, Neon and Supabase](/hosted-databases)), or `libsql` for Turso | `"database"` or Redis over HTTP such as Upstash. Never memory. |
| Cloudflare Workers | `d1` | `"database"` on D1 for anything that must be exact, KV only for soft limits |

## The database, in one paragraph each

**`sqlite` (sql.js).** SQLite compiled to WebAssembly. No native build, runs on Node, Bun, Deno and edge runtimes. The whole database lives in memory and is never written to disk, so it only accepts `url: ":memory:"`. A file URL throws `THEAUTH_SQLITE_NOT_PERSISTENT` at startup instead of silently losing your data on restart. Good for tests, CI and throwaway demos. For a database file use `sqlite-native`.

**`sqlite-native` (better-sqlite3).** Real SQLite on disk, fast, one process at a time in practice. Needs a native build (install `better-sqlite3` yourself). A good fit for a single VPS where you want zero database operations. Back up the file.

**`postgres`.** The default recommendation for anything with more than one instance or any real traffic. Needs the `pg` package. Use a pooler on serverless.

**`mysql`.** Not supported in this release. `provider: "mysql"` throws `THEAUTH_MYSQL_UNSUPPORTED` unless you pass `experimental: true`. See [What is tested per database](#what-is-tested-per-database).

**`libsql`.** SQLite served by libSQL: Turso, or a local `file:` URL. Needs `@libsql/client`. See [Turso, Neon and Supabase](/hosted-databases).

**`d1`.** Cloudflare D1 through a Worker binding. Use it when the app lives on Workers. Pass the binding instead of a URL.

Setup snippets for each are on [Database setup](/database).

## What is tested per database

All providers share one versioned schema (`theauth_schema_migrations`, see [Database setup](/database#schema-migrations)). What the repository tests on each:

| Provider | Tested in CI | Tested by hand |
|----------|--------------|----------------|
| `sqlite`, `sqlite-native` | Every test file, on every change (fresh create, upgrade from the 1.2.0 schema, stamped baseline, rerun, checksum mismatch, `skipMigrations`) | n/a |
| `postgres` | Not yet | The dialect suite runs the full instance (users, sessions, agents, delegation, audit chain, MCP OAuth stores, GDPR erasure) against Postgres 14 when `THEAUTH_TEST_PG_URL` is set |
| `libsql` | Not yet | The store contract suite (14 checks) against a real libSQL file, through the provider and through a bring your own handle. A remote Turso database is untested |
| `mysql` | No | Not supported in this release |
| `d1` | Not yet | Not yet. The generated SQLite SQL (`theauth db sql --dialect sqlite`) is applied with `skipMigrations: true` |

> Postgres works through the `pg` driver. We run the full test suite against Postgres 14 by hand before each release; it is not in CI yet. Timestamps are stored as integer epoch seconds, the same as SQLite, so use `to_timestamp(created_at)` when you query the tables yourself. The store contract also ran behind a transaction mode pooler (PgBouncer) and, through a bring your own handle, on Neon's WebSocket driver against a local Postgres (see [Turso, Neon and Supabase](/hosted-databases)). Other Postgres drivers (postgres.js, PGlite) are not supported yet.
>
> MySQL is not supported in this release. `provider: "mysql"` throws unless you pass `experimental: true`, and even then the baseline schema does not apply on a real server. We will turn it on once the same suite passes on MySQL 8.4.

## SQL Server, and why there is no `kysely` option yet

You cannot run theAuth on SQL Server today, and there is no `database: { kysely, dialect }` config. The reason is narrow. Core still talks to one Drizzle handle in about 700 places. Only two parts go through a store layer that another backend can implement: the single-use state (magic links, OTP guesses, refresh token claims, approvals, enrollment uses, the hourly budget) and the audit chain write path. Drizzle has no SQL Server driver, so an instance cannot boot on it, and a config option that accepted a Kysely handle but still needed a second, Drizzle-compatible connection to the same database would only mislead.

What exists is internal and tested. A Kysely kernel (`packages/core/src/db/store/kysely`) implements the store contract for SQLite, Postgres, MySQL and SQL Server: conditional claims decided by the affected row count, guarded increments, atomic batches, interactive transactions and typed unique conflicts. A generator (`packages/core/src/db/ddl/mssql.ts`) writes SQL Server DDL for every core table from the Drizzle schema plus the indexes the migrations declare. Both are meant for the remaining aggregate ports, after which the option can be exposed.

What was run, on this repository's contract suites:

| Backend | Kernel and repository contract (59 checks) | Instance contract with the kernel routed in (14 checks) |
|---------|------|------|
| Kysely on SQLite (better-sqlite3 file) | passed | passed |
| Kysely on Postgres 14 (local) | passed | passed |
| Kysely on MySQL 8.4 (Docker) | passed | passed |
| Kysely on SQL Server (Azure SQL Edge 15.0 in Docker) | passed | not possible, no Drizzle driver |

The kernel contract covers each kernel primitive, value round trips, the single-use repositories under 24 parallel callers, and the audit repository: one test per hashed audit field (the hash is recomputed from what the backend returns), a gapless chain from parallel writers, and one chain from two processes. The instance contract is the full `runStoreContract` suite with the Kysely kernel taking over the single-use and audit writes while Drizzle serves everything else on the same database.

Limits you should know about:

- SQL Server DDL is generated for the current schema only. There is no migration ledger for it, so an existing table is never altered. Rerunning the statements is safe.
- Text columns use a binary collation (`Latin1_General_100_BIN2`) because the server default ignores case. A text column that is part of an index has a bounded length: 191 characters for ids, and what is left of the 1700 byte index limit for other columns (850 characters at most). Values longer than that fail to insert.
- A unique index over a nullable column is created as a filtered index (`WHERE column IS NOT NULL`), because SQL Server treats NULLs as equal in a unique index.
- Trailing spaces are ignored in comparisons on SQL Server and on MySQL with the binary collation. Two tokens that differ only by trailing spaces collide there.
- Reads and claims must go to the primary. A Kysely setup that routes reads to a replica breaks claim then read flows such as refresh token reuse detection.
- The Azure SQL Edge image used for the run was retired by Microsoft in 2025. It was the one that runs on Apple silicon. The generated statements stay with features from SQL Server 2016 on (filtered indexes, OFFSET and FETCH, 1700 byte index keys), but only the SQL Server 2019 engine in the Edge image was run.

## Secondary storage, in one paragraph each

| Backend | Counts exactly | Shared between instances | Pick it when |
|---|---|---|---|
| memory | yes | no | One process, or you accept that every instance counts separately. |
| `"database"` | yes | yes | You already run a database and do not want another service. This is the safe default for multi-instance. |
| `redisStorage(client)` | yes | yes | High request volume, or you want counters off your main database. Works with ioredis, node-redis and `@upstash/redis`. |
| `cloudflareKvStorage(kv)` | no | eventually | Soft limits on Workers only. KV has no atomic increment and writes take time to appear elsewhere. |
| `defineSecondaryStorage({...})` | only if you provide `incr` | depends | You have a store the others do not cover. |

The `"database"` backend creates one table, `theauth_secondary_storage`, and increments counters with a single guarded `UPDATE`, so it is exact on every supported database. Expired rows are removed lazily; run `databaseStorage(db).purgeExpired()` from a cron if you have a large keyspace.

You can choose per feature. A common production setup on Workers is exact storage for device codes and soft storage for rate limits:

```ts
const theauth = await createTheAuth({
  database: { provider: 'd1', binding: env.DB },
  secondaryStorage: {
    default: 'database',
    rateLimit: cloudflareKvStorage(env.RATE_KV), // soft limits are fine
    deviceCodes: 'database',                     // must be reliable
  },
});
```

More detail, including the interface for a custom store, is on [Secondary storage](/secondary-storage).

## A few rules of thumb

- If you are unsure, use Postgres plus `secondaryStorage: 'database'`. It has one moving part and works on every deployment shape except Workers.
- Do not use memory for anything you would be upset to lose or to count twice: device codes and rate limits on a deployment with more than one instance.
- Do not put the main database on KV. KV is only an option for secondary storage.
- Moving from `sqlite` to Postgres later is a data copy. The table names are the same (`theauth_*`), so plan a one-off export and import, and run `createTheAuth` against the new database first so the tables exist.
- The Prisma adapter reads and writes theAuth tables from code that already uses Prisma. It does not replace the choice above. See [Prisma](/prisma).

Using the Go library instead? Its backends are different, see [Storage backends](/go/getting-started/storage-backends) and [Shared state for several replicas](/go/guides/pluggable-stores).

## Related

<CardGroup cols={2}>
  <Card title="Database setup" href="/database" icon="database">
    Connection snippets for every provider.
  </Card>
  <Card title="Secondary storage" href="/secondary-storage" icon="database">
    Interface, adapters and per-feature overrides.
  </Card>
  <Card title="Production checklist" href="/production-checklist" icon="list-check">
    Check storage and everything else before launch.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting" icon="wrench">
    Serverless and edge gotchas.
  </Card>
</CardGroup>

---

# Benchmarks

Source: https://docs.theauth.dev/benchmarks

The harness lives in `benchmarks/` in the repository. It is not published to npm and has no dependencies of its own: it loads the built SDK and uses `performance.now()` for timing.

No results are published on this page yet. Numbers will be pasted in from a CI run (see below), with the hardware they came from. Until then, treat any performance claim about TheAuth as unmeasured.

## What it measures

| Scenario | What runs |
| --- | --- |
| createAgent | `agent.create`, for one owner with a growing agent count and for a fresh owner each call |
| Token issue and verify | `agent.rotate`, `agent.validateToken` (database lookup), JWT session `createSession` and `verifySession` (stateless) |
| authorize | allow and deny, direct permission and delegation depth 1, 2 and 3, with and without an audit row per call |
| simulate | the simulator, allow and deny, at the same depths |
| Sessions | session create and validate |
| Audit append | tamper evident off and on, with 1 to 64 concurrent writers on the same agent and on separate agents, plus multiple instances on one database (Postgres and MySQL) |
| Rate limit | the in-process limiter, the memory store, and the database counter behind `maxCallsPerHour` |
| Password hash | PBKDF2 hash and verify at the default cost |

Backends: SQLite through sql.js (in memory and file), SQLite through better-sqlite3 (in memory and file), and Postgres and MySQL when a connection URL is provided (`POSTGRES_URL`, `MYSQL_URL`, or `DATABASE_URL`). Backends without a URL are skipped.

For each row the report gives p50, p95 and p99 latency, operations per second, RSS change, and the Node version, CPU model and backend it ran on.

## Run it

The full run is heavy. Do not run it on a laptop. It is meant for the manual `Benchmarks` workflow, which starts Postgres and MySQL service containers, runs everything and uploads `full.json` and `full.md` as an artifact.

To check that the harness works on your machine, run the smoke profile (a few hundred iterations, in memory SQLite only):

```bash
pnpm install --frozen-lockfile
nice -n 19 pnpm bench:smoke
```

The exact command the CI job runs:

```bash
pnpm bench:ci
```

That script builds `@glinr/theauth`, runs `node --expose-gc benchmarks/run.mjs --profile full`, then `node benchmarks/report.mjs` to write markdown tables. Output goes to `benchmarks/results/` (git ignored).

## Caveats

- Concurrency numbers are async tasks on one event loop, not threads.
- Postgres and MySQL run on the same machine as the benchmark, so network latency is close to zero.
- Shared CI runners are noisy at the tail. Repeat runs before trusting p99.
- Table sizes grow during a run, which affects operations that scan.

See [scale and architecture notes](https://github.com/glincker/theauth/blob/main/docs/scale-and-architecture.md) for the code paths these scenarios were written to test.

---

# Performance

Source: https://docs.theauth.dev/performance

These numbers come from one laptop, taken while other work was running on it. They show what changed between two builds of the same code. They are not a throughput claim for your deployment, and nothing here compares TheAuth with anything else. The general benchmark harness is described on [Benchmarks](/benchmarks); this page covers the micro benchmarks and the profile driven fixes.

## Where the numbers come from

| | |
| --- | --- |
| Machine | Apple M4 Max, 14 cores, 36 GB, macOS (Darwin 27.2) |
| Node | 22.22.2 |
| Database | SQLite through better-sqlite3 (file and in memory) |
| Load | The load average was about 14 during the runs, so wall clock timings were noisy |
| Before | The source tree as it was before the changes, rebuilt from a copy |
| After | The same entry points built from the current source |

Both sides are bundled with esbuild from source, not from `dist`, so a stale build cannot skew a run. `benchmarks/micro-build.mjs` builds either tree.

### How medians and noise are stated

Each micro benchmark is timed in batches. One run is the median over 9 batches. A result on this page is the median of several runs, with the lowest and highest run in brackets, and the run order alternated between before and after.

Wall clock was too noisy on a busy laptop (the same case moved by 30 to 50 percent between rounds, and some crypto cases swung by more than the change being measured). The micro benchmark tables below therefore use `--clock cpu`, which counts process CPU time (user plus system, all threads). With it the spread between runs was under 10 percent for most cases, and the brackets show it. The database timings are wall clock per operation; their brackets are wider and are shown too.

## What the profiles showed

`node --cpu-prof` on the existing authorize scenario put roughly half of the self time in drizzle query building and the SQLite driver, so the first lever was the number and cost of queries, not the matcher. `EXPLAIN QUERY PLAN` then showed full table scans on lookups that every authorize and token check makes. In the micro benchmarks, the top self costs were `TextEncoder.encode` (inside the key sort of the approval hash), a fresh `importKey` per call for HMAC secrets, and a fresh JWKS import per device bundle verify.

## Results

### Database paths

SQLite file, 50,000 agents with 4 permissions each, one delegation link each (a third revoked), 7 runs per setting, 3 rounds. Milliseconds per operation, median with the lowest and highest round.

| Operation | Before | After | Change |
| --- | --- | --- | --- |
| `authorize`, direct permission | 4.10 (4.07 to 4.16) | 0.077 (0.075 to 0.077) | 53x |
| `authorize`, three hop delegation chain | 9.88 (9.80 to 9.90) | 0.36 (0.36 to 0.38) | 27x |
| `agent.validateToken` style lookup by token hash | 5.33 (5.30 to 5.35) | 0.12 (0.115 to 0.117) | 46x |
| Admin pending approvals list, 100 quorum requests | 2.29 (2.29 to 2.34) | 0.51 (0.50 to 0.53) | 4.5x |

The wall clock spread inside a round reached 20 to 60 percent for some of these on the loaded machine, but the before and after ranges do not overlap. The cause is the new migration `0014-hot-path-indexes`. Without indexes, SQLite and Postgres scan `theauth_agents` (by `token_hash`), `theauth_permissions` (by `agent_id`), `theauth_delegation_chains` and `theauth_rate_limits` on every call, so the cost grows with table size. MySQL already indexes foreign key columns, so it only gets the composite and `token_hash` indexes. Databases with the MCP module enabled already had the two delegation chain indexes from migration 0007; on a 5,000 row table with MCP on, a direct `authorize` went from 0.52 to 0.076 ms and a three hop chain from 0.86 to 0.36 ms (single round, noisy).

Reproduce with:

```bash
node benchmarks/micro-build.mjs packages/core/.bench/after.mjs
node benchmarks/scale.mjs --entry packages/core/.bench/after.mjs --rows 50000 --runs 7
node benchmarks/scale.mjs --entry packages/core/.bench/after.mjs --rows 50000 --runs 7 --drop-indexes
```

The last command removes the new indexes after boot, which shows what they are worth on the current code.

### The standard harness, small tables

`benchmarks/run.mjs` (profile `smoke`, better-sqlite3 in memory, 3,000 iterations, p50, median of 5 runs). Tables here are small, so the index effect is mostly absent and the gains come from fewer queries.

| Scenario | Before (ms) | After (ms) | Change |
| --- | --- | --- | --- |
| `agent.validateToken` (database lookup and last active write) | 0.448 | 0.097 | 4.6x |
| `agent.rotate` | 0.265 | 0.110 | 2.4x |
| `agent.create`, fresh owner | 0.215 | 0.110 | 2.0x |
| `agent.create`, one owner with a growing agent count | 1.85 | 1.84 | unchanged |
| `authorize`, delegation depth 1, 2, 3 (audit off) | 0.205, 0.282, 0.358 | 0.170, 0.244, 0.315 | 1.14x to 1.21x |
| `authorize`, delegation depth 1, 2, 3 (audit row per call) | 0.252, 0.334, 0.411 | 0.214, 0.290, 0.361 | 1.14x to 1.18x |
| `authorize`, direct permission | 0.067 to 0.115 | 0.067 to 0.114 | unchanged |
| `simulate`, depth 1, 2, 3 | 0.205 to 0.407 | 0.175 to 0.364 | 1.12x to 1.17x |
| JWT `verifySession` (stateless) | 0.022 | 0.022 | unchanged |

The delegation gain comes from reading each agent's inbound links once (the liveness walk used to run one query for the live links and a second one for every link ever received) and from reading an agent and its permissions together.

### CPU hot paths, no database

Nanoseconds per operation (CPU time), median of 3 rounds of 9 batches, lowest and highest round in brackets.

| Case | Before | After | Change |
| --- | --- | --- | --- |
| Colon match, 50 permissions, hit on the last one | 4,350 (4,290 to 4,762) | 1,842 (1,621 to 1,857) | 2.4x |
| Colon match, 50 permissions, no match | 4,330 (4,268 to 4,683) | 1,847 (1,603 to 1,869) | 2.3x |
| Path match, one `*` glob hit | 512 (512 to 515) | 136 (134 to 146) | 3.8x |
| Path match, deep `**` hit | 1,077 (1,074 to 1,079) | 669 (656 to 680) | 1.6x |
| Path match, glob miss | 1,374 (1,372 to 1,386) | 695 (682 to 697) | 2.0x |
| Approval hash canonical JSON, 20 row payload | 38,556 (35,038 to 39,820) | 10,884 (10,740 to 12,374) | 3.5x |
| Device bundle verify (JWKS object) | 92,650 (91,955 to 92,675) | 63,530 (63,205 to 64,405) | 1.5x |
| Device bundle sign | 73,530 (71,510 to 78,040) | 42,560 (40,760 to 43,260) | 1.7x |
| MCP `validateAccessToken`, HS256, offline mode | 24,250 (23,680 to 27,815) | 18,995 (18,455 to 20,300) | 1.3x |
| MCP `validateAccessToken`, HS256, cached mode | 26,155 (24,670 to 27,160) | 20,965 (18,670 to 21,220) | 1.25x |
| MCP `validateAccessToken`, HS256, live mode, 5,000 entry denylist | 26,770 (24,165 to 33,920) | 18,935 (18,240 to 20,130) | 1.4x |
| Stateless session `getSession` | 20,012 (19,992 to 23,261) | 14,145 (13,866 to 15,036) | 1.4x |
| DPoP proof verify (ES256) | 123,155 (119,765 to 126,445) | 120,880 (119,815 to 123,025) | no change |
| Device bundle offline decision | 120 (119 to 122) | 128 (126 to 131) | about 7 percent slower |

Two honest notes. The DPoP proof carries a new public key every time, so there is no key to cache and nothing changed. The offline bundle decision with two permissions got a few nanoseconds slower, because the new colon matcher pays a little more than `split` on very short inputs; it wins as soon as a list has more than a handful of entries.

The MCP validation figures use the HMAC secret path with a fake revocation store holding 5,000 denylist entries. The revocation lookup itself is a set or indexed read and was not the cost in any mode; the key import and JWT parsing were.

## What changed

All of these keep decisions identical. Each fast path has a test that compares it with the code it replaced over a seeded random corpus (`tests/perf-equivalence.test.ts`, `tests/delegation-liveness-queries.test.ts`, `tests/admin-api-list-queries.test.ts`, `tests/migrate-hot-path-0014.test.ts`). No check was removed or reordered.

- **Indexes** (migration `0014-hot-path-indexes`): `theauth_agents(token_hash)`, `theauth_agents(owner_id, status)`, `theauth_permissions(agent_id)`, `theauth_delegation_chains(to_agent_id, status)` and `(from_agent_id, status)`, `theauth_rate_limits(agent_id, resource, window_start)`, `theauth_approval_requests(status, created_at)`. The query plans before and after are asserted in the migration test.
- **HMAC keys**: a string secret is imported once and the non extractable `CryptoKey` is reused (at most 32 secrets, oldest dropped). Used by session cookies, the stateless session ring and MCP HS256 verification.
- **Device bundles**: the JWKS resolver and the signing key are reused while the same JWKS or JWK object keeps the same content. A fingerprint of the content is compared on every call, so a key rotated in place is picked up at once.
- **Resource matching**: the default colon matcher walks both strings with `indexOf` instead of splitting them, and the path matcher skips its table when the pattern has no `**`.
- **Approval hash**: strings that need no escaping skip the character loop, and keys are ordered by byte without encoding two copies per comparison when both are ASCII.
- **Delegation liveness**: one read per agent for inbound links, filtered in memory for the live ones, instead of two reads.
- **Admin API**: the pending approvals list reads quorum approvers for the whole page in one query instead of one per request, and the delegation list without a status filter reads only `limit` rows instead of the 500 row ceiling.

One attempt was dropped. Restricting the delegation impact view's denylist read to the links shown cost more than it saved when the denylist is small, so it is unchanged.

## Not changed on purpose

- DPoP proof keys are not cached: they come from the request.
- Nothing is cached across `authorize` calls. A cached delegation or agent row would delay a revocation, and revocations must take effect on the next call.
- The rate limit counter still does a read then a write per allowed call. Making it atomic is a separate change.

## Run the micro benchmarks yourself

```bash
pnpm --filter @glinr/theauth build   # only needed for benchmarks/run.mjs
node benchmarks/micro-build.mjs packages/core/.bench/micro.mjs
node benchmarks/micro.mjs --entry packages/core/.bench/micro.mjs --runs 9 --clock cpu --out /tmp/micro.json
```

The output file lists, for every case, the median and the lowest and highest run, so you can judge the noise on your own machine before trusting a difference.

---

# Production security checklist

Source: https://docs.theauth.dev/production-checklist

None of these items is exotic. Each one is something that works in development and fails, or worse, silently weakens things, in production. Go through the list once before launch and again after any change to your hosting.

## Secrets

- [ ] **Session secret is 32+ random characters and comes from the environment.** The cookie session manager, the refresher and JWT sessions all reject anything shorter. Generate it with `openssl rand -base64 48`.
- [ ] **Every instance has the same secret.** A mismatch looks like users randomly signed out.
- [ ] **`signingSecret` for MCP is a different value from the session secret.** It does not fall back to any other secret. If you can, publish keys and sign with ES256 or EdDSA instead of a shared HMAC secret ([MCP](/mcp)); generate keys with `generateMcpSigningKey`.
- [ ] **You know how you would rotate them.** theAuth takes one secret at a time, so rotating the session secret signs everyone out. Decide when you would accept that, and write it down.
- [ ] **No secrets in the repository or in client bundles.** OAuth client secrets, database URLs and webhook secrets are server-only.

## Cookies and transport

- [ ] **Everything is served over HTTPS**, including the OAuth callback URL you registered with each provider.
- [ ] **`NODE_ENV=production` is set, or `cookieOptions.secure: true` is.** The cookie session manager picks `Secure` from `NODE_ENV` only ([Cookie options](/cookies)).
- [ ] **`baseUrl` is the public `https://` URL** with the adapter mount path, not the internal address behind your proxy.
- [ ] **`SameSite` is `Lax` or `Strict`** unless you have a measured need for `None`. Cookies are `HttpOnly` by default, keep it that way.
- [ ] **Cross-origin front ends use an explicit CORS allowlist with credentials, never `*`.** Core sends CORS headers only on the MCP endpoints, so the rest is yours to configure ([Troubleshooting](/troubleshooting#cors-and-cross-origin-requests)).
- [ ] **State-changing requests from browsers are checked for origin.** `validateOrigin` and the double-submit helpers are exported for this ([Sessions](/sessions)).

## Proxies and rate limits

- [ ] **`trustedProxy` is set** (`trustedProxyCount` or `trustedHeader`) when you run behind a load balancer or CDN. Without it all clients share one rate-limit bucket. Do not set `trustedHeader` for a header your edge does not overwrite.
- [ ] **The `rateLimit()` plugin is installed** and covers sign-in, sign-up and password reset. Defaults are in [Rate limiting](/rate-limiting).
- [ ] **Rate limit storage is shared between instances**, and exact where it matters. Not memory on multi-instance or serverless, and not Cloudflare KV for limits you rely on ([Which storage should I pick](/choose-storage)).
- [ ] **Device-flow codes live in reliable storage** (`secondaryStorage.deviceCodes`), not in memory that restarts or splits across instances.

## Management routes and sessions

- [ ] **Your framework adapter's `authenticate` resolver matches who should see what.** The default guard accepts any signed-in user, limited to their own agents, delegations and audit rows. A custom resolver is a trust decision and sees everything, so verify admin rights inside it ([Adapters](/adapters)).
- [ ] **`trustedProxy` is set on the adapter** when you use `ipAllowlist`. The adapter ignores forwarded headers for the client IP unless `trustedProxyCount` or `trustedHeader` is configured.
- [ ] **`allowUnauthenticated` is not set anywhere.** Search your code and config.
- [ ] **Email verification is on** for password sign-up if email identity matters to you, and verification and reset links expire.
- [ ] **Compromised-password checking is on.** `createHibpModule` rejects passwords found in breaches using k-anonymity, so the password never leaves your server ([HIBP](/hibp)). Decide whether `onError` should be `'allow'` (default) or `'block'` when the API is down.
- [ ] **Sensitive actions require a fresh session.** Use session freshness so that changing a password or email, or approving a high-risk agent action, asks for a recent sign-in ([Sessions](/sessions)).
- [ ] **Two-factor is available** for accounts that can approve agent actions or manage organizations ([Two-factor](/auth/two-factor)).
- [ ] **You have a plan for imported users.** Hashes in formats other than the PBKDF2 one cannot be verified. Use `force_password_reset` or a lazy rehash ([Migrate from Auth0](/migrate/from-auth0#user-data-migration)).

## Agents and MCP

- [ ] **Agent permissions are narrow.** Grant specific resources and actions rather than wildcards, add `maxCallsPerHour` where an agent could loop, and use `requireApproval` for actions with side effects you cannot undo ([Permissions](/permissions)).
- [ ] **IP allowlists are IPv4 only.** `ipAllowlist` accepts IPv4 addresses and CIDR ranges and denies requests with no known IP. If your traffic arrives over IPv6, the allowlist will block it.
- [ ] **Delegation depth is capped** (`maxDepth`) and delegated permissions are a subset of the delegator's ([Delegation](/delegation)).
- [ ] **Agent token lifetimes are short** and agents are revoked when their owner leaves. Revocation cascades through delegations.
- [ ] **MCP clients register over HTTPS redirect URIs.** Registration rejects plain HTTP except for localhost. If you turn on Client ID Metadata Documents, read the SSRF notes on the [MCP](/mcp) page first.
- [ ] **Refresh-token reuse detection is understood.** Reusing a refresh token revokes the whole family on purpose. Make sure your clients store the newest token.

## Audit, webhooks and data

- [ ] **Audit retention is decided.** Entries are only removed by `audit.cleanup({ retentionDays })`, so choose a value that matches your policy, and ship logs to your SIEM through [event streaming](/event-streaming) if you need them elsewhere.
- [ ] **Webhook receivers verify the signature.** `X-TheAuth-Signature` is `sha256=` plus the HMAC of `<timestamp>.<raw body>`. Verify against the raw body and reject old timestamps ([Webhooks](/webhooks)).
- [ ] **Backups exist and a restore has been tried.** The database is the source of truth for users, sessions, agents and audit. SQLite files need a file-level backup.
- [ ] **Deletion and export paths are wired** if you serve people in the EU ([GDPR](/gdpr)).
- [ ] **Migrations ran on the production database**, and you know whether `skipMigrations` is on.

## Runtime

- [ ] **Clocks are synchronized** on every host that issues or verifies tokens. Verification uses no clock tolerance.
- [ ] **Database connections are pooled** if you are serverless.
- [ ] **Error output does not leak.** Do not log request bodies for sign-in routes, and do not return raw exception messages from your own handlers.
- [ ] **You have run a failed-login and a rate-limit test against production**, from outside your network, and confirmed the 401 and 429 responses look like [Error codes](/errors).

## Related

<CardGroup cols={2}>
  <Card title="Troubleshooting" href="/troubleshooting" icon="wrench">
    When one of these items goes wrong.
  </Card>
  <Card title="Which storage should I pick" href="/choose-storage" icon="scale-balanced">
    Database and secondary storage by deployment shape.
  </Card>
  <Card title="Compliance" href="/compliance" icon="shield-halved">
    Mapping features to SOC 2, GDPR and the EU AI Act.
  </Card>
  <Card title="Security policy" href="https://github.com/glincker/theauth/blob/main/SECURITY.md" icon="lock">
    How to report a vulnerability.
  </Card>
</CardGroup>

---

# Troubleshooting

Source: https://docs.theauth.dev/troubleshooting

Start with the symptom you see. Each entry says what the cause usually is and what to change. If you have an error code, jump to [Error codes you will meet](#error-codes-you-will-meet). The full list lives in [Error codes](/errors).

## Sign-in works, then the user is signed out

<AccordionGroup>
<Accordion title="The session cookie never reaches the browser">
Open the network tab and look at the `Set-Cookie` header on the sign-in or OAuth callback response. Three things stop a cookie from being stored:

- It is marked `Secure` but the page is served over plain `http://`. Browsers drop it silently. The cookie session manager sets `Secure` only when `NODE_ENV=production`; the OAuth plugin sets it when `baseUrl` starts with `https://`. Behind a TLS-terminating proxy, make sure `baseUrl` is the public `https://` URL, not the internal `http://` one.
- You set `cookieOptions.domain` to a domain the page is not on.
- The response was cached or rewritten by a proxy that strips `Set-Cookie`.

Details and defaults are on [Cookie options](/cookies).
</Accordion>

<Accordion title="Everyone is signed out after each deploy">
The session `secret` changed. Session tokens are signed with it, so a new value invalidates every cookie. Keep it in an environment variable that is the same across deploys and across instances. theAuth takes a single secret, not a list, so there is no graceful rotation: plan a rotation as a sign-out of all users. The same applies to `signingSecret` for the MCP module.
</Accordion>

<Accordion title="Sessions work on one instance and fail on another">
Instances are using different secrets, or something in the session path is in memory. Check that `auth.session.secret` is identical everywhere. If you use the `rateLimit()` plugin or device flow, also check that [secondary storage](/secondary-storage) is not left at the in-memory default.
</Accordion>

<Accordion title="Signed in on app.example.com, signed out on api.example.com">
Cookies are host-only by default. Set `cookieOptions.domain` to `.example.com` for subdomains. For different registrable domains (`example.com` and `example.org`) cookies cannot be shared at all, use [JWT sessions](/jwt-sessions) with an `Authorization` header.
</Accordion>

<Accordion title="The session is fine in the browser but middleware cannot read it">
Edge middleware cannot usually open your database, so it cannot validate a cookie session. Check only that the cookie is present there and validate in server code. See the middleware example in [Migrate from Clerk](/migrate/from-clerk#middleware-ts).
</Accordion>
</AccordionGroup>

## CORS and cross-origin requests

theAuth core adds CORS headers only to the MCP endpoints (`Access-Control-Allow-Origin: *`, with `WWW-Authenticate` exposed so browser MCP clients can read the challenge). The sign-in, session and management routes send no CORS headers. If your front end is on a different origin from the API, you add the headers yourself in your framework, for example with Hono's `cors()` middleware or Express's `cors` package, and you must allow credentials for cookies:

```ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { theAuthHono } from '@glinr/theauth-hono';

const app = new Hono();

// Register CORS before the auth routes so preflight (OPTIONS) requests are answered.
app.use('/api/theauth/*', cors({
  origin: 'https://app.example.com', // a single origin, not '*', when credentials are on
  credentials: true,
}));
app.route('/api/theauth', theAuthHono(theauth, { authenticate }));
```

On the browser side, send `credentials: 'include'` on `fetch` calls. A wildcard origin together with credentials is rejected by browsers, which shows up as "CORS error" even though the request reached the server.

For a cookie to be sent cross-site at all it needs `SameSite=None; Secure`, and recent browsers also block third-party cookies by default in some modes. If the front end and API can share a registrable domain, do that and keep `SameSite=Lax`. Otherwise use [JWT sessions](/jwt-sessions).

The device approval endpoint is stricter on purpose. `POST /auth/device/authorize` answers `403 access_denied` to a browser `Origin` that is neither the origin of `verificationUri` nor listed in `trustedOrigins`. If your verification page is on another origin, add it to `trustedOrigins` on `deviceAuth()` ([Device authorization](/auth/device)).

## OAuth and redirects

<AccordionGroup>
<Accordion title="redirect_uri_mismatch (reported by Google, GitHub and others)">
The callback URL you registered with the provider must match exactly, character for character. For the `oauth()` plugin it is `{baseUrl}/auth/oauth/callback/{provider}`. Because `baseUrl` includes the adapter mount path, with `baseUrl: 'https://app.example.com/api/theauth'` and Google the URL is `https://app.example.com/api/theauth/auth/oauth/callback/google`. Trailing slashes, `http` versus `https`, and `localhost` versus `127.0.0.1` all count as different. To compute something else, pass `buildRedirectUri` to `oauth()`.
</Accordion>

<Accordion title='"OAuth callback: unknown or already-used state value."'>
The `state` in the callback is not in the database. Common causes: the user pressed back and retried the same callback URL (state is deleted on first use), the callback went to a different deployment with a different database, or the state row was cleaned up. Restart the flow from the authorize URL.
</Accordion>

<Accordion title='"OAuth callback: state has expired. Restart the authorization flow."'>
The user took too long on the provider screen, or a link was opened much later. Send them back to `/auth/oauth/authorize/{provider}`.
</Accordion>

<Accordion title='"OAuth callback: state was issued for provider X, not Y."'>
The authorize step and the callback used different providers. Usually a copy and paste error in the callback URL registered with the provider.
</Accordion>

<Accordion title="After sign-in the user lands on a 404">
After a successful OAuth callback the plugin redirects to `{baseUrl}/` with an `auth_user` query parameter. If `baseUrl` includes the mount path (which the callback URL needs), that is a path under your mount. Handle the redirect in your app, or use [`createRedirectChain`](/redirect) to send people to where they were going.
</Accordion>

<Accordion title="MCP client says redirect_uri is invalid (INVALID_REDIRECT_URI)">
During dynamic registration, redirect URIs must be HTTPS (or `localhost` for development) and must not contain a fragment. During the authorize and consent steps the `redirect_uri` has to match one registered for the client exactly. See [MCP](/mcp).
</Accordion>
</AccordionGroup>

## Clock skew

JWT checks use the server's clock. The session and MCP token verification do not add a tolerance, so a server clock that runs ahead can reject a freshly issued token as not yet valid, and one that runs behind keeps expired tokens alive for longer. Run NTP (or your cloud's time sync) on every host that issues or verifies tokens. In containers, the host clock is what counts.

SAML single sign-on is the exception. It allows `clockSkewSeconds` of drift, 120 by default, when checking the assertion's validity window ([SSO](/auth/sso)). If an IdP is rejected with a "not yet valid" or "expired" assertion error, fix the clock first and raise `clockSkewSeconds` only as a stopgap.

Device and one-time codes use expiry timestamps stored by the server, so skew between the device and the server does not matter for them.

## Proxies, load balancers and client IPs

Rate limits are per client IP, and theAuth does not trust `X-Forwarded-For` by default because clients can forge it. When no trusted source is configured the plugin falls back to one shared `"unknown"` bucket. Behind a proxy that means every user shares one limit and a busy site returns `429 RATE_LIMITED` to everyone at once.

Tell theAuth how your edge works, in one of two ways:

```ts
const theauth = await createTheAuth({
  // ...
  // Option A: you run N proxies in front of the app.
  // With "x-forwarded-for: a, b, c" and a count of 1, the client is c.
  trustedProxy: { trustedProxyCount: 1 },

  // Option B: your edge overwrites a single header and the app is only reachable through it.
  // trustedProxy: { trustedHeader: 'cf-connecting-ip' },
});
```

The `rateLimit()` plugin takes the same two options, `trustedProxyCount` and `trustedHeader`. Count from the right: the last entry was added by the proxy closest to you, the leftmost entries were written by the client. Do not set `trustedHeader` unless the app is truly unreachable except through that edge, otherwise anyone can send the header themselves. Standard header names: `cf-connecting-ip` (Cloudflare), `x-real-ip` (nginx), `fly-client-ip` (Fly.io). Values that are not a plain address-like string are discarded.

`withRateLimit` resolves the IP the same way: it ignores forwarded headers unless you pass `trustedProxyCount` or `trustedHeader` (or the instance sets `trustedProxy`). Without one of them every caller shares the `"unknown"` key, so set it when you run behind a proxy, or pass your own `keyExtractor` built on `resolveClientIp` ([Rate limiting](/rate-limiting)).

Also behind a proxy: set `baseUrl` to the public URL with `https://`, or cookies and OAuth callback URLs will be built from the wrong scheme.

## Serverless and edge runtimes

<AccordionGroup>
<Accordion title="Rate limits and device codes behave randomly on Vercel or Workers">
The default [secondary storage](/secondary-storage) is process memory. On serverless each invocation or isolate may have its own, and they are discarded when idle, so counters reset and a device code created in one instance is unknown to the next. Use `"database"`, Redis (Upstash works over HTTP), or on Workers a D1 database. Cloudflare KV is eventually consistent and cannot count atomically, so treat KV limits as soft. See [Which storage should I pick](/choose-storage).
</Accordion>

<Accordion title="SQLite file database on Vercel">
The filesystem of a serverless function is read-only apart from a temporary directory that does not persist. The `sqlite` provider keeps data in memory and rewrites the file, so nothing survives. Use Postgres or MySQL (a serverless-friendly host such as Neon helps with connection counts), or D1 on Cloudflare.
</Accordion>

<Accordion title="Postgres connection errors under load">
Each cold instance opens its own connections, and a function that scales to hundreds of instances can exhaust the database. Point `database.url` at a pooled connection string (PgBouncer or your provider's pooler) and keep the pool small.
</Accordion>

<Accordion title="Next.js middleware cannot use theAuth">
Middleware runs on the edge runtime by default. Keep it to a cookie presence check and validate the session in route handlers and server components, which run on Node.
</Accordion>

<Accordion title='Missing package: "TheAuth: provider ... requires the ... package"'>
The `sqlite-native`, `postgres` and `mysql` providers need `better-sqlite3`, `pg` and `mysql2` respectively, installed in your app. The `sqlite` provider uses `sql.js`, which ships with theAuth, and `d1` uses `drizzle-orm/d1`. See [Database setup](/database).
</Accordion>

<Accordion title="Cookies are not marked Secure on a platform that does not set NODE_ENV">
The cookie session manager chooses `Secure` from `NODE_ENV`. Set `NODE_ENV=production`, or pass `cookieOptions.secure: true` explicitly.
</Accordion>
</AccordionGroup>

## Startup errors

| Message | Cause |
|---|---|
| `SessionManager: secret must be at least 32 characters.` | The cookie session secret is short or empty. Same text pattern for `SessionRefresher`. |
| `McpConfig.signingSecret must be at least 32 characters` | The MCP signing secret is short. `createMcpModule` also throws if neither `signingSecret` nor `signing` is set. |
| `theauth-oauth plugin requires auth.session to be configured ...` | You added `oauth()` without `auth.session`. |
| `TheAuth: unsupported database provider "..."` | `database.provider` is not one of `sqlite`, `sqlite-native`, `postgres`, `mysql`, `d1`. |
| `[theauth] <adapter>: the management routes (/agents, /delegations, /audit, /dashboard, /authorize) require authentication, but none is configured.` | The framework adapter has no `authenticate` resolver and `auth.session` is not set. Pass `authenticate`, configure `auth.session`, or for local development only set `allowUnauthenticated: true`. See [Adapters](/adapters). |

## Error codes you will meet

These are returned by the code, not invented for the docs. For the complete list see [Error codes](/errors).

**Email and password** (`@glinr/theauth-email`, JSON body `{ code, message }`):

| Code | HTTP | What it means |
|---|---|---|
| `INVALID_CREDENTIALS` | 401 | Wrong email or password. |
| `EMAIL_NOT_VERIFIED` | 401 | Sign-in blocked until the address is verified. |
| `DUPLICATE_EMAIL` | 409 | An account with that email exists. |
| `USER_NOT_FOUND` | 404 | No such user for the operation. |
| `INVALID_TOKEN`, `TOKEN_EXPIRED` | 400 | The verification or reset token is wrong or old. |
| `INVALID_PASSWORD`, `INVALID_EMAIL`, `WRONG_PASSWORD` | 400 | Validation failed. |

**Username module:** `PASSWORD_RESET_REQUIRED` (403) when the user's `force_password_reset` flag is set, which is how imported users are forced through a reset ([Migrate from Auth0](/migrate/from-auth0#user-data-migration)).

**Passwordless:** magic link answers `401 Invalid or expired magic link`, email OTP answers `401 Invalid or expired OTP code`. Both are deliberately vague about which part failed.

**Rate limits:** `429` with `{ "error": { "code": "RATE_LIMITED", "message": "Too many requests" } }` from `rateLimit()` and `withRateLimit`, with a `Retry-After` header. Plugin endpoints that declare their own limit answer `{ "error": "Rate limit exceeded" }`.

**Session freshness:** `SESSION_NOT_FRESH` when an action needs a recent sign-in. Ask the user to re-authenticate.

**Management routes:** `401 UNAUTHORIZED` "Authentication required" from the adapter guard when the caller is not signed in or `authenticate` returned `null`.

**Device flow** (`/auth/device/token`): `authorization_pending` (keep polling), `slow_down` (the interval grew by 5 seconds, use the new one), `access_denied`, `expired_token`. `/auth/device/authorize` answers `401 login_required`, `403 access_denied`, `400 invalid_request`, `429`.

**MCP and OAuth server:**

| Code | Typical cause |
|---|---|
| `INVALID_CLIENT` | Unknown `client_id`, a disabled client, or a bad `client_secret`. |
| `INVALID_GRANT` | Authorization code unknown, expired or already used; PKCE `code_verifier` failed; `redirect_uri` differs from the authorize request; refresh token expired. |
| `INVALID_GRANT` "Refresh token reuse detected; the grant has been revoked" | An old refresh token was presented again. The whole token family is revoked on purpose. The client must restart authorization. |
| `INVALID_TARGET` | `resource` is missing or is not a known MCP server, or differs from the grant's resource. |
| `INVALID_SCOPE` | A scope that is not supported, or not granted by the original authorization. |
| `LOGIN_REQUIRED` | The authorize request arrived without a signed-in user. |
| `INVALID_AUDIENCE` | The access token has no audience, or the audience is not this resource. |
| `INVALID_TOKEN`, `TOKEN_EXPIRED`, `INVALID_ISSUER` | Bad signature or claims, `exp` in the past, wrong `iss`. A revoked token reports `INVALID_TOKEN`. |
| `INSUFFICIENT_SCOPE` | The token lacks a scope the endpoint requires. The response is a `403` with a step-up challenge. |

**JWT sessions** (`createJwtSessionModule`, returned as `Result` errors): `INVALID_INPUT` for an empty token, `INVALID_TOKEN` for a bad signature, wrong issuer or audience, or a token without `sub`, and `TOKEN_EXPIRED` once `exp` has passed.

## Still stuck

<CardGroup cols={2}>
  <Card title="Error codes" href="/errors" icon="triangle-exclamation">
    The full reference.
  </Card>
  <Card title="Production checklist" href="/production-checklist" icon="list-check">
    Many of the problems above are avoided by checking these before launch.
  </Card>
  <Card title="Open an issue" href="https://github.com/glincker/theauth/issues" icon="github">
    Include the exact error code and message, your runtime, and a minimal config.
  </Card>
</CardGroup>

---

# AI coding assistant setup with theAuth docs

Source: https://docs.theauth.dev/ai-assistants

Give your assistant the real docs and API. One command sets it up.

```bash title="terminal"
npx @glinr/theauth-cli init --agent
```

It writes these files in your project root:

| Assistant | Files |
|---|---|
| Claude Code | `.claude/skills/theauth/SKILL.md` and `.mcp.json` |
| Cursor | `.cursor/rules/theauth.mdc` and `.cursor/mcp.json` |
| VS Code | `.vscode/mcp.json` |

`--target claude,cursor,vscode` limits the set (default: all three). `--dry-run` writes nothing. `--force` replaces existing files and `theauth` MCP entries.

Existing files are left alone and other MCP servers stay in place. A config that is not plain JSON (for example, one with comments) is skipped and reported. Add the entry by hand:

```json title=".mcp.json"
{
  "mcpServers": {
    "theauth": { "command": "npx", "args": ["-y", "@glinr/theauth-cli", "mcp"] }
  }
}
```

VS Code uses `servers` with `"type": "stdio"` instead of `mcpServers`.

## Run the theAuth MCP server

`theauth mcp` is a read-only stdio server.

| Tool | What it does |
|---|---|
| `search_docs` | Ranked search over these docs |
| `get_doc` | Full text of one page by slug |
| `add_plugin` | Install command and `plugins` entry. Edits no files. |
| `generate_schema` | SQLite DDL, built in memory. Needs `better-sqlite3`. |
| `inspect` | Setup summary. Env var names, never values. |

## Skill and hosted docs

The skill ([`SKILL.md`](https://github.com/glincker/theauth/blob/main/skills/theauth/SKILL.md)) covers install, plugins, agent identity, delegation and MCP auth. `init --agent` copies it into your project.

The docs site serves a remote MCP server with a search tool over these docs. It needs no install:

```bash title="terminal"
claude mcp add --transport http theauth-docs https://docs.theauth.dev/mcp
```

For Cursor, VS Code, Codex and Gemini CLI, add the same URL as a remote (HTTP) server. Config file names change between releases, so check each tool's MCP docs. The Go docs are not part of this server.

## Rule files

Copy these from [`templates/ai-rules/`](https://github.com/glincker/theauth/tree/main/templates/ai-rules):

| File | Copy to | Tool |
|---|---|---|
| `AGENTS.md` | `AGENTS.md` in your project root | Codex, Cursor, Copilot coding agent, Gemini CLI |
| `.cursor/rules/theauth.mdc` | `.cursor/rules/theauth.mdc` | Cursor |
| `CLAUDE.md` | Append to your `CLAUDE.md` | Claude Code |
| `copilot-instructions.md` | `.github/copilot-instructions.md` | GitHub Copilot |

Gemini CLI reads `GEMINI.md` by default. Copy `AGENTS.md` to `GEMINI.md`, or set `context.fileName` to `AGENTS.md` in `.gemini/settings.json`.

The rules require:

- Never log or commit tokens, secrets or `Authorization` headers.
- Verify delegation with `authorize` or `policy.evaluate`. Never trust a claimed owner.
- Keep policy deny by default. Treat `deny`, `escalate` and errors as do not run.
- Wrap every agent tool with `guardTool` or `guardTools`.
- Use DPoP for MCP tokens that leave your trust boundary.
- Match redirect URIs exactly.

## llms.txt

For assistants that fetch context from URLs:

- [`/llms.txt`](https://docs.theauth.dev/llms.txt): one line per page.
- [`/llms-full.txt`](https://docs.theauth.dev/llms-full.txt): the full text of every page.

Regenerate with `node scripts/generate-llms.mjs`. Add `--check` to fail when they are stale. The Go docs are not included.

---

# Session model

Source: https://docs.theauth.dev/sessions

theAuth has three session types:

- **Cookie sessions** for human users in browsers. A signed JWT sits in an `httpOnly` cookie. The session record lives in your database so you can revoke it instantly.
- **JWT sessions** for SPAs, mobile apps, and server-to-server flows. Stateless access tokens paired with rotating refresh tokens.
- **Ephemeral agent sessions** for AI agents (Claude, GPT-with-browsing, operator loops). Short-lived, budget-bounded credentials that expire by time or action count, whichever comes first.

This page covers cookie sessions and JWT sessions. For ephemeral agent sessions see the [agents guide](/agents).

---

## Cookie sessions

<Steps>

<Step>

### Configure the session manager

Pass `createCookieSessionManager` a config object and your `theauth.db` instance. The manager handles creation, validation, refresh, and revocation.

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { createCookieSessionManager } from '@glinr/theauth'; // [!code highlight]

export const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  baseUrl: 'https://auth.example.com',
});

export const sessions = createCookieSessionManager({ // [!code highlight]
  secret: process.env.SESSION_SECRET!, // at least 32 characters // [!code highlight]
  sessionName: 'theauth_session', // [!code highlight]
  maxAge: 7 * 24 * 60 * 60, // 7 days in seconds // [!code highlight]
  autoRefresh: true, // [!code highlight]
  cookieOptions: {
    httpOnly: true, // [!code highlight]
    secure: true, // [!code highlight]
    sameSite: 'lax', // [!code highlight]
    path: '/',
  },
}, theauth.db);
```

</Step>

<Step>

### Create a session after sign-in

Call `createSession` with a user ID after your authentication logic succeeds. `createSession` returns the session record and a ready-made `Set-Cookie` header value to send back to the browser. It does not return a `Result`: it resolves with the data or throws.

```typescript title="routes/sign-in.ts"
import { sessions } from '@/lib/theauth';

export async function POST(req: Request): Promise<Response> {
  // ... validate credentials, look up user

  // The second argument is the session metadata object
  const { setCookieHeader } = await sessions.createSession(user.id, { // [!code highlight]
    ipAddress: req.headers.get('x-forwarded-for') ?? 'unknown',
    userAgent: req.headers.get('user-agent') ?? 'unknown',
  });

  return Response.json(
    { user: { id: user.id, email: user.email } },
    {
      status: 200,
      headers: { 'Set-Cookie': setCookieHeader }, // [!code highlight]
    },
  );
}
```

</Step>

<Step>

### Validate on each request

Pass the raw `Cookie` request header to `validateSession`. It returns `{ session, refreshCookieHeader }`. `session` is `null` when the cookie is missing, invalid, expired, or revoked.

```typescript title="middleware.ts"
import { sessions } from '@/lib/theauth';

export async function middleware(req: Request): Promise<Response | null> {
  const cookieHeader = req.headers.get('cookie') ?? '';
  const { session } = await sessions.validateSession(cookieHeader); // [!code highlight]

  if (!session) {
    return Response.json({ error: 'Not authenticated' }, { status: 401 });
  }

  // session.userId, session.createdAt, session.expiresAt, session.metadata
  return null; // continue
}
```

When `autoRefresh` is on (the default), every successful `validateSession` call extends the session. Internally the old session row is deleted and a new one is created, so the session ID changes on each refresh. `validateSession` returns the new cookie as `refreshCookieHeader`. Forward it in your response.

```typescript title="middleware.ts"
const { session, refreshCookieHeader } = await sessions.validateSession(cookieHeader);

if (session && refreshCookieHeader) {
  response.headers.set('Set-Cookie', refreshCookieHeader); // [!code highlight]
}
```

</Step>

<Step>

### Revoke on sign-out

Revoking a session removes it from the database immediately. Any subsequent validation attempt returns `session: null`.

```typescript title="routes/sign-out.ts"
import { sessions } from '@/lib/theauth';

export async function POST(req: Request): Promise<Response> {
  const cookieHeader = req.headers.get('cookie') ?? '';
  const { session } = await sessions.validateSession(cookieHeader);

  if (session) {
    await sessions.revokeSession(session.id); // [!code highlight]
  }

  return new Response(null, {
    status: 204,
    headers: { 'Set-Cookie': sessions.buildLogoutCookie() }, // [!code highlight]
  });
}
```

</Step>

</Steps>

---

## Idle timeout and remember me

Both are opt-in. With neither set, sessions behave exactly as before.

```typescript
const sessions = createCookieSessionManager(
  { secret: process.env.SESSION_SECRET!, maxAge: 60 * 60 * 24 * 7, idleTimeoutMs: 30 * 60 * 1000 },
  db,
);

// "Keep me signed in" ticked: persistent cookie, 30 day absolute lifetime.
await sessions.createSession(user.id, undefined, { rememberMe: true });
// Not ticked: browser session cookie, normal `maxAge` lifetime on the server.
await sessions.createSession(user.id, undefined, { rememberMe: false });
```

**Idle timeout.** `idleTimeoutMs` denies a session with no activity for that long, in addition to the absolute `maxAge`, which still applies. It is off by default: turning it on signs users out and adds a small write to busy sessions, so it is your call. Activity is recorded in `lastActiveAt`, written at most once per `activityGranularityMs` (default 60 seconds, or a quarter of the idle timeout when that is shorter), so a busy session costs one write per window and the effective idle limit can be up to one window longer than configured. `activityGranularityMs` must be shorter than `idleTimeoutMs`. Sessions created before you enabled the feature have no stamp and are treated as active on first read, not logged out.

An idle session is deleted (and `onRevoke` is called), so it cannot return. `validateSession` still answers `session: null`; read `reason` for `idle_timeout`, `expired`, `not_found` or `invalid_token`. With the raw manager use `validateDetailed(token)`.

Environment overrides, used when the option is not set in code: `THEAUTH_SESSION_IDLE_TIMEOUT_MS`, `THEAUTH_SESSION_ACTIVITY_GRANULARITY_MS`, `THEAUTH_SESSION_REMEMBER_ME_MAX_AGE`.

**Remember me.** `rememberMe: true` uses `rememberMeMaxAge` (default 30 days) as the absolute lifetime, never more than `rememberMeMaxAgeCap` (default 90 days). `rememberMe: false` keeps the normal lifetime and, through the cookie manager or `buildSessionCookie(name, token, session)`, sends a cookie with no `Max-Age`, so the browser drops it on close. Leaving it unset keeps the old behavior (persistent cookie for `maxAge`). The choice is kept when a session is refreshed. If you build the cookie yourself from `createTheAuth` sessions, use `buildSessionCookie` so `rememberMe: false` is honored.

**Remember me on the sign in routes.** The built in sign in paths accept an optional `rememberMe` choice and pass it to `create`. Username sign in reads `rememberMe` (boolean) from the JSON body. Passkey verify (`/auth/passkey/authenticate/verify`) and the two-factor and password-change completions of the sign in gate read it from the JSON body too. The magic link verify route reads `?rememberMe=true|false`, and `/auth/oauth/authorize/:provider` reads the same query parameter and carries it to the callback in a short lived cookie. Routes that set the session cookie (passkey, OAuth callback, gate completions) send a browser session cookie for `false` and a persistent cookie until `expiresAt` for `true`. The username and magic link modules return JSON, so they report the choice as `session.rememberMe` for the code that sets your cookie. A value that is not a boolean counts as absent, and with no choice every route keeps the old persistent cookie and the normal lifetime. `true` never exceeds `rememberMeMaxAgeCap`. Email OTP, phone, one tap and anonymous sign in do not read the option yet.

The activity stamp and the remember me flag live in the session `metadata` JSON (no schema change). They are hidden from `session.metadata` and exposed as `session.lastActiveAt` and `session.rememberMe`. The stamp write re-reads the row first, but a metadata write landing in the same few milliseconds can still be overwritten by it, and the next activity repairs the stamp.

## JWT sessions

Use JWT sessions when cookies don't work: SPAs calling a separate API origin, mobile apps, server-to-server flows. Access tokens are short-lived and stateless. Refresh tokens are opaque random strings stored as SHA-256 hashes, and they rotate on every use.

```typescript title="lib/theauth.ts"
import { createJwtSessionModule } from '@glinr/theauth/auth'; // [!code highlight]

export const jwtSessions = createJwtSessionModule({
  secret: process.env.SESSION_SECRET!, // HS256 by default // [!code highlight]
  issuer: 'https://auth.example.com', // [!code highlight]
  audience: 'https://app.example.com',
  accessTokenTtl: 900, // 15 minutes // [!code highlight]
  refreshTokenTtl: 604_800, // 7 days // [!code highlight]
  customClaims: (user) => ({ email: user.email }), // [!code highlight]
}, theauth.db);
```

`customClaims` receives only `{ id, email?, name? }`. On refresh the module re-issues tokens from `{ id }` alone, so claims that depend on `email` or `name` are not present on tokens minted by `refreshSession`.

### Create a session

```typescript title="routes/sign-in.ts"
const result = await jwtSessions.createSession({
  id: user.id,
  email: user.email,
  name: user.name,
});

if (!result.success) {
  return Response.json({ error: result.error }, { status: 500 });
}

const { accessToken, refreshToken, expiresIn } = result.data; // [!code highlight]
// Store refreshToken in an httpOnly cookie or secure storage
// Send accessToken to the client for use in Authorization headers
```

### Verify on each request

Access token verification is stateless. No database round-trip.

```typescript title="middleware.ts"
const authHeader = req.headers.get('authorization') ?? '';
const token = authHeader.replace('Bearer ', '');

const result = await jwtSessions.verifySession(token); // [!code highlight]

if (!result.success) {
  return Response.json({ error: result.error }, { status: 401 });
}

const { userId, email, claims } = result.data; // [!code highlight]
```

### Refresh

Calling `refreshSession` marks the old refresh token as used and issues a new access + refresh pair. Each refresh rotates the token, so a stolen token is invalidated the moment the legitimate client refreshes.

```typescript title="routes/refresh.ts"
const result = await jwtSessions.refreshSession(refreshToken); // [!code highlight]

if (!result.success) {
  // REFRESH_TOKEN_EXPIRED, REFRESH_TOKEN_USED, REFRESH_TOKEN_NOT_FOUND
  return Response.json({ error: result.error }, { status: 401 });
}

const { accessToken, refreshToken: newRefreshToken, expiresIn } = result.data;
// Return newRefreshToken to the client to replace the old one
```

<Warning>
  If `refreshSession` returns `REFRESH_TOKEN_USED`, a previously-used token was replayed. This is a strong signal that the refresh token was stolen. The module only rejects the replayed token: it does not revoke the newer token it issued. Revoke the user's other refresh tokens with `revokeSession` and force re-authentication.
</Warning>

---

## Session freshness

Some operations should require that the user authenticated recently, not just that they hold any valid session. Changing a password, registering a passkey, or modifying billing details are examples where a session from 6 days ago is not good enough even though it is technically valid.

`createSessionFreshnessModule` wraps this check. When a session is older than `freshAge` seconds, it returns a `403` response with code `SESSION_NOT_FRESH`. Your handler returns it directly.

```typescript title="lib/freshness.ts"
import { createSessionFreshnessModule } from '@glinr/theauth'; // [!code highlight]

export const freshness = createSessionFreshnessModule({
  freshAge: 300, // 5 minutes // [!code highlight]
});
```

Use it in any endpoint that needs re-authentication assurance:

```typescript title="routes/change-password.ts"
import { sessions } from '@/lib/theauth';
import { freshness } from '@/lib/freshness';

export async function POST(req: Request): Promise<Response> {
  const cookieHeader = req.headers.get('cookie') ?? '';
  const { session } = await sessions.validateSession(cookieHeader);

  if (!session) {
    return Response.json({ error: 'Not authenticated' }, { status: 401 });
  }

  const staleResponse = freshness.guard(session); // [!code highlight]
  if (staleResponse) return staleResponse; // 403 SESSION_NOT_FRESH if too old // [!code highlight]

  // Session is fresh, safe to proceed with the sensitive operation
  // ...
}
```

When the client receives `SESSION_NOT_FRESH`, redirect them to a lightweight re-authentication page (password confirmation, passkey prompt, or similar) rather than a full sign-out. After re-auth succeeds, refresh the session's `createdAt` timestamp by issuing a new session, then retry the original operation.

<Info>
  The `freshAge` threshold is separate from `maxAge`. A session can be well within its 7-day lifetime but still be considered stale for sensitive operations. Keep `freshAge` short. 5 to 15 minutes is typical.
</Info>

---

## CSRF protection

Cookie-based sessions are vulnerable to cross-site request forgery unless you add a second layer. theAuth uses the double-submit cookie pattern: a random token is set in a separate readable cookie and must also appear in the request body or header. An attacker's page can trigger the cookie but cannot read it to reproduce the header value.

```typescript title="lib/csrf.ts"
import { generateCsrfToken, parseCookies, validateCsrfToken, validateOrigin } from '@glinr/theauth'; // [!code highlight]

// On page load or sign-in response, set a CSRF cookie
const csrfToken = generateCsrfToken(); // [!code highlight]
// Return it in a Set-Cookie header: SameSite=Strict, NOT httpOnly
// Also send it in the response body so the client can store it

// On each mutating request (POST, PUT, DELETE, PATCH)
const headerToken = req.headers.get('x-csrf-token') ?? '';
const cookieToken = parseCookies(req.headers.get('cookie') ?? '')['theauth_csrf'] ?? '';

// Both validators return { valid: boolean; reason?: string }, not a boolean
const csrf = validateCsrfToken(headerToken, cookieToken); // [!code highlight]
if (!csrf.valid) {
  return Response.json(
    { error: { code: 'CSRF_INVALID', message: 'CSRF token mismatch' } },
    { status: 403 },
  );
}

// Also validate the Origin header to defend against subdomain takeover
const origin = validateOrigin(req, ['https://app.example.com']); // [!code highlight]
if (!origin.valid) {
  return Response.json(
    { error: { code: 'ORIGIN_MISMATCH', message: 'Request origin not allowed' } },
    { status: 403 },
  );
}
```

<Warning>
  The CSRF cookie must **not** be `httpOnly`. It needs to be readable by JavaScript so the client can include its value in the `x-csrf-token` request header. Set it as `SameSite=Strict` instead.
</Warning>

---

## Revocation patterns

<Tabs>
  <Tab title="Single session">
```typescript title="Revoke one session"
// Revoke by session ID. Use this for "sign out this device"
await sessions.revokeSession(sessionId); // [!code highlight]
```
  </Tab>
  <Tab title="All sessions">
```typescript title="Revoke all sessions for a user"
// Full sign-out. Use this after a password reset or account compromise
await sessions.revokeAllSessions(userId); // [!code highlight]
```

Call this yourself after a password change or when handling a security event (suspicious login, compromised credential detected, etc.).
  </Tab>
  <Tab title="All except current">
```typescript title="Revoke all other sessions"
// There is no revokeAllSessionsExcept. List the sessions and revoke the others.
const { session } = await sessions.validateSession(cookieHeader);
const all = await sessions.listSessions(userId); // [!code highlight]
for (const other of all) {
  if (session && other.id !== session.id) await sessions.revokeSession(other.id);
}
```

This is the "sign out everywhere else" pattern, common in account security settings. Because `autoRefresh` replaces the session row on each validation, read the current session ID from the same `validateSession` call you use for the comparison.
  </Tab>
</Tabs>

---

## Session metadata

Store arbitrary data on a session at creation time. Useful for tracking the device, IP address, or a custom attribute your app needs. The object you pass as the second argument of `createSession` is stored as the session metadata. There is no separate call to update it later.

```typescript title="Creating a session with metadata"
const { setCookieHeader } = await sessions.createSession(userId, {
  ipAddress: req.headers.get('x-forwarded-for') ?? 'unknown', // [!code highlight]
  userAgent: req.headers.get('user-agent') ?? 'unknown', // [!code highlight]
  appVersion: req.headers.get('x-app-version'), // [!code highlight]
});
```

Read it back when you validate:

```typescript title="Reading session metadata"
const { session } = await sessions.validateSession(cookieHeader);

if (session) {
  console.log(session.metadata?.userAgent, session.metadata?.ipAddress);
}
```

<Info>
  Metadata is stored as a JSON column. It is not indexed, so avoid querying sessions by metadata fields. If you need to look up sessions by device or IP, store those in a separate indexed column via a custom schema extension.
</Info>

---

## Human sessions vs agent tokens

|  | Human sessions | Agent tokens |
|---|---|---|
| Format | Signed JWT in `httpOnly` cookie | `ta_...` bearer token |
| Lifetime | 7 days (configurable) | 24 hours (configurable) |
| Storage | Database row | SHA-256 hash in database |
| Revocation | Per-session or per-user bulk | Per-agent |
| CSRF | Required (cookie-based) | Not needed (header-based) |
| Freshness guard | Yes, for sensitive operations | N/A |
| Action budget | No | Yes (ephemeral sessions) |

Agent tokens use a `ta_` prefix and are stored only as a SHA-256 hash. The raw token is shown once at creation and cannot be recovered. If a token is lost, issue a new one.

---

## Error codes

The cookie session manager does not return error codes: `validateSession` returns `session: null` for a missing, invalid, expired, or revoked cookie. The JWT session module and the helpers return the following:

| Code | Status | Meaning |
|------|--------|---------|
| `SESSION_NOT_FRESH` | 403 | Session is valid but older than `freshAge` (from `freshness.guard`) |
| `INVALID_TOKEN` | 401 | JWT signature is invalid or the token has no `sub` claim |
| `TOKEN_EXPIRED` | 401 | Access token has expired |
| `ISSUER_MISMATCH` | 401 | Access token `iss` does not match the configured issuer |
| `AUDIENCE_MISMATCH` | 401 | Access token `aud` does not match the configured audience |
| `REFRESH_TOKEN_NOT_FOUND` | 401 | Refresh token does not exist in the database |
| `REFRESH_TOKEN_USED` | 401 | Refresh token was already exchanged (possible replay) |
| `REFRESH_TOKEN_EXPIRED` | 401 | Refresh token lifetime has elapsed |
| `INVALID_INPUT` | 400 | An empty token or user ID was passed |

`validateCsrfToken` and `validateOrigin` return `{ valid: false, reason }` rather than a code. The status codes in the table are what the modules suggest, not something the library sets on a response (except the `403` from `freshness.guard`).

---

## Configuration reference

### CookieSessionConfig

<ParamField path="secret" type="string" required>Signing secret for the session token, at least 32 characters.</ParamField>
<ParamField path="sessionName" type="string" default="'theauth_session'">Name of the session cookie.</ParamField>
<ParamField path="maxAge" type="number" default="604800 (7 days)">Session lifetime in seconds. After this period the session is considered expired even if it has been used recently.</ParamField>
<ParamField path="autoRefresh" type="boolean" default="true">When true, every successful `validateSession` extends the session by replacing the session row (the session ID changes) and returns a new cookie as `refreshCookieHeader`.</ParamField>
<ParamField path="idleTimeoutMs" type="number" default="off">Deny a session with no activity for this many ms. Env `THEAUTH_SESSION_IDLE_TIMEOUT_MS`.</ParamField>
<ParamField path="activityGranularityMs" type="number" default="60000 (or idleTimeoutMs / 4 if shorter)">Minimum age of `lastActiveAt` before it is rewritten. Must be below `idleTimeoutMs`. Env `THEAUTH_SESSION_ACTIVITY_GRANULARITY_MS`.</ParamField>
<ParamField path="rememberMeMaxAge" type="number" default="2592000 (30 days)">Absolute lifetime in seconds for `rememberMe: true`. Env `THEAUTH_SESSION_REMEMBER_ME_MAX_AGE`.</ParamField>
<ParamField path="rememberMeMaxAgeCap" type="number" default="7776000 (90 days)">Hard ceiling on the remember me lifetime.</ParamField>
<ParamField path="cookieOptions.httpOnly" type="boolean" default="true">Prevents client-side JavaScript from accessing the cookie. Always set this to true for session cookies.</ParamField>
<ParamField path="cookieOptions.secure" type="boolean" default="true when NODE_ENV is production, otherwise false">Only send the cookie over HTTPS. Set it to true explicitly if your deployment does not set NODE_ENV=production.</ParamField>
<ParamField path="cookieOptions.sameSite" type="'lax' | 'strict' | 'none'" default="'lax'">Controls cross-site cookie behavior. 'lax' works for most apps. 'strict' adds more protection. 'none' is only for cross-origin cookie transport (requires secure: true).</ParamField>
<ParamField path="cookieOptions.path" type="string" default="'/'">Restricts the cookie to a URL path prefix.</ParamField>
<ParamField path="cookieOptions.domain" type="string" default="undefined">Set a cookie domain to share sessions across subdomains. Omit for single-domain apps.</ParamField>

### JwtSessionConfig

<ParamField path="secret" type="string | CryptoKey | JsonWebKey" required>Signing secret. A string uses HMAC-SHA256 and must be at least 32 characters. Pass a CryptoKey or JsonWebKey for asymmetric algorithms (RS256, ES256).</ParamField>
<ParamField path="algorithm" type="string" default="'HS256'">JWT signing algorithm. Inferred from the secret type when not set: 'HS256' for strings, 'RS256' for RSA keys, 'ES256' for EC keys.</ParamField>
<ParamField path="accessTokenTtl" type="number" default="900 (15 min)">Access token lifetime in seconds. Keep this short. Access tokens are stateless and cannot be revoked before expiry.</ParamField>
<ParamField path="refreshTokenTtl" type="number" default="604800 (7 days)">Refresh token lifetime in seconds. Refresh tokens are stored hashed and can be revoked immediately.</ParamField>
<ParamField path="issuer" type="string" default="undefined">Value for the JWT 'iss' claim. Validated on every verify call.</ParamField>
<ParamField path="audience" type="string" default="undefined">Value for the JWT 'aud' claim. Validated on every verify call.</ParamField>
<ParamField path="customClaims" type={`(user: { id: string; email?: string; name?: string }) => Record<string, unknown>`} default="undefined">Function called at token creation to attach extra claims to the access token payload.</ParamField>

### SessionFreshnessConfig

<ParamField path="freshAge" type="number" default="300 (5 min)">Maximum session age in seconds before a sensitive operation requires re-authentication. The freshness guard returns 403 SESSION_NOT_FRESH when the session is older than this.</ParamField>

---

## Security best practices

<Warning>
  Always keep `cookieOptions.httpOnly: true` and set `cookieOptions.secure: true` in production. Without `httpOnly`, a single XSS vulnerability lets an attacker read the session cookie directly. Without `secure`, the cookie travels over plain HTTP and can be intercepted on any network path.
</Warning>

<Warning>
  Rotate the session `secret` periodically. All existing sessions signed with the old secret become invalid when you rotate. Plan for a brief period where some users need to sign in again, or run with two active secrets during the transition window.
</Warning>

<Info>
  Set `cookieOptions.sameSite: 'lax'` for most apps. `'lax'` allows the cookie to be sent on top-level navigations (clicking a link) but blocks it on cross-origin subresource requests, which stops most CSRF attacks without breaking OAuth redirect flows. Only use `'strict'` if you have no external links that expect to land in an authenticated state.
</Info>

<Info>
  Short access token TTLs (`accessTokenTtl`) reduce the window of exposure for a stolen token, but they increase refresh traffic. 15 minutes is a reasonable default. If you need to revoke access instantly (account ban, credential compromise), route your API through the session validation middleware instead of relying solely on stateless JWT verification.
</Info>

## Related

<CardGroup cols={2}>
  <Card title="Cookie options" href="/cookies" icon="cookie-bite">
    Cookie attributes, cross-subdomain setup, and rolling vs absolute expiry.
  </Card>
  <Card title="JWT sessions" href="/jwt-sessions" icon="key">
    Stateless access tokens with rotating refresh tokens.
  </Card>
  <Card title="Multi-session" href="/multi-session" icon="key">
    Cap concurrent sessions and build an active devices settings page.
  </Card>
  <Card title="Ephemeral sessions" href="/ephemeral-sessions" icon="stopwatch">
    Short-lived, budget-bounded agent credentials for single tasks.
  </Card>
</CardGroup>

---

# JWT sessions

Source: https://docs.theauth.dev/jwt-sessions

The JWT session module issues short-lived access tokens (JWTs) and long-lived refresh tokens. Access tokens are stateless (no DB lookup on every request). Refresh tokens are opaque random strings stored hashed in the database and rotate on every use.

It is a module you create yourself with `createJwtSessionModule`. There is no `jwtSession()` plugin, no `theauth.jwt` property, and no `/auth/jwt/*` routes. You call the module from your own handlers.

## Setup

```ts
import { createTheAuth } from '@glinr/theauth';
import { createJwtSessionModule } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
});

export const jwtSessions = createJwtSessionModule({
  secret: process.env.JWT_SECRET!, // min 32 chars for HS256
  accessTokenTtl: 900,             // 15 minutes
  refreshTokenTtl: 604800,         // 7 days
  issuer: 'https://myapp.com',
  audience: 'https://api.myapp.com',
  customClaims: (user) => ({ role: 'admin' }), // optional extra claims
}, theauth.db);
```

## Usage

Every method returns a `Result`: `{ success: true, data }` or `{ success: false, error: { code, message } }`. They do not throw for normal failures.

### Create a session

```ts
const result = await jwtSessions.createSession({
  id: user.id,
  email: user.email,
  name: user.name,
});

if (result.success) {
  const { accessToken, refreshToken, expiresIn } = result.data;
}
```

Custom claims come from the `customClaims` function in the config, which receives `{ id, email?, name? }`. `createSession` does not take a `claims` argument.

### Verify an access token

```ts
const result = await jwtSessions.verifySession(accessToken);

if (result.success) {
  const { userId, email, name, claims } = result.data;
  // claims includes custom claims plus iss, aud, iat, exp
}
```

Verification does not touch the database. It fails on a bad signature, expiry, or issuer or audience mismatch (error codes include `TOKEN_EXPIRED`, `INVALID_TOKEN`, `ISSUER_MISMATCH`, `AUDIENCE_MISMATCH`).

### Refresh

```ts
const result = await jwtSessions.refreshSession(refreshToken);

if (result.success) {
  const { accessToken: newAccess, refreshToken: newRefresh } = result.data;
}
// The old refresh token is marked used (rotation)
```

A refresh issues a new pair for the same user ID. The new access token carries `sub` and your `customClaims` output, but not the `email` or `name` from the original `createSession` call. Errors are `REFRESH_TOKEN_NOT_FOUND`, `REFRESH_TOKEN_USED`, and `REFRESH_TOKEN_EXPIRED`. Presenting an already-used refresh token fails but does not revoke the user's other refresh tokens.

### Revoke

```ts
await jwtSessions.revokeSession(refreshToken);
```

`revokeSession` marks one refresh token as used. Revoking an already-revoked or unknown token is a no-op. There is no revoke-all method on this module.

## Algorithms

| Algorithm | Config | Notes |
|-----------|--------|-------|
| HS256 | `secret: string` (at least 32 characters) | Symmetric, simplest |
| RS256 / ES256 | `secret: CryptoKey` or `JsonWebKey` | Asymmetric. The algorithm is inferred from the key type, or set `algorithm` explicitly |

The module does not publish a JWKS endpoint. If other services verify your tokens with a public key, serve that key yourself.

## HTTP endpoints

The module ships no HTTP handler. Expose the methods through routes you write, for example `POST /auth/token` calling `createSession` and `POST /auth/refresh` calling `refreshSession`.

<Info>
Access tokens are stateless. Revocation only affects refresh tokens. For immediate access token invalidation, use short TTLs (5-15 minutes).
</Info>

Set `emitAgenticJwtClaims: true` to add `agent_id`, `agent_type`, and `trust_tier` claims when `createSession` receives an `agenticContext` on the user. See [Standards alignment](/standards).

## Related

<CardGroup cols={2}>
  <Card title="Sessions" href="/sessions" icon="key">
    Cookie sessions, JWT tokens, and the full session lifecycle.
  </Card>
  <Card title="Cookie options" href="/cookies" icon="cookie-bite">
    Cookie attributes for same-domain browser setups.
  </Card>
  <Card title="MCP OAuth 2.1" href="/mcp" icon="globe">
    OAuth 2.1 token issuance for MCP tool server authorization.
  </Card>
  <Card title="Standards alignment" href="/standards" icon="book-open">
    IETF draft claims added to agent JWTs.
  </Card>
</CardGroup>

---

# Session hardening

Source: https://docs.theauth.dev/session-hardening

Everything on this page is opt-in. Nothing changes until you pass the option.

## Cookie cache (experimental)

A signed cookie that answers `validateSession` without a database read. It exists only on `createCookieSessionManager`. `createTheAuth` does not use it, and the reserved `sessionsCache` secondary storage name is not read by core.

It is not revocation safe. A revoked, banned or signed out everywhere session keeps validating from the cache until the cache cookie expires (5 minutes by default), and a cache hit also skips the idle timeout check. Nothing binds the cookie to a revocation version, so keep `maxAge` to seconds, or leave it off when a ban must take effect at once.

```typescript
const sessions = createCookieSessionManager(
  { secret: process.env.SESSION_SECRET!, cookieCache: { maxAge: 120, exclude: ["metadata.ipAddress"] } },
  db,
);
const { session, setCookieHeader, cacheCookieHeaders } = await sessions.createSession(userId);
// On sign-out, clear the session cookie and the cache cookies together:
const deletions = sessions.buildLogoutCookies(request.headers.get("cookie") ?? "");
```

Rotating the cache secret: `createSessionCookieCache` and `createStatelessSessions` take `secret: string | string[]` (or entries shaped `{ id, secret }`). With an array, the first entry signs and every entry verifies, so cookies issued before a rotation keep working. Add the new secret first and deploy, then remove the old one once the longest cookie lifetime (`maxAge`) has passed. Each secret must be at least 32 bytes, and empty or duplicate entries throw at construction. `createCookieSessionManager` still passes its single `secret` string to the cache.

The cookie is signed, not encrypted, and tied to the session token it was issued for. Payloads past about 3.6 KB are chunked; oversized ones fall back to database reads.

## Refresh token reuse grace

Strict one-time use stays the default. Set `reuseGracePeriod` (capped at 5 minutes) to tolerate a lost rotation response or two tabs refreshing at once. A token reused inside the window gets a fresh pair; outside it, the whole family is revoked as before.

```typescript
createSessionRefresher({ db, secret, session: { reuseGracePeriod: "10s" } });
```

## Per-request base URL

Set `allowedHosts` (and `trustForwardedHeaders` only behind a proxy that overwrites the headers) so OAuth redirect URIs follow the request host. Hosts outside the list fall back to `baseUrl`.

```typescript
createTheAuth({ baseUrl: "https://app.example.com", allowedHosts: ["app.example.com", "*.example.com"] });
```

## Stateless sessions and federated logout

`createStatelessSessions` keeps an IdP identity in signed cookies and the IdP tokens in an encrypted cookie, with no database. Sessions cannot be revoked server side, so pair it with `isRevoked` and `createBackChannelLogoutReceiver`, which verifies OIDC logout tokens (issuer, audience, `jti` replay) and calls your `onLogout`. `createEndSessionHandler` and `buildEndSessionUrl` cover RP-initiated logout.

### Token cookie binding

The encrypted token cookie is bound to the identity cookie, so one user's token cookie cannot be swapped into another user's session. Cookies written before the binding existed (no `v2:` prefix) are refused by default. If you upgrade with live sessions and want to keep them, set `acceptLegacyTokenCookies: true` for one cookie lifetime (`maxAge`). Legacy cookies are re-sealed in the bound format as they are read, and `onDeprecation` reports each one. Users whose cookie is refused sign in again once.

## Secure cookies by default

Session, cache, refresh and plugin cookies are `Secure` on every runtime, with no `NODE_ENV` check. See [Cookie options](/cookies) for the https, trusted proxy and localhost rules and for `__Host-` prefix validation.

---

# Cookie options

Source: https://docs.theauth.dev/cookies

theAuth only sets cookies when you use its human-auth session features. If Clerk, Auth.js, or better-auth runs your sign-in, theAuth is cookie-free, it authenticates agents with bearer tokens instead.

Cookie handling is configured on the cookie session manager (`createCookieSessionManager`, exported from `@glinr/theauth`), not through a top-level `cookies` option on `createTheAuth`. See [Sessions](/sessions) for the full manager API.

## Defaults

| Attribute | Value |
|---|---|
| Name | `theauth_session` (`sessionName`) |
| `HttpOnly` | `true` |
| `Secure` | `true` on every runtime. Never read from `NODE_ENV` |
| `SameSite` | `Lax` |
| `Path` | `/` |
| `Domain` | unset (host-only) |
| `Max-Age` | 7 days (`maxAge`), sliding by default (`autoRefresh`) |

`Secure` is on by default and does not depend on `NODE_ENV`, which is unset or different on Cloudflare Workers, Deno and Bun. Every `Set-Cookie` from core goes through one serializer (`serializeCookie` in `@glinr/theauth/session`), so the rule is the same everywhere.

- Without a request, the cookie is `Secure`.
- `resolveCookieSecure(request, { trustProxy })` (or `serializeCookie(..., { request })`) gives `Secure` for https URLs and for `x-forwarded-proto: https` when the proxy is trusted (`trustProxy: true`, or `trustedProxyCount` / `trustedHeader` set). Untrusted forwarded headers are ignored.
- It gives no `Secure` only for plain `http://localhost`, `127.0.0.1`, `[::1]` or `*.localhost` during development, or when you pass `secure: false` explicitly.

Chrome and Firefox accept `Secure` cookies on `http://localhost`, so the default also works for local development. Safari does not, so pass `secure: false` there or use https.

### Prefixes and attribute checks

The serializer throws on combinations browsers reject: a `__Host-` cookie must be `Secure`, `Path=/` and have no `Domain`; a `__Secure-` cookie must be `Secure`; `Partitioned` and `SameSite=None` require `Secure`.

## Overriding

```ts
import { createTheAuth, createCookieSessionManager } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
});

const sessions = createCookieSessionManager({
  secret: process.env.SESSION_SECRET!, // at least 32 characters
  sessionName: 'myapp_session',
  maxAge: 60 * 60 * 24 * 7, // seconds; seven days
  cookieOptions: {
    secure: true,
    sameSite: 'strict',
    domain: '.example.com',
  },
}, theauth.db);
```

`cookieOptions` accepts `httpOnly`, `secure`, `sameSite`, `path`, `domain`, `expires` and `partitioned`. `maxAge` comes from the top-level `maxAge` option.

<Warning>
Changing the cookie name on a live app signs every user out on the next request. Do it during a planned migration window or ship a middleware that reads both names for a transition period.
</Warning>

## Cross-subdomain

Share sessions across `app.example.com` and `auth.example.com` by setting a leading-dot domain.

```ts
cookieOptions: {
  domain: '.example.com',
  sameSite: 'lax',     // required for top-level nav; 'strict' would break redirects
}
```

<Info>
A leading dot is the shape browsers accept even if the spec no longer requires it. It is still the interoperable choice across every browser that matters.
</Info>

## Cross-origin (different registrable domains)

If your auth server and your app live on different registrable domains (`auth.example.com` and `app.example.org`), cookies are not enough. Use the [JWT session](/jwt-sessions) path instead. Cookies do not cross registrable domains under any `SameSite` mode that browsers accept in 2026.

## Sliding vs absolute expiry

By default (`autoRefresh: true`), every successful `validateSession` replaces the session row and returns a new cookie in `refreshCookieHeader` with a fresh `Max-Age`. A user signed in thirty days ago but still active stays signed in. Turn `autoRefresh` off to expire the session `maxAge` seconds after sign-in regardless of activity:

```ts
const sessions = createCookieSessionManager({
  secret: process.env.SESSION_SECRET!,
  maxAge: 60 * 60 * 24 * 7,
  autoRefresh: false,      // expire seven days after sign-in, regardless of activity
}, theauth.db);
```

For sensitive actions you can additionally require a recently authenticated session, see session freshness in [Sessions](/sessions).

## Reading the cookie yourself

Pass the raw `Cookie` header to `validateSession`. It returns `{ session, refreshCookieHeader }`, where `session` is `null` when the cookie is missing, invalid, expired, or revoked.

```ts
export async function GET(req: Request) {
  const cookieHeader = req.headers.get('cookie') ?? '';
  const { session, refreshCookieHeader } = await sessions.validateSession(cookieHeader);

  if (!session) {
    return new Response('Unauthorized', { status: 401 });
  }

  const headers = new Headers({ 'Content-Type': 'application/json' });
  if (refreshCookieHeader) headers.set('Set-Cookie', refreshCookieHeader);
  return new Response(JSON.stringify({ userId: session.userId }), { headers });
}
```

## Troubleshooting

<AccordionGroup>
<Accordion title="Cookie missing in production, present in dev" icon="bug">
The cookie is probably being sent without `Secure` over HTTPS, or `Secure` is set but the request is plain HTTP. `Secure` is now on by default, and browsers drop `Secure` cookies sent over plain HTTP (except on localhost in most browsers). Make sure the site runs over HTTPS, or pass `cookieOptions.secure: false` for a plain-HTTP development setup.
</Accordion>

<Accordion title="Cookie missing on a subdomain" icon="bug">
Default cookies are host-only. Set `cookieOptions.domain: '.your-domain.com'` (with the leading dot) and re-sign the user in, or issue the cookie from the top-level domain.
</Accordion>

<Accordion title="User signed out after every deploy" icon="bug">
You are probably changing the session `secret`. Session tokens are signed with it, and a new secret invalidates existing cookies. The session manager `secret` option takes a single string, so keep it stable across deploys. The stateless session cookies and the session cookie cache do support rotation: pass `secret: [newSecret, oldSecret]` (first signs, all verify) and remove the old entry after the cookie lifetime has passed. See [Session hardening](/session-hardening).
</Accordion>

<Accordion title="SameSite=Lax rejected by a mobile webview" icon="bug">
Some older mobile webviews treat `Lax` inconsistently across top-level POSTs. Set `cookieOptions: { sameSite: 'none', secure: true }` and make sure you are on HTTPS.
</Accordion>
</AccordionGroup>

## Related

<CardGroup cols={2}>
  <Card title="Sessions" href="/sessions" icon="key">
    Cookie and JWT session management with lifecycle details.
  </Card>
  <Card title="JWT sessions" href="/jwt-sessions" icon="key">
    Stateless access tokens for cross-origin and mobile setups.
  </Card>
  <Card title="Multi-session" href="/multi-session" icon="key">
    List and revoke a user's sessions.
  </Card>
  <Card title="Custom session fields" href="/custom-session" icon="sliders">
    Store application-specific data inside every session.
  </Card>
</CardGroup>

---

# One-time tokens

Source: https://docs.theauth.dev/one-time-tokens

One-time tokens are short-lived, single-use strings for flows like email verification, password resets, and invitations. The raw token is handed to the caller once and never stored, only a SHA-256 hash lives in the database. On first use (or expiry), the token is gone.

## Setup

The module is part of theAuth core. No extra plugin needed.

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
});

// Access the module
const tokens = theauth.oneTimeTokens;
```

`theauth.oneTimeTokens` is always available and uses the default config (one hour TTL). To change the default TTL, create your own instance with `createOneTimeTokenModule`:

```typescript
import { createOneTimeTokenModule } from '@glinr/theauth/auth';

const customTokens = createOneTimeTokenModule({ defaultTtlSeconds: 1800 }, theauth.db);
```

## Token purposes

Each token has a `purpose` that scopes its validity. Validation fails if the purpose at creation does not match the purpose at consumption.

| Purpose | Use |
|---------|-----|
| `email-verify` | Confirm a new email address |
| `password-reset` | Authenticate a password-reset request |
| `invitation` | Invite a user to an org or workspace |
| `custom` | Any application-specific flow |

## Creating a token

`createToken` returns the raw token exactly once. Put it in a link or hand it to your mailer, there is no way to recover it from the database later.

<Tabs>
  <Tab title="password reset">
```typescript
const result = await tokens.createToken({
  purpose: 'password-reset',
  identifier: 'alice@example.com', // email, user ID, or any scoping key
  ttlSeconds: 1800,                // 30 minutes
});

if (result.success) {
  const { token, expiresAt } = result.data;
  await mailer.send({
    to: 'alice@example.com',
    subject: 'Reset your password',
    html: `<a href="https://app.example.com/reset?token=${token}">Reset password</a>`,
  });
}
```
  </Tab>
  <Tab title="email verify">
```typescript
const result = await tokens.createToken({
  purpose: 'email-verify',
  identifier: user.id,
  ttlSeconds: 86400, // 24 hours
});

if (result.success) {
  await sendVerificationEmail(user.email, result.data.token);
}
```
  </Tab>
  <Tab title="invitation">
```typescript
const result = await tokens.createToken({
  purpose: 'invitation',
  identifier: 'invited@example.com',
  ttlSeconds: 604800, // 7 days
  metadata: { orgId: 'org_abc123', role: 'member' }, // [!code highlight]
});
```
  </Tab>
</Tabs>

The default TTL is **3600 seconds** (1 hour). Override it per call with `ttlSeconds`, or set `defaultTtlSeconds` on a module you create with `createOneTimeTokenModule` to change the default for all tokens. The raw token is 64 hex characters (32 random bytes).

## Validating a token

Call `validateToken` when the user lands on your reset or verification page. On success, the token is consumed immediately, a second call with the same token always fails.

```typescript
const result = await tokens.validateToken(
  incomingToken, // from the URL query param
  'password-reset',
);

if (!result.success) {
  // result.error.code is one of:
  // TOKEN_NOT_FOUND | TOKEN_ALREADY_USED | TOKEN_EXPIRED | TOKEN_PURPOSE_MISMATCH
  // (also INVALID_INPUT and CONSUME_TOKEN_FAILED)
  return { error: result.error.message };
}

const { identifier, metadata } = result.data;
// identifier === 'alice@example.com'
// Proceed with the reset
```

<Warning>
  `validateToken` marks the token as used before it returns. Even if your handler crashes after this call, the token cannot be reused. Handle the downstream action (password update, email confirmation) in the same request.
</Warning>

## Revoking tokens

Revoke all active tokens for an identifier when a user takes an action that makes them obsolete, for example, invalidating outstanding reset links when a user changes their password through a different flow.

```typescript
// Revoke all active password-reset tokens for this user
const result = await tokens.revokeTokens('alice@example.com', 'password-reset');

if (result.success) {
  console.log(`Revoked ${result.data.count} token(s)`);
}

// Revoke everything for this identifier (all purposes)
await tokens.revokeTokens(user.id);
```

Revocation is a soft operation, tokens are marked as used, not deleted. Only active (unused, unexpired) tokens are counted. The `purpose` argument must be one of the four purpose values.

## Attaching metadata

Pass a `metadata` object to store arbitrary data alongside the token. It is returned on successful validation.

```typescript
const result = await tokens.createToken({
  purpose: 'invitation',
  identifier: 'bob@example.com',
  metadata: { orgId: 'org_xyz', role: 'admin', invitedBy: 'alice' },
});

// On validation:
const validation = await tokens.validateToken(token, 'invitation');
if (validation.success) {
  const { orgId, role } = validation.data.metadata as { orgId: string; role: string };
  await addUserToOrg(user.id, orgId, role);
}
```

## Error codes

| Code | Cause |
|------|-------|
| `TOKEN_NOT_FOUND` | Token does not exist or was already deleted |
| `TOKEN_ALREADY_USED` | Token was consumed by a previous call |
| `TOKEN_EXPIRED` | Token's `expiresAt` is in the past |
| `TOKEN_PURPOSE_MISMATCH` | Purpose at validation does not match purpose at creation |
| `INVALID_INPUT` | Empty token, purpose or identifier, or an unknown purpose value |
| `CREATE_TOKEN_FAILED` | Database write failed |
| `CONSUME_TOKEN_FAILED` | Database update failed while marking the token used |
| `REVOKE_TOKENS_FAILED` | Database update failed during revocation |

## Security notes

**Tokens are hashed at rest.** Only a SHA-256 hash is stored. A database dump does not expose usable tokens.

**Single-use marking.** The module reads the token, then marks it used with a conditional `WHERE used = false` update before returning. It does not check how many rows the update changed, so two truly simultaneous requests with the same token could both succeed. If that matters for your flow, make the downstream action idempotent. A purpose mismatch returns `TOKEN_PURPOSE_MISMATCH` without consuming the token.

**Purpose binding prevents cross-flow reuse.** A password-reset token cannot be submitted to an email-verify endpoint.

## Related

<CardGroup cols={2}>
  <Card title="Sessions" href="/sessions" icon="key">
    Cookie and JWT sessions you can issue after a token validates.
  </Card>
  <Card title="Email templates" href="/email-templates" icon="envelope">
    Email templates for reset and verification messages.
  </Card>
  <Card title="Hooks" href="/hooks" icon="bolt">
    Lifecycle hooks you can attach to your instance.
  </Card>
  <Card title="Error codes" href="/errors" icon="triangle-exclamation">
    theAuth error codes with their HTTP status and meaning.
  </Card>
</CardGroup>

---

# Secondary storage

Source: https://docs.theauth.dev/secondary-storage

## Overview

Some state is small, short-lived and written constantly: rate limit counters, device login codes, nonces, caches. TheAuth keeps it in a **secondary storage**, a tiny key/value interface with expiry. By default that is process memory, which is fine for local development and a single server. Once you run more than one instance, or on serverless, point it somewhere shared.

```ts
import { createTheAuth, redisStorage } from "@glinr/theauth";
import Redis from "ioredis";

const theauth = await createTheAuth({
  database: { provider: "postgres", url: process.env.DATABASE_URL },
  secondaryStorage: redisStorage(new Redis(process.env.REDIS_URL)),
});
```

## Choosing a backend

| Backend | Atomic counters | Shared across instances | Notes |
| --- | --- | --- | --- |
| `"memory"` (default) | yes | no | Lost on restart. Dev and single process. |
| `"database"` | yes | yes | Uses the TheAuth database (SQLite, Postgres, MySQL, D1). No new infrastructure. |
| `redisStorage(client)` | yes | yes | Redis, Valkey, Upstash, Dragonfly. Best throughput. |
| `mongoStorage(collection)` | yes | yes | MongoDB 4.4+. TTL index cleans up expired rows. |
| `sqlStorage({ dialect, run })` | yes | yes | Any SQL database, including Microsoft SQL Server. You supply the query runner. |
| `kyselyStorage(db, { dialect, CompiledQuery })` | yes | yes | `sqlStorage` wired to a Kysely instance: SQLite and D1, Postgres, MySQL, SQL Server. |
| `cloudflareKvStorage(kv)` | **no** | eventually | See the warning below. |
| `defineSecondaryStorage({...})` | only if you supply `incr` | depends | Bring your own. |

<Warning>
Cloudflare KV is eventually consistent and has no atomic increment. Two requests hitting different locations at the same moment can read the same count, and a write can take up to a minute to be visible elsewhere. Rate limits on KV are soft: they stop casual abuse, not a determined burst. For strict counters on Workers use `"database"` with D1, a Durable Object, or Upstash Redis. Plain values such as device codes work on KV if you accept the propagation delay.
</Warning>

## Per-feature overrides

Pass one backend for everything, or an object. Each feature falls back to `default`, then to memory.

```ts
const theauth = await createTheAuth({
  database: { provider: "d1", binding: env.DB },
  secondaryStorage: {
    default: "database",
    rateLimit: cloudflareKvStorage(env.RATE_KV), // soft limits are fine here
    deviceCodes: "database",                     // must be reliable
  },
});
```

| Key | Used for |
| --- | --- |
| `rateLimit` | The `rateLimit()` plugin counters. |
| `deviceCodes` | Device authorization grants (`deviceAuth()`). |
| `oneTimeTokens`, `nonces`, `sessionsCache` | Reserved names. `theauth.secondaryStorage.for(name)` returns a namespaced store for your own code; core modules do not read them yet. |

Keys are prefixed `theauth:<feature>:` automatically, so features never collide in a shared Redis or table. A misspelled feature name throws at startup.

## Database adapter

`"database"` creates a `theauth_secondary_storage` table (it is added to the automatic migrations). `incr` is a single `UPDATE counter = counter + 1` guarded by the key, so it is atomic on every supported database. Expired rows are removed lazily; for large keyspaces call `databaseStorage(db).purgeExpired()` from a cron.

## Redis adapter

`redisStorage` accepts any client with `get`, `del`, `incr`, `pexpire` and `pttl` (node-redis spells them `pExpire` and `pTTL`, and that works too). It has no Redis dependency, so ioredis, node-redis and `@upstash/redis` all fit.

```ts
import { createClient } from "redis";
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
const storage = redisStorage(client);
```

If the process dies between `INCR` and `PEXPIRE`, the next caller notices the missing TTL and repairs it, so a counter can never become permanent.

## MongoDB adapter

`mongoStorage` takes one collection. The official `mongodb` driver (v5 and later) fits as is, and TheAuth does not depend on it.

```ts
import { MongoClient } from "mongodb";
import { mongoStorage } from "@glinr/theauth";

const client = await MongoClient.connect(process.env.MONGO_URL);
const storage = mongoStorage(client.db("app").collection("theauth_secondary_storage"));
await storage.ensureIndexes(); // TTL index on expiresAt
```

`incr` is one `findOneAndUpdate` with an aggregation pipeline, so a live counter is bumped and an expired one restarted in a single step. Reads skip rows whose `expiresAt` has passed, because MongoDB's TTL monitor only sweeps about once a minute. This needs MongoDB 4.4 or later (`$isNumber`).

This covers secondary storage only. The main database (users, sessions, agents, audit) is a SQL schema and still runs on SQLite, Postgres or D1. See [Which storage should I pick](/choose-storage).

## Kysely and SQL Server

`sqlStorage` only uses INSERT, UPDATE, DELETE and primary key SELECT, and builds placeholders for the dialect (`?`, `$1`, `@p1`). With Kysely, pass its `CompiledQuery` class so TheAuth can run parameterised statements without importing Kysely:

```ts
import { CompiledQuery, Kysely, MssqlDialect } from "kysely";
import { kyselyStorage } from "@glinr/theauth";

const db = new Kysely({ dialect: new MssqlDialect({ /* tarn + tedious */ }) });
const storage = kyselyStorage(db, { dialect: "mssql", CompiledQuery });
await storage.ensureTable(); // creates theauth_secondary_storage if missing
```

Without Kysely, give `sqlStorage` any function that runs one statement and reports `affectedRows`:

```ts
const storage = sqlStorage({
  dialect: "postgres",
  run: async (text, params) => {
    const r = await pool.query(text, params);
    return { rows: r.rows, affectedRows: r.rowCount ?? 0 };
  },
});
```

Counters are exact per key. The value `incr` returns is read back after the update, so under heavy contention it may include callers that landed just after you, which only errs toward limiting more. Call `purgeExpired()` from a cron on large keyspaces. The SQL Server statements are generated but have not been run against a real SQL Server; SQLite and Postgres are tested.

## Custom store

```ts
import { defineSecondaryStorage } from "@glinr/theauth";

const storage = defineSecondaryStorage({
  get: (key) => myStore.get(key),
  set: (key, value, ttlSeconds) => myStore.set(key, value, { ttl: ttlSeconds }),
  delete: (key) => myStore.del(key),
  // Provide incr if your store can do it atomically.
  incr: async (key, ttlSeconds) => { /* return { count, expiresAt } */ },
});
```

Without `incr`, the helper builds one from `get` and `set`. That is not atomic and the result reports `atomicIncr: false`.

## The interface

```ts
interface SecondaryStorage {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttlSeconds?: number): Promise<void>;
  delete(key: string): Promise<void>;
  incr(key: string, ttlSeconds: number): Promise<{ count: number; expiresAt: number }>;
  list?(prefix: string): Promise<string[]>;
  readonly atomicIncr?: boolean;
}
```

`incr` uses a fixed window: a missing or expired key starts at 1 with the given TTL, and later calls keep the original expiry.

## Backward compatibility

`kvStore(namespace)`, `KVStore`, `MemoryStore` and `RateLimitStore` keep working. `KVStore` now delegates to the new adapter, and it also accepts any `SecondaryStorage`, which gives you atomic counting through the old API. `rateLimit({ store })` accepts either kind. If you set no `store`, the plugin follows `secondaryStorage.rateLimit`.

---

# Authentication

Source: https://docs.theauth.dev/auth/index

<Columns cols={2}>
<div>
theAuth runs the sign-in for the humans who own agents. Each method is either a plugin or a config key on `createTheAuth` that you opt into, use only what your app needs. Methods that issue sessions (magic link, email OTP, username, phone, OAuth) need `auth.session` configured.

If you already run Clerk, Auth.js, or better-auth, keep them and skip the plugins. [Plug into an existing provider.](/migrate)
</div>

```ts
import { createTheAuth } from '@glinr/theauth';
import {
  oauth,
  magicLink,
  createGoogleProvider,
  createGithubProvider,
} from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  plugins: [
    oauth({
      providers: {
        google: createGoogleProvider({
          clientId: process.env.GOOGLE_CLIENT_ID!,
          clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
        }),
        github: createGithubProvider({
          clientId: process.env.GITHUB_CLIENT_ID!,
          clientSecret: process.env.GITHUB_CLIENT_SECRET!,
        }),
      },
    }),
    magicLink({
      appUrl: 'https://auth.example.com/api/theauth',
      sendMagicLink: async (email, _token, url) => {
        await sendEmail(email, `Sign in: ${url}`);
      },
    }),
  ],
});
```
</Columns>

## Sign-in methods

<CardGroup cols={3}>
  <Card title="Email and password" icon="envelope" href="/auth/email-password">
    Why there is no email plus password plugin, and the options.
  </Card>
  <Card title="Username and password" icon="user" href="/auth/username">
    Built-in password auth with PBKDF2-SHA256 hashing.
  </Card>
  <Card title="Magic link" icon="link" href="/auth/magic-link">
    Single-use link in an email, no password.
  </Card>
  <Card title="Email OTP" icon="hashtag" href="/auth/email-otp">
    Six-digit code via email.
  </Card>
  <Card title="Phone OTP" icon="phone" href="/auth/phone">
    SMS code, you supply the delivery function.
  </Card>
  <Card title="Passkey" icon="fingerprint" href="/auth/passkey">
    WebAuthn / FIDO2 biometrics and security keys.
  </Card>
  <Card title="Sign-in with Ethereum" icon="wallet" href="/auth/siwe">
    EIP-4361 wallet-based sign-in.
  </Card>
  <Card title="Device code" icon="tv" href="/auth/device">
    For TVs and CLIs that can't take a password.
  </Card>
  <Card title="Two-factor" icon="shield-halved" href="/auth/two-factor">
    TOTP with backup codes.
  </Card>
  <Card title="Captcha" icon="shield-check" href="/auth/captcha">
    Turnstile, hCaptcha, reCAPTCHA token verification.
  </Card>
  <Card title="Anonymous" icon="user-secret" href="/auth/anonymous">
    Throwaway sessions, upgrade on sign-up.
  </Card>
  <Card title="Google One Tap" icon="hand-pointer" href="/auth/one-tap">
    Google's one-tap sign-in widget.
  </Card>
  <Card title="OAuth proxy" icon="arrows-left-right" href="/auth/oauth-proxy">
    Reverse-proxy mode for trusted ingress.
  </Card>
</CardGroup>

## OAuth providers

Thirty-eight first-class providers, plus a generic factory for anything with a standard authorization code flow.

<Tabs>
<Tab title="Popular">
<CardGroup cols={4}>
  <Card title="Google" icon="google" href="/auth/google" />
  <Card title="GitHub" icon="github" href="/auth/github" />
  <Card title="Apple" icon="apple" href="/auth/apple" />
  <Card title="Microsoft" icon="microsoft" href="/auth/microsoft" />
  <Card title="Discord" icon="discord" href="/auth/discord" />
  <Card title="Slack" icon="slack" href="/auth/slack" />
  <Card title="LinkedIn" icon="linkedin" href="/auth/linkedin" />
</CardGroup>
</Tab>
<Tab title="Social">
<CardGroup cols={4}>
  <Card title="Facebook" icon="facebook" href="/auth/facebook" />
  <Card title="X / Twitter" icon="twitter" href="/auth/twitter" />
  <Card title="Spotify" icon="spotify" href="/auth/spotify" />
  <Card title="Twitch" icon="twitch" href="/auth/twitch" />
  <Card title="Reddit" icon="reddit" href="/auth/reddit" />
  <Card title="TikTok" icon="tiktok" href="/auth/tiktok" />
  <Card title="Kakao" icon="/brand-icons/kakaotalk.svg" href="/auth/kakao" />
  <Card title="Naver" icon="/brand-icons/naver.svg" href="/auth/naver" />
  <Card title="VK" icon="vk" href="/auth/vk" />
  <Card title="Line" icon="/brand-icons/line.svg" href="/auth/line" />
  <Card title="WeChat" icon="weixin" href="/auth/wechat" />
  <Card title="Kick" icon="/brand-icons/kick.svg" href="/auth/kick" />
  <Card title="Cloudflare" icon="cloud" href="/auth/cloudflare" />
  <Card title="Paybin" icon="credit-card" href="/auth/paybin" />
  <Card title="Roblox" icon="/brand-icons/roblox.svg" href="/auth/roblox" />
</CardGroup>
</Tab>
<Tab title="Work">
<CardGroup cols={4}>
  <Card title="Notion" icon="/brand-icons/notion.svg" href="/auth/notion" />
  <Card title="Figma" icon="figma" href="/auth/figma" />
  <Card title="Dropbox" icon="dropbox" href="/auth/dropbox" />
  <Card title="Zoom" icon="/brand-icons/zoom.svg" href="/auth/zoom" />
  <Card title="Atlassian" icon="atlassian" href="/auth/atlassian" />
  <Card title="Linear" icon="/brand-icons/linear.svg" href="/auth/linear" />
  <Card title="GitLab" icon="gitlab" href="/auth/gitlab" />
  <Card title="Bitbucket" icon="bitbucket" href="/auth/bitbucket" />
  <Card title="Salesforce" icon="salesforce" href="/auth/salesforce" />
</CardGroup>
</Tab>
<Tab title="Developer">
<CardGroup cols={4}>
  <Card title="Vercel" icon="/brand-icons/vercel.svg" href="/auth/vercel" />
  <Card title="Railway" icon="/brand-icons/railway.svg" href="/auth/railway" />
  <Card title="Hugging Face" icon="/brand-icons/huggingface.svg" href="/auth/huggingface" />
</CardGroup>
</Tab>
<Tab title="Payments">
<CardGroup cols={4}>
  <Card title="PayPal" icon="paypal" href="/auth/paypal" />
  <Card title="Coinbase" icon="/brand-icons/coinbase.svg" href="/auth/coinbase" />
  <Card title="Polar" icon="/brand-icons/polar.svg" href="/auth/polar" />
</CardGroup>
</Tab>
<Tab title="Enterprise">
<CardGroup cols={4}>
  <Card title="Auth0" icon="/brand-icons/auth0.svg" href="/auth/auth0" />
  <Card title="Okta" icon="/brand-icons/okta.svg" href="/auth/okta" />
  <Card title="Yahoo" icon="yahoo" href="/auth/yahoo" />
</CardGroup>
</Tab>
</Tabs>

<Tip>
Don't see your provider? The [generic OAuth factory](/auth/oauth) wires any authorization-code provider in about ten lines of config.
</Tip>

## How plugins fit

Plugins register routes at `createTheAuth()` time, and the instance serves them through `theauth.plugins.handleRequest(request)` (framework adapters do this for you, mounted under `/api/theauth` by default). Several methods are also available as config keys that expose a module on the instance, for example `username`, `phone`, `magicLink`, `emailOtp`, `totp`, `passkey`, `org`, `sso`, `admin`, `apiKeys` and `captcha`. Those properties are `null` unless the matching config key is passed.

To identify the signed-in human, configure an `auth.adapter` (for example `bearerAuth`) or `auth.session`, then resolve the user from a request:

```ts title="resolving a user from a request"
const user = await theauth.auth.resolveUser(request);

if (!user) {
  return new Response('Unauthorized', { status: 401 });
}

// user.id is the stable owner ID for creating agents
```

`theauth.auth.resolveUser` uses the configured `auth.adapter`. It returns `null` when no adapter is set, even if `auth.session` is configured.

<Info>
Once the user is resolved, theAuth is done with human auth. The rest of the stack (agents, permissions, audit) hangs off `user.id`.
</Info>

## Enterprise identity

<CardGroup cols={3}>
  <Card title="Organizations" icon="building" href="/auth/organizations">
    Multi-user accounts, roles, invitations.
  </Card>
  <Card title="SSO / SAML" icon="key" href="/auth/sso">
    SAML 2.0 and OIDC SSO.
  </Card>
  <Card title="SCIM" icon="arrows-rotate" href="/auth/scim">
    Automated provisioning from your IdP.
  </Card>
  <Card title="Admin plugin" icon="user-shield" href="/auth/admin">
    Ban, impersonate with TTL, audit.
  </Card>
  <Card title="API keys" icon="key-skeleton" href="/auth/api-keys">
    For machine-to-machine callers.
  </Card>
  <Card title="OIDC provider" icon="plug" href="/auth/oidc-provider">
    Turn your theAuth instance into an IdP for other apps.
  </Card>
</CardGroup>

## Related

<CardGroup cols={2}>
  <Card title="Agent identity" href="/agents" icon="robot">
    The agent layer that sits on top of human authentication.
  </Card>
  <Card title="Sessions" href="/sessions" icon="clock">
    How theAuth manages session lifetime and cookie settings.
  </Card>
  <Card title="Quickstart" href="/quickstart" icon="bolt">
    Get a working theAuth instance running in minutes.
  </Card>
  <Card title="Adapters overview" href="/adapters" icon="code">
    Mount auth routes on Hono, Next.js, Express, and more.
  </Card>
</CardGroup>

---

# Last login tracking

Source: https://docs.theauth.dev/last-login

The last login module records every successful authentication event per user. You can show users when and how they last signed in on a security page, or build your own checks on the login history. theAuth's sign-in modules do not call this module for you, so you call `recordLogin` yourself after each successful sign-in.

## Setup

```ts
import { createTheAuth } from '@glinr/theauth';
import { createLastLoginModule } from '@glinr/theauth/auth';

const theauth = await createTheAuth({ /* ... */ });

const loginHistory = createLastLoginModule(
  { maxHistoryPerUser: 20 },
  theauth.db,
);
```

`createLastLoginModule` is stateless and safe to call multiple times against the same database instance.

## Recording a login

Call `recordLogin` after any successful authentication. It validates its input (the `userId` must be non-empty and the `method` must be one of the values below) and returns a `Result` with the stored event:

```ts
await loginHistory.recordLogin({
  userId: 'usr_123',
  method: 'magic-link',
  ip: request.headers.get('x-forwarded-for') ?? undefined,
  userAgent: request.headers.get('user-agent') ?? undefined,
});
```

Older entries beyond `maxHistoryPerUser` (default: `10`) are pruned automatically on every write.

## Reading login data

### Last login

```ts
const result = await loginHistory.getLastLogin('usr_123');

if (result.success && result.data) {
  console.log(result.data.method);    // 'magic-link'
  console.log(result.data.timestamp); // Date
  console.log(result.data.ip);        // string | null
}
```

Returns `null` in `data` when no history exists for the user.

### Full history

```ts
const result = await loginHistory.getLoginHistory('usr_123', 5);

if (result.success) {
  for (const event of result.data) {
    console.log(event.method, event.timestamp);
  }
}
```

Events are returned newest first. The optional `limit` parameter overrides the module's `maxHistoryPerUser`.

## Supported methods

| Value | Description |
|-------|-------------|
| `email-password` | Email and password sign-in |
| `magic-link` | Passwordless email link |
| `email-otp` | One-time code via email |
| `passkey` | WebAuthn / FIDO2 |
| `username-password` | Username and password |
| `phone-sms` | SMS one-time code |
| `siwe` | Sign-in with Ethereum |
| `device-auth` | OAuth 2.0 device flow |
| `anonymous` | Anonymous session |
| `api-key` | API key authentication |
| `oauth:{provider}` | Any OAuth provider, e.g. `oauth:github` |

## Result type

All methods return `Result<T>`, either `{ success: true; data: T }` or `{ success: false; error: TheAuthError }`. Invalid input and database failures come back as `success: false` rather than thrown errors.

```ts
const result = await loginHistory.getLastLogin(userId);

if (!result.success) {
  console.error(result.error.code, result.error.message);
}
```

<Note>
IP addresses are stored as-is. Normalise or hash them before passing if your privacy policy requires it.
</Note>

## Related

<CardGroup cols={2}>
  <Card title="Authentication overview" href="/auth/index" icon="key">
    All supported auth methods that generate login events.
  </Card>
  <Card title="Anomaly detection" href="/anomaly" icon="triangle-exclamation">
    What exists today for unusual behavior (theAuth has no login anomaly detector).
  </Card>
  <Card title="Trusted devices" href="/trusted-device" icon="shield-halved">
    Skip 2FA on devices you have marked as trusted.
  </Card>
  <Card title="Audit trail" href="/audit" icon="scroll">
    Full agent-level action log for deeper activity analysis.
  </Card>
</CardGroup>

---

# Email and password

Source: https://docs.theauth.dev/auth/email-password

## Option 1: Email and password in core

`emailPassword` is a top-level config key. It stores a real email in `users.email`, hashes the password with PBKDF2, locks repeated failures, and creates ordinary core sessions, so `GET /auth/session`, `POST /auth/sign-out`, every framework adapter and the client bindings work with no extra wiring. `createTheAuthHandler` serves its routes under `/auth/email/*`.

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';

export const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  // You provide the email transport. Each callback receives a ready link.
  emailVerification: {
    verifyUrl: 'https://app.example.com/verify-email',
    sendVerificationEmail: async (email, token, url) => { /* send url */ },
  },
  passwordReset: {
    resetUrl: 'https://app.example.com/reset-password',
    sendResetEmail: async (email, token, url) => { /* send url */ },
  },
  emailPassword: {
    // requireVerification defaults to true
    password: { minLength: 10 },
  },
});
```

`emailPassword` needs `auth.session`. With `requireVerification` on (the default) it also needs `emailVerification`, and `createTheAuth` throws at startup if that is missing. `passwordReset` is optional: without it the forgot and reset routes are not served.

| Route | Body | Answer |
|---|---|---|
| `POST /auth/email/sign-up` | `{ email, password, name? }` | `201 { verificationRequired: true }`, or `{ user, session }` when verification is off |
| `POST /auth/email/sign-in` | `{ email, password, rememberMe? }` | `200 { user, session }` and the session cookie |
| `POST /auth/email/verify` | `{ token }` | `200 { verified: true, user }` |
| `POST /auth/email/resend-verification` | `{ email }` | `204`, whether or not the address exists |
| `POST /auth/email/forgot-password` | `{ email }` | `204` (needs `passwordReset`) |
| `POST /auth/email/reset-password` | `{ token, password }` | `200 { success: true }` (needs `passwordReset`) |
| `POST /auth/email/change-password` | `{ current, newPassword }` | `200 { success: true }`, signed-in user only |

Errors are `{ error: { code, message } }` with one of `INVALID_EMAIL`, `INVALID_PASSWORD`, `INVALID_CREDENTIALS` (401), `EMAIL_NOT_VERIFIED` (403), `INVALID_TOKEN`, `EMAIL_TAKEN` (409, only with verification off) or `SIGN_IN_LOCKED` (429 with `Retry-After`). A sign-in that the [sign-in gate](/sign-in-gate) holds back answers like the username module does.

### What it does and does not tell the caller

- **Verification on (default).** Sign-up creates the account and sends the email, but creates no session. Sign-in checks the password first and only then answers `EMAIL_NOT_VERIFIED`, so someone who guesses an address learns nothing.
- **Taken addresses look new.** Sign-up for an address that already has a user answers the same `201 { verificationRequired: true }`. It never attaches a password to the existing user, so an address that signed up through Google cannot be claimed by someone else. A password account that is still unverified gets a fresh verification email; its password does not change.
- **A reset proves the inbox.** A completed password reset marks the address verified and replaces the password, which also clears an account that someone else pre-registered with your address.
- **Lockout and rate limits** follow the [username module](#failed-sign-in-lockout) (`emailPassword.lockout`, same defaults) and each route has a per-IP limit.
- **Credentials** live in the same table as username accounts, so password reset, forced password change and two-factor through the gate work unchanged. Accounts imported with `theauth migrate` (credential keyed by the lowercase address) sign in here too.

### Sign up, verify and sign in without a browser

```typescript title="flow.ts"
import { createTheAuthHandler } from '@glinr/theauth/http';
import { theauth } from './lib/theauth';

const handler = createTheAuthHandler(theauth, { basePath: '/api/theauth' });
const post = (path: string, body: unknown) =>
  handler(new Request(`http://localhost/api/theauth${path}`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(body),
  }));

await post('/auth/email/sign-up', { email: 'ada@example.com', password: 'correct horse battery' });
// The token from your sendVerificationEmail callback:
await post('/auth/email/verify', { token });
const res = await post('/auth/email/sign-in', { email: 'ada@example.com', password: 'correct horse battery' });
const { session } = await res.json();

const me = await handler(new Request('http://localhost/api/theauth/auth/session', {
  headers: { authorization: `Bearer ${session.token}` },
}));
console.log((await me.json()).user.email); // ada@example.com
```

The [client bindings](/react) take `emailPassword: true` and talk to these routes. There is a test of exactly this flow through the session store, in `packages/client/tests/session-store-email-password.test.ts`.

### Programmatic use

`theauth.emailPassword` is the module behind the routes: `signUp`, `signIn`, `verifyEmail`, `resendVerification`, `changePassword` and `markVerified`. They throw `EmailPasswordError` (with a `code`), `LoginLockedError` or `SignInGateError`.

## Option 1a: The email plugin (`@glinr/theauth-email`), legacy

<Warning>
  The plugin predates the core module and does not work with core sessions. Its sign-in token is a random id in its own format, so `GET /auth/session`, `POST /auth/sign-out` and the adapters answer 401 for it, it sets no cookie, it writes with raw SQLite statements so it is only reliable on the SQLite drivers, and its sign-up returns a session token before the address is verified. Use Option 1 for new projects. The package stays published for existing installs and is deprecated.
</Warning>

```bash
pnpm add @glinr/theauth @glinr/theauth-email
```

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { emailPassword } from '@glinr/theauth-email';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  plugins: [
    emailPassword({
      appUrl: 'https://app.example.com',
      sendVerificationEmail: async (email, token, url) => { /* send url */ },
      sendResetEmail: async (email, token, url) => { /* send url */ },
    }),
  ],
});
```

It registers `POST /auth/sign-up`, `/auth/sign-in`, `/auth/verify-email`, `/auth/request-reset`, `/auth/reset-password` and `/auth/change-password`. To move to the core module, replace the plugin with the `emailPassword`, `emailVerification` and `passwordReset` config keys and point your client at `/auth/email/*`. The plugin keeps its accounts in `theauth_email_accounts`, so existing users need to reset their password once or be imported.

## Option 1b: Username as the identifier

The [username module](/auth/username) hashes a password against a username string. It does not send verification or reset emails or track `emailVerified`; those live in separate modules (see below).

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  username: {
    password: { minLength: 8 },
  },
});
```

<Warning>
  The username module stores a placeholder (`{username}@username.local`) in `users.email`, so do not rely on that column for delivery.
</Warning>

### Password hashing

Passwords are hashed with PBKDF2-HMAC-SHA256 at 600000 iterations (OWASP guidance). Each hash is stored as `pbkdf2:<iterations>:<salt_hex>:<hash_hex>`, so the algorithm and cost travel with the hash and older hashes keep verifying with their own parameters. On a successful sign-in, a hash made with fewer iterations than the target is rewritten transparently. Comparison is constant time, and an unknown username still pays one hash so timing does not reveal which usernames exist.

Set the target with `pbkdf2Iterations` on the username or password reset config, or with the `THEAUTH_PBKDF2_ITERATIONS` environment variable (config wins). Raising it upgrades users gradually as they sign in.

Cost: about 100 ms per hash at 600000 iterations on an Apple Silicon laptop (about 17 ms at the former 100000). Cloudflare Workers limits CPU time per request, so on the free plan or tight budgets set a lower value such as 100000 and compensate with rate limiting and breached password checks. Where the runtime allows, prefer a memory hard function (scrypt or argon2id) through your own hasher. The Go library uses argon2id (64 MiB, t=3, p=4) with parameters stored in a PHC string.

### Failed sign-in lockout

Username sign-in locks repeated failures, on by default. It follows the Go library's login throttle.

- **Per account, any IP.** 10 failures inside 15 minutes lock the account for 15 minutes. Each repeat lockout inside 24 hours doubles the lock, capped at 1 hour.
- **Per account and IP.** After 3 misses the pair is delayed 1 second, doubling per extra miss up to 15 minutes. Other IPs are not slowed. This needs `trustedProxy` so the IP is known; without it only the per-account lock applies.
- **Same answer for unknown names.** The counters are keyed by the submitted username, so a name that does not exist locks exactly like one that does. A locked sign-in answers `429` with `Retry-After` and the body `{ error, code: "SIGN_IN_LOCKED", retryAfter }`; the signIn function throws `LoginLockedError`. Locked attempts are refused before any hashing and are not counted.
- **Unlock.** The lock ends on its own. `theauth.username.unlockAccount("alice")` lifts it at once, and a correct password from an unlocked client clears the counters.

The trade-off: anyone who knows a username can lock its owner out from any IP. The harm is bounded. The lock never extends while it is active, it is capped by `maxLockoutSeconds`, escalation is forgotten after the strike window, and an admin can unlock. If that is not acceptable, raise `maxFailures` or set `lockout: false` and rely on the per-IP rate limit.

```ts title="theauth.ts"
username: {
  trustedProxy: { trustedProxyCount: 1 },
  lockout: {
    maxFailures: 10,          // THEAUTH_LOGIN_MAX_FAILURES
    failureWindowSeconds: 900, // THEAUTH_LOGIN_FAILURE_WINDOW_SECONDS
    lockoutSeconds: 900,       // THEAUTH_LOGIN_LOCKOUT_SECONDS
    maxLockoutSeconds: 3600,   // THEAUTH_LOGIN_MAX_LOCKOUT_SECONDS
    graceFailures: 3,          // THEAUTH_LOGIN_GRACE_FAILURES
    failClosed: false,         // THEAUTH_LOGIN_LOCKOUT_FAIL_CLOSED
    onLockout: (e) => log.warn('sign-in lockout', { user: e.identifier, scope: e.scope, until: e.until }),
  },
}
```

Counters live in the `rateLimit` [secondary storage](/secondary-storage) (memory by default, so share a store such as Redis or the database across instances). If the store errors, sign-in is let through by default and a startup warning says so; set `failClosed: true` to answer `429` instead. `THEAUTH_LOGIN_LOCKOUT_DISABLED=true` or `lockout: false` turns the feature off.

## Option 2: Passwordless

If you would rather not store passwords at all, theAuth's passwordless methods sign users in with a real email address, so there is no password reset screen to build:

- [Magic link](/auth/magic-link), a single-use link emailed to the user, no password.
- [Email OTP](/auth/email-otp), a numeric code emailed to the user, no password.

Both can be added as a plugin (`magicLink()`, `emailOtp()`) or as a top-level config key on `createTheAuth()`, which exposes a module with a `handleRequest` you forward matching requests to. Both require `auth.session`.

## Option 3: Bring your own (better-auth, Auth.js, Clerk, custom)

If you already run email+password auth in an existing system, don't migrate it, adapt it. `customAuth` wraps an arbitrary resolver function as an `AuthAdapter` so theAuth's agent layer can resolve the human user from an inbound request without owning the credential flow:

```typescript
import { createTheAuth } from '@glinr/theauth';
import { customAuth } from '@glinr/theauth/auth';

const adapter = customAuth(async (request) => {
  const session = await existingAuth.api.getSession({ headers: request.headers });
  if (!session?.user) return null;
  return { id: session.user.id, email: session.user.email, name: session.user.name };
});

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { adapter },
});
```

See [Concepts](/concepts) for how theAuth's agent layer composes with an external human-auth provider, and the [migration guides](/migrate) for better-auth, Auth0, and Clerk specifically.

## Supporting modules (compose with any of the above)

These are independent, headless modules, use them regardless of which sign-in method you pick:

<CardGroup cols={2}>
  <Card title="Email verification" icon="envelope-circle-check">
    `createEmailVerificationModule` (`@glinr/theauth/auth`), or `theauth.emailVerification` when you pass the `emailVerification` config key. Sends and confirms email verification tokens.
  </Card>
  <Card title="Password reset" icon="key">
    `createPasswordResetModule` (`@glinr/theauth/auth`), or `theauth.passwordReset` when you pass the `passwordReset` config key. Token-based reset flow that revokes existing sessions on success by default (`revokeSessionsOnReset`).
  </Card>
  <Card title="Breach checking" href="/hibp" icon="shield-halved">
    Reject passwords found in known breaches via HaveIBeenPwned.
  </Card>
  <Card title="Two-factor auth" href="/auth/two-factor" icon="mobile-screen">
    Add TOTP as a second factor.
  </Card>
</CardGroup>

## Related

<CardGroup cols={2}>
  <Card title="Username and password" href="/auth/username" icon="key">
    theAuth's actual built-in password module.
  </Card>
  <Card title="Magic link" href="/auth/magic-link" icon="link">
    Passwordless alternative using a single-use email link.
  </Card>
  <Card title="Email OTP" href="/auth/email-otp" icon="hashtag">
    Numeric code via email, no password required.
  </Card>
  <Card title="Authentication overview" href="/auth/index" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Username and password

Source: https://docs.theauth.dev/auth/username

theAuth's built-in password auth is username-based: users register with a username (not an email address) and a password. Passwords are hashed with PBKDF2 before storage so a database breach does not expose credentials. It is a headless module, you mount it yourself by forwarding requests to `theauth.username.handleRequest`.

<Info>
  There is no separate email-based password plugin. For email-first sign-in, pair this module with the email verification module and [email OTP](/auth/email-otp), or use [magic link](/auth/magic-link) for a fully passwordless flow.
</Info>

## Setup

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { session: { secret: process.env.SESSION_SECRET! } }, // required, signUp/signIn issue sessions
  username: {
    minUsernameLength: 3,
    maxUsernameLength: 32,
    allowedPattern: /^[a-z0-9_-]+$/i,
    caseSensitive: false,
    password: { minLength: 8, maxLength: 128 },
  },
});
```

Forward matching requests to the module from your framework adapter (or call it directly from a catch-all route):

```typescript title="Mount the handler"
async function handleAuth(request: Request): Promise<Response> {
  const response = await theauth.username?.handleRequest(request);
  if (response) return response;
  return new Response('Not Found', { status: 404 });
}
```

`handleRequest` matches the literal pathnames below (`/auth/username/sign-up` and so on) and only for `POST`. If you mount it under a prefix such as `/api/theauth`, strip the prefix from the URL before calling it. `theauth.username` is `null` unless the `username` key is passed and `auth.session` is configured.

## Sign up

`POST /auth/username/sign-up`

```typescript title="Sign up (client)"
const res = await fetch('/auth/username/sign-up', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    username: 'ada_lovelace', // [!code highlight]
    password: 'correct horse battery',
    name: 'Ada Lovelace', // optional display name
  }),
});

// 201: { user: { id, username, name }, session: { token, expiresAt } }
// 400: { error: "Username already taken" | "Username must be at least N characters" | ... }
const { user, session } = await res.json();
```

Sign-up throws (returned as a `400` with a plain-text `error` message, not a machine-readable code) when the username fails validation or is already taken, or when the password fails length validation.

## Sign in

`POST /auth/username/sign-in`

```typescript title="Sign in (client)"
const res = await fetch('/auth/username/sign-in', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    username: 'ada_lovelace', // [!code highlight]
    password: 'correct horse battery', // [!code highlight]
  }),
});

if (res.status === 403) {
  const { error } = await res.json();
  // error.code === 'PASSWORD_RESET_REQUIRED'
} else if (!res.ok) {
  // 401: { error: "Invalid username or password" } (generic, does not reveal which field was wrong)
} else {
  const { user, session } = await res.json();
}
```

## Change password

`POST /auth/username/change-password`

Requires the caller to know the current password. The module does not read the session for you, pass the authenticated `userId` from your own session lookup.

<Warning>
  The HTTP handler takes `userId` from the request body and does not check the caller's session. Do not expose `change-password` or `change-username` to browsers as-is. Resolve the session on your server and call `theauth.username.changePassword(userId, current, newPassword)` and `theauth.username.changeUsername(userId, newUsername)` instead.
</Warning>

```typescript title="Change password (client)"
const res = await fetch('/auth/username/change-password', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    userId: user.id, // [!code highlight]
    current: 'correct horse battery', // [!code highlight]
    newPassword: 'new-stronger-password', // [!code highlight]
  }),
});
```

## Change username

`POST /auth/username/change-username`

```typescript title="Change username (client)"
const res = await fetch('/auth/username/change-username', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    userId: user.id, // [!code highlight]
    newUsername: 'ada_v2', // [!code highlight]
  }),
});
```

Usernames must still be unique after the change, sign-up validation rules (`allowedPattern`, length) apply here too.

## Using the module directly

`theauth.username` exposes typed methods in addition to `handleRequest`, useful when you want to call sign-up/sign-in from server code (e.g. an admin invite flow) instead of going through HTTP:

```typescript
const result = await theauth.username?.signUp({ username: 'ada_lovelace', password: 'correct horse battery' });
const signedIn = await theauth.username?.signIn({ username: 'ada_lovelace', password: 'correct horse battery' });
await theauth.username?.changePassword(userId, 'correct horse battery', 'new-stronger-password');
```

## Configuration reference

<ParamField path="minUsernameLength" type="number" default={3}>Minimum username length.</ParamField>
<ParamField path="maxUsernameLength" type="number" default={32}>Maximum username length.</ParamField>
<ParamField path="allowedPattern" type="RegExp" default="/^[a-zA-Z0-9_-]+$/">Regex the username must match.</ParamField>
<ParamField path="caseSensitive" type="boolean" default="false">Whether usernames are normalized to lowercase before storage and lookup.</ParamField>
<ParamField path="password.minLength" type="number" default={8}>Minimum password length.</ParamField>
<ParamField path="password.maxLength" type="number" default={128}>Maximum password length.</ParamField>

## Related

<CardGroup cols={2}>
  <Card title="Magic link" href="/auth/magic-link" icon="link">
    Passwordless, email-based alternative when you don't want a password field at all.
  </Card>
  <Card title="Two-factor auth" href="/auth/two-factor" icon="mobile-screen">
    Add TOTP as a second factor on top of username/password sign-in.
  </Card>
  <Card title="Authentication overview" href="/auth/index" icon="key">
    All available auth methods and how to combine them.
  </Card>
  <Card title="Sessions" href="/sessions" icon="clock">
    What happens after sign-in: cookie and JWT session management.
  </Card>
</CardGroup>

---

# Magic link

Source: https://docs.theauth.dev/auth/magic-link

Magic links let users sign in by clicking a link sent to their email. No password needed. The link carries a random single-use token that expires after a configurable window.

## Setup

<Steps>

<Step>

### Install

```bash
pnpm add @glinr/theauth
```

</Step>

<Step>

### Add the plugin

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { magicLink } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { session: { secret: process.env.SESSION_SECRET! } }, // [!code highlight]
  plugins: [
    magicLink({ // [!code highlight]
      appUrl: 'https://auth.example.com/api/theauth', // [!code highlight]
      sendMagicLink: async (email, _token, url) => { // [!code highlight]
        await resend.emails.send({
          from: 'auth@example.com',
          to: email,
          subject: 'Your sign-in link',
          html: `<a href="${url}">Sign in to Example</a>. Link expires in 15 minutes.`,
        });
      }, // [!code highlight]
    }), // [!code highlight]
  ],
});
```

The plugin throws at startup unless `auth.session` is configured, because it issues a session when the link is verified. `auth.session.secret` must be at least 32 characters. `appUrl` is prepended to the callback path to build the link, so it must point at where the verify endpoint is reachable (the adapter mount path, `/api/theauth` by default).

You can also skip the plugin and pass the same options as a top-level `magicLink` key to `createTheAuth`. That exposes `theauth.magicLink` (`sendLink`, `verify`, `handleRequest`), which is `null` when the config key is absent or `auth.session` is not set.

</Step>

<Step>

### Handle the callback route

Magic links point at `appUrl + callbackPath + ?token=...`. The plugin registers `GET /auth/magic-link/verify`, which matches the default `callbackPath`. Leave `callbackPath` unchanged when you use the plugin.

```typescript
magicLink({
  appUrl: 'https://auth.example.com/api/theauth',
  sendMagicLink: async (email, token, url) => { /* ... */ },
}),
```

</Step>

</Steps>

## How it works

1. User submits their email to `POST /auth/magic-link/send`.
2. theAuth generates a signed token and calls your `sendMagicLink` function with the email address, the raw token, and the full URL.
3. User clicks the link in their inbox.
4. theAuth validates the token, creates or retrieves the user, and returns the user and a session token as JSON. It does not set a cookie or redirect, so your app stores the session token (for example in a cookie) and navigates the user.

If the email belongs to an existing account, the same user ID is returned. If it is new, an account is created automatically.

## Endpoints

### Send link

`POST /auth/magic-link/send`

```typescript
await fetch('/api/theauth/auth/magic-link/send', { // [!code highlight]
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'user@example.com' }),
});
```

The email is trimmed and lowercased. A successful response is `200` with `{ "sent": true }`. A missing `email` returns `400`. A user record is created for the address when the link is issued, so an unknown email gets an account.

### Verify token

`GET /auth/magic-link/verify?token=<token>`

On success, returns `200` with `{ user: { id, email }, session: { token, expiresAt } }`. A missing `token` returns `400`, and an invalid, expired, or already-used token returns `401`.

## Rate limiting

The send endpoint is limited to **5 requests per minute per IP address** (taken from `x-forwarded-for` or `x-real-ip`). Requests over the limit return `429 Too Many Requests` with a `Retry-After` header. Build a cooldown timer into your UI so users know when they can retry.

<Warning>
  Magic links are single-use. Clicking an expired or already-used link returns a 401. Show the user a "resend" option in your UI.
</Warning>

## Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `sendMagicLink` | `(email: string, token: string, url: string) => Promise<void>` | required | Called with the recipient email, the raw token, and the full magic link URL |
| `appUrl` | `string` | required | Base URL prepended to the callback path, for example `https://app.example.com` |
| `tokenExpiry` | `number` | `900` | Token lifetime in seconds (15 minutes) |
| `callbackPath` | `string` | `/auth/magic-link/verify` | Path appended to `appUrl` in the link |
| `keepUnverifiedCredentials` | `boolean` | `false` | When the owner first proves the mailbox on an account whose address was unverified, theAuth deletes the password, linked providers, passkeys and sessions that were created before the proof. Set `true` to keep them |

Redeeming a link or code marks the address verified. The cleanup stops account pre-hijacking: someone who registered the victim's address first would otherwise keep the password they set after the victim signs in.

## Related

<CardGroup cols={2}>
  <Card title="Email OTP" href="/auth/email-otp" icon="hashtag">
    Numeric code via email, useful when links are awkward to click.
  </Card>
  <Card title="Email and password" href="/auth/email-password" icon="envelope">
    Password-based auth that can run alongside magic links.
  </Card>
  <Card title="Passkey" href="/auth/passkey" icon="fingerprint">
    WebAuthn biometrics as an alternative passwordless method.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Email OTP

Source: https://docs.theauth.dev/auth/email-otp

Email OTP sends a short numeric code to the user's inbox. It works well for mobile flows where clicking a link is awkward and for verification steps inside an existing session.

## Setup

<Steps>

<Step>

### Install

```bash
pnpm add @glinr/theauth
```

</Step>

<Step>

### Add the plugin

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { emailOtp } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { session: { secret: process.env.SESSION_SECRET! } }, // [!code highlight]
  plugins: [
    emailOtp({ // [!code highlight]
      sendOtp: async (email, code) => { // [!code highlight]
        await resend.emails.send({
          from: 'auth@example.com',
          to: email,
          subject: `Your code: ${code}`,
          html: `<p>Your sign-in code is <strong>${code}</strong>. It expires in 5 minutes.</p>`,
        });
      }, // [!code highlight]
    }), // [!code highlight]
  ],
});
```

The plugin throws at startup unless `auth.session` is configured, because it issues a session on successful verification. `auth.session.secret` must be at least 32 characters.

You can also pass the same options as a top-level `emailOtp` key to `createTheAuth` instead of using the plugin. That exposes `theauth.emailOtp` (`sendCode`, `verifyCode`, `handleRequest`), which is `null` when the key is absent or `auth.session` is not set.

</Step>

</Steps>

## How it works

1. User submits their email to `POST /auth/email-otp/send`.
2. theAuth generates a cryptographically random code and calls your `sendOtp` function with the email and code.
3. User enters the code in your UI and submits to `POST /auth/email-otp/verify`.
4. On success, the response contains the user and a session token. theAuth does not set a cookie, so your app stores the token.

If the email belongs to an existing account, the same user ID is returned. If it is new, an account is created on successful verification.

## Send a code

`POST /auth/email-otp/send`

```typescript
await fetch('/api/theauth/auth/email-otp/send', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'user@example.com' }),
});
```

A successful response is `200` with `{ "sent": true }`. The email is trimmed and lowercased, and a missing `email` returns `400`. Requesting a new code deletes any earlier code for that address. The endpoint is limited to 5 requests per minute per IP address, and requests over the limit return `429`. Build a countdown timer into your UI.

## Verify a code

`POST /auth/email-otp/verify`

```typescript
const res = await fetch('/api/theauth/auth/email-otp/verify', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: 'user@example.com',
    code: '482910', // [!code highlight]
  }),
});

const { user, session } = await res.json(); // [!code highlight]
// user: { id, email }, session: { token, expiresAt }
```

A wrong, expired, or exhausted code returns `401`. After `maxAttempts` verification attempts the stored code stops being accepted and a new one must be requested. Verification is limited to 10 requests per minute per IP address.

<Warning>
  Codes are single-use. A successful verification invalidates the code immediately. Do not retry the same code after a success response.
</Warning>

## Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `sendOtp` | `(email: string, code: string) => Promise<void>` | required | Called with the recipient email and the numeric code |
| `codeLength` | `number` | `6` | Number of digits in the OTP |
| `codeExpiry` | `number` | `300` | Code lifetime in seconds (5 minutes) |
| `maxAttempts` | `number` | `5` | Verification attempts allowed per code |
| `keepUnverifiedCredentials` | `boolean` | `false` | When the owner first proves the mailbox on an account whose address was unverified, theAuth deletes the password, linked providers, passkeys and sessions that were created before the proof. Set `true` to keep them |

Redeeming a link or code marks the address verified. The cleanup stops account pre-hijacking: someone who registered the victim's address first would otherwise keep the password they set after the victim signs in.

## Related

<CardGroup cols={2}>
  <Card title="Magic link" href="/auth/magic-link" icon="link">
    Single-use email link as an alternative to numeric codes.
  </Card>
  <Card title="Phone number" href="/auth/phone" icon="phone">
    OTP delivered via SMS instead of email.
  </Card>
  <Card title="Email and password" href="/auth/email-password" icon="envelope">
    Password-based auth that can run alongside OTP.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Phone number

Source: https://docs.theauth.dev/auth/phone

The phone module registers and authenticates users with a phone number and a one-time code. You supply the SMS delivery function. theAuth generates the code, hashes it before storage, enforces expiry, and limits verification attempts. It ships as a config key, not a plugin: there is no `phoneAuth()` plugin export.

## Setup

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { Twilio } from 'twilio';

const twilio = new Twilio(process.env.TWILIO_SID!, process.env.TWILIO_TOKEN!);

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { session: { secret: process.env.SESSION_SECRET! } }, // [!code highlight]
  phone: { // [!code highlight]
    sendSms: async (phone, code) => { // [!code highlight]
      await twilio.messages.create({
        body: `Your verification code: ${code}`,
        from: process.env.TWILIO_FROM!,
        to: phone,
      });
    }, // [!code highlight]
    codeLength: 6,
    codeExpiry: 300, // seconds
  },
});
```

`theauth.phone` is `null` unless the `phone` key is passed and `auth.session` is configured (a session is issued on verification). Forward matching requests to it from a catch-all route:

```typescript title="Mount the handler"
async function handleAuth(request: Request): Promise<Response> {
  const response = await theauth.phone?.handleRequest(request);
  if (response) return response;
  return new Response('Not Found', { status: 404 });
}
```

`handleRequest` handles `POST` only and matches the literal pathnames below. If you mount it under a prefix such as `/api/theauth`, strip the prefix from the URL first. See [adapters](/adapters).

## Send code

`POST /auth/phone/send-code`

Sends a one-time code to the given phone number, replacing any earlier code for that number.

```typescript title="Send code (client)"
const res = await fetch('/auth/phone/send-code', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    phoneNumber: '+14155550123', // E.164 format // [!code highlight]
  }),
});

// 200: { sent: true }
```

Use [E.164 format](https://www.twilio.com/docs/glossary/what-e164) (`+` prefix, country code, number). theAuth only strips whitespace and does not validate or reformat the number, so normalize it before you call. A missing `phoneNumber` returns `400`. The module has no built-in rate limit for sending, so add one in front of this route (see [rate limiting](/rate-limiting)) to limit SMS cost and abuse.

## Verify code

`POST /auth/phone/verify`

Submits the code the user received. Returns the user and a session token on success. The user account is created on the first successful verification.

```typescript title="Verify code (client)"
const res = await fetch('/auth/phone/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    phoneNumber: '+14155550123', // [!code highlight]
    code: '482910', // [!code highlight]
  }),
});

if (res.ok) {
  const { user, session } = await res.json();
  // user: { id, phone }, session: { token, expiresAt }
}
```

**Responses**

| Status | Meaning |
|--------|---------|
| 200 | `{ user: { id, phone }, session: { token, expiresAt } }` |
| 400 | Missing `phoneNumber` or `code`, or invalid JSON |
| 401 | `{ error: "Invalid or expired code" }`, covering a wrong code, an expired code, and exhausted attempts |

The handler does not set a cookie, so your app stores the session token.

## Configuration reference

<ParamField path="sendSms" type={`(phoneNumber: string, code: string) => Promise<void>`} required>Callback invoked to deliver the code. Receives the phone number and the numeric code as a string.</ParamField>
<ParamField path="codeLength" type="number" default={6}>Number of digits in the generated code.</ParamField>
<ParamField path="codeExpiry" type="number" default="300 (5 min)">Code validity window in seconds.</ParamField>
<ParamField path="maxAttempts" type="number" default={5}>Verification attempts allowed per code before it stops being accepted.</ParamField>

## Related

<CardGroup cols={2}>
  <Card title="Email OTP" href="/auth/email-otp" icon="hashtag">
    OTP via email instead of SMS.
  </Card>
  <Card title="Magic link" href="/auth/magic-link" icon="link">
    Passwordless sign-in via email link.
  </Card>
  <Card title="Passkey" href="/auth/passkey" icon="fingerprint">
    Device biometric auth as a phone-free alternative.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Unified OTP

Source: https://docs.theauth.dev/auth/otp

`createOtpService` is the shared engine behind code-based flows. It does not create sessions or users. You call it, then do whatever the verified code unlocks. The older [email OTP](/auth/email-otp) and [phone](/auth/phone) modules keep working and now also take `resendCooldownSeconds`.

## Setup

```typescript title="lib/otp.ts"
import {
  createOtpService,
  emailOtpSender,
  twilioOtpSender,
  resend,
} from '@glinr/theauth/auth';

export const otp = createOtpService({
  secret: process.env.OTP_SECRET!, // keys the code hash
  senders: {
    email: emailOtpSender(resend({ apiKey: process.env.RESEND_API_KEY!, from: 'Acme <auth@acme.dev>' })),
    sms: twilioOtpSender({
      accountSid: process.env.TWILIO_ACCOUNT_SID!,
      authToken: process.env.TWILIO_AUTH_TOKEN!,
      from: process.env.TWILIO_FROM!,
    }),
  },
  resendCooldownSeconds: 60,
  maxAttempts: 5,
  lockoutSeconds: 900,
});
```

## Send and verify

```typescript
const sent = await otp.send({ purpose: 'sign-in', channel: 'email', identifier: 'ada@example.com' });
if (!sent.success && sent.error.code === 'OTP_COOLDOWN') {
  // sent.error.details.retryAfter is in seconds
}

const checked = await otp.verify({ purpose: 'sign-in', identifier: 'ada@example.com', code: '482913' });
if (checked.success) {
  // open a session, mark the email verified, allow the password change, ...
}
```

Purposes are `sign-in`, `verify-email`, `reset-password` and `two-factor`. A code only verifies for the purpose it was sent for, so a sign-in code cannot reset a password. For 2FA step-up, send a `two-factor` code to a user who is already signed in, then require a successful `verify` before the sensitive action.

## Behavior

- Codes are digits drawn with rejection sampling and stored only as an HMAC (or SHA-256 without `secret`) bound to purpose and identifier.
- Comparison is constant time.
- A new send inside `resendCooldownSeconds` fails with `OTP_COOLDOWN` and a `retryAfter`.
- After `maxAttempts` wrong codes the pending code is deleted and the identifier is locked for `lockoutSeconds` (`OTP_LOCKED`). The right code does not unlock early.
- A failed delivery removes the code and returns `OTP_SEND_FAILED`, so a provider outage does not start a cooldown.
- Verifying consumes the code.

State lives in a [secondary storage](/secondary-storage). The default is in memory, which is right for development and single-process servers. Pass `storage` (Redis, KV or database storage) when you run more than one instance. Counters are read and written without a transaction, so under heavy concurrency a few extra guesses can slip in before the lockout lands.

## Senders

| Sender | Channel | Notes |
|--------|---------|-------|
| `emailOtpSender(provider)` | email | Wraps any email provider: `resend`, `ses`, `postmark`, `sendgrid`, `smtp` |
| `twilioOtpSender(config)` | sms | Raw `fetch`, give `from` or `messagingServiceSid` |
| `vonageOtpSender(config)` | sms | Vonage Messages API, Basic auth. Needs `apiKey`, `apiSecret`, `from`. Optional `region` |
| `plivoOtpSender(config)` | sms | Plivo Message API. Needs `authId`, `authToken` and `from` or `powerpackUuid` |
| `consoleOtpSender()` | either | Prints the code. Development only |

All three SMS senders use `fetch` with no SDK, time out after 10 seconds (`timeoutMs`), accept only the `sms` channel, and throw on a non-2xx answer. The error carries the HTTP status and the vendor's short error code, never the response body, credentials, phone number or code, so it is safe to log. A 2xx answer means the vendor accepted the message, not that the phone received it: delivery receipts are a vendor webhook and are not handled here.

```typescript
import { createOtpService, plivoOtpSender, vonageOtpSender } from '@glinr/theauth/auth';

const sms = vonageOtpSender({
  apiKey: process.env.VONAGE_API_KEY!,
  apiSecret: process.env.VONAGE_API_SECRET!,
  from: process.env.VONAGE_FROM!, // E.164 number, or an alphanumeric ID where the country allows it
});
// or: plivoOtpSender({ authId: process.env.PLIVO_AUTH_ID!, authToken: process.env.PLIVO_AUTH_TOKEN!, from: process.env.PLIVO_FROM! })

export const otp = createOtpService({ secret: process.env.OTP_SECRET!, senders: { sms } });
```

Vonage wants the recipient without the leading `+`, and the sender strips it for you. Request shapes follow the vendors' docs: [Twilio Messages](https://www.twilio.com/docs/messaging/api/message-resource), [Vonage Messages API](https://developer.vonage.com/en/api/messages), [Plivo Message API](https://www.plivo.com/docs/messaging/api/message/send-a-message). Look at the sender cost and sender registration rules (10DLC in the US, sender ID rules elsewhere) of the vendor you pick, they differ by country.

A sender is `{ send(message): Promise<void> }`, so adding another provider takes a few lines. `emailOtpSender` accepts `subject` and `render` to change the message text.

## HTTP routes

Nothing is mounted until you register the plugin. `otpRoutes` adds two endpoints:

```typescript
import { createTheAuth } from '@glinr/theauth';
import { consoleOtpSender, createOtpService, otpRoutes } from '@glinr/theauth/auth';

const otp = createOtpService({ senders: { email: consoleOtpSender() }, secret: process.env.OTP_SECRET });

const auth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  plugins: [
    otpRoutes({
      service: otp,
      // Skip delivery for identifiers with no account. The caller still sees 202.
      canSend: async ({ identifier }) => (await findUserByEmail(identifier)) !== null,
      // Open a session or mark the email verified here. The result is merged into the 200 body.
      onVerified: async ({ identifier }) => ({ email: identifier }),
    }),
  ],
});
```

```bash
curl -X POST localhost:3000/auth/code/send   -d '{"identifier":"ada@example.com"}'
curl -X POST localhost:3000/auth/code/verify -d '{"identifier":"ada@example.com","code":"482913"}'
```

- `send` answers `202 { "sent": true }` for every well formed request. Cooldown, lockout, delivery failure and a `canSend` refusal look the same, so the route cannot be used to probe for accounts. You lose the `Retry-After` hint on send as a result.
- `verify` answers `400 { "error": "Invalid or expired code" }` for wrong, expired and unknown codes. It answers `429` with `Retry-After` only after the identifier is locked, which does not depend on whether an account exists.
- Both are rate limited per client IP (defaults 5 and 10 per minute, change with `sendRateLimit` and `verifyRateLimit`).
- Only `sign-in` and `verify-email` are reachable by default (`purposes` widens this). Reset and 2FA codes go through their own modules below.

## Two-factor method

Pass `otp` to `twoFactor` to add an email or SMS code next to TOTP. TOTP routes are unchanged.

```typescript
twoFactor({
  otp: {
    service: otp,
    resolveContact: async (userId) => ({ channel: 'email', identifier: await emailFor(userId) }),
  },
});
// POST /auth/2fa/otp/send    (signed in) -> 202
// POST /auth/2fa/otp/verify  { code }    -> { valid: true } | 400 | 429
```

## Password reset by code

Set `passwordReset.otp` to offer a code instead of a link. The link flow keeps working.

```typescript
passwordReset: {
  sendResetEmail, resetUrl,        // existing link flow
  otp: { service: otp },           // adds the code flow
}
// POST /auth/forgot-password/otp  { email }                    -> 204 always
// POST /auth/reset-password/otp   { email, code, password }    -> 204 | 400 | 429
```

The same thing is available in code as `auth.passwordReset.requestResetOtp(email)` and `resetPasswordWithOtp(email, code, password)`. Successful resets revoke sessions like the link flow does.

## Existing modules

```typescript
emailOtp({ sendOtp, resendCooldownSeconds: 60 });
// POST /auth/otp/send now answers 429 with a Retry-After header inside the window.
```

The email OTP module also compares hashes in constant time now.

---

# Passkey

Source: https://docs.theauth.dev/auth/passkey

Passkeys use the WebAuthn standard (FIDO2) to authenticate users with device biometrics (Touch ID, Face ID, Windows Hello) or hardware security keys. No password is ever created or stored.

## Setup

<Steps>

<Step>

### Install

```bash
pnpm add @glinr/theauth
```

</Step>

<Step>

### Add the plugin

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { passkey } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  plugins: [
    passkey({ // [!code highlight]
      rpName: 'My App',                  // Shown in the browser prompt // [!code highlight]
      rpId: 'example.com',               // Must match your domain, no protocol // [!code highlight]
      origin: 'https://app.example.com', // Exact origin(s) the browser sends // [!code highlight]
    }), // [!code highlight]
  ],
});
```

`origin` is required and is compared exactly against the origin in the browser's `clientDataJSON`. Pass an array to allow several origins. Passkey registration and the credential endpoints need an authenticated user, resolved from `auth.adapter` or an `auth.session` token. The sign-in verify endpoint issues a session only when `auth.session` is configured.

You can also pass the same options as a top-level `passkey` key to `createTheAuth`, which exposes `theauth.passkey` (`getRegistrationOptions`, `verifyRegistration`, `getAuthenticationOptions`, `verifyAuthentication`, `listCredentials`, `removeCredential`, `handleRequest`). It is `null` when the key is absent.

<Warning>
  `rpId` must be a registrable domain suffix of the origin. For `https://app.example.com`, valid values are `app.example.com` or `example.com`. Localhost works during development (set `origin` to the exact dev origin, for example `http://localhost:3000`).
</Warning>

</Step>

</Steps>

## Registration ceremony

A passkey is tied to a specific device. Users register once per device they want to use.

<Steps>

<Step>

### Get registration options

`POST /auth/passkey/register/options`

Returns a WebAuthn challenge from the server. Requires an authenticated user (the user must already be signed in to register a passkey). No request body is needed.

```typescript
const res = await fetch('/api/theauth/auth/passkey/register/options', {
  method: 'POST',
  credentials: 'include',
});

const options = await res.json();
```

</Step>

<Step>

### Create the credential

Pass the options to the browser's WebAuthn API:

```typescript
import { startRegistration } from '@simplewebauthn/browser';

const credential = await startRegistration(options);
```

</Step>

<Step>

### Verify and store

`POST /auth/passkey/register/verify`

```typescript
await fetch('/api/theauth/auth/passkey/register/verify', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ response: credential }), // [!code highlight]
});
```

The body wraps the browser's credential in a `response` field. On success, the credential is stored and the passkey is active, and the response is `{ credential }` with the stored record.

</Step>

</Steps>

## Authentication ceremony

<Steps>

<Step>

### Get authentication options

`POST /auth/passkey/authenticate/options`

Does not require a session, this is the start of sign-in.

```typescript
const res = await fetch('/api/theauth/auth/passkey/authenticate/options', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ userId: 'user_123' }), // optional
});

const options = await res.json();
```

The optional `userId` limits `allowCredentials` to that user's passkeys. Omitting it returns options with no credential restriction, so the authenticator offers any discoverable passkey for the relying party (useful for conditional UI). The body takes a user ID, not an email.

</Step>

<Step>

### Get the assertion

```typescript
import { startAuthentication } from '@simplewebauthn/browser';

const assertion = await startAuthentication(options);
```

</Step>

<Step>

### Verify and start session

`POST /auth/passkey/authenticate/verify`

```typescript
const res = await fetch('/api/theauth/auth/passkey/authenticate/verify', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ response: assertion }), // [!code highlight]
});

const { user, session, credential } = await res.json(); // [!code highlight]
```

With `auth.session` configured, the response is `{ user: { id }, session: { token, expiresAt }, credential }` and a `theauth_session` cookie is set (marked `Secure` when `baseUrl` starts with `https://`). Without a session manager the response is the raw `{ userId, credential }` and no session is created. A failed assertion returns `401`.

</Step>

</Steps>

## Managing credentials

Users can register multiple passkeys across different devices.

### List credentials

`GET /auth/passkey/credentials`

```typescript
const res = await fetch('/api/theauth/auth/passkey/credentials', {
  credentials: 'include',
});

const { credentials } = await res.json();
// each: { id, credentialId, publicKey, counter, userId, deviceName?, transports?, createdAt, lastUsedAt }
```

### Delete a credential

`DELETE /auth/passkey/credentials/:id`

```typescript
await fetch(`/api/theauth/auth/passkey/credentials/${credentialId}`, {
  method: 'DELETE',
  credentials: 'include',
});
```

Returns `{ removed: true }`. Only the authenticated user's own credentials can be removed. Use the `id` field of the listed credential.

<Warning>
  If a user deletes their last passkey and has no other sign-in method, they will be locked out. Check the credential count before allowing deletion, or make sure they have another sign-in method first.
</Warning>

## Endpoints

| Endpoint | Auth required | Description |
|----------|--------------|-------------|
| `POST /auth/passkey/register/options` | Yes | Get challenge to start registration |
| `POST /auth/passkey/register/verify` | Yes | Store the new credential |
| `POST /auth/passkey/authenticate/options` | No | Get challenge to start sign-in |
| `POST /auth/passkey/authenticate/verify` | No | Verify assertion, issue session |
| `GET /auth/passkey/credentials` | Yes | List registered credentials |
| `DELETE /auth/passkey/credentials/:id` | Yes | Remove a credential |

## Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `rpName` | `string` | required | App name shown in the browser passkey prompt |
| `rpId` | `string` | required | Relying party ID, must match your domain |
| `origin` | `string \| string[]` | required | Expected origin(s), compared exactly against the browser's `clientDataJSON` |
| `attestation` | `string` | `'none'` | Attestation preference: `'none'`, `'indirect'`, `'direct'` |
| `userVerification` | `string` | `'preferred'` | Whether biometric check is required: `'required'`, `'preferred'`, `'discouraged'` |
| `challengeTimeout` | `number` | `60000` | Challenge timeout in milliseconds (maximum 300000) |

## Browser client

`createPasskeyClient` in `@glinr/theauth-client` runs the whole ceremony against the plugin's routes, so you do not hand-roll `navigator.credentials` or the base64url conversions.

```typescript
import { createPasskeyClient } from '@glinr/theauth-client';

const passkeys = createPasskeyClient({ baseUrl: '/api/theauth' });

// Add a passkey to the signed-in user.
await passkeys.register('MacBook');

// Modal prompt.
const result = await passkeys.signIn();
if (!result.ok && result.reason !== 'cancelled') console.error(result.message);
```

Results are `{ ok: true, data }` or `{ ok: false, reason, message }`, where `reason` is `cancelled` (the user dismissed the prompt), `unsupported` or `failed`.

### Autofill (conditional UI)

With `conditional: true` the browser lists saved passkeys in the username field's suggestions, and the promise resolves only if the user picks one. Give the field `autocomplete="username webauthn"` and abort the request when the page goes away:

```typescript
if (await passkeys.isConditionalAvailable()) {
  const controller = new AbortController();
  const result = await passkeys.signIn({ conditional: true, signal: controller.signal });
}
```

### Drop-in UI

The `SignIn` component from `@glinr/theauth-ui` does this for you:

```tsx
<SignIn passkeyClient={createPasskeyClient({ baseUrl: '/api/theauth' })} onSuccess={goHome} />
```

It adds a "Sign in with a passkey" button and, when the browser supports it, offers saved passkeys in the email field. Set `passkeyAutofill={false}` for the button only.

## Related

<CardGroup cols={2}>
  <Card title="Username and password" href="/auth/username" icon="key">
    Password-based auth that passkeys can supplement or replace.
  </Card>
  <Card title="Magic link" href="/auth/magic-link" icon="link">
    Another passwordless option using single-use email links.
  </Card>
  <Card title="Sign In with Ethereum" href="/auth/siwe" icon="wallet">
    Wallet-based auth as an alternative to device biometrics.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Sign In With Ethereum

Source: https://docs.theauth.dev/auth/siwe

Sign In With Ethereum (SIWE) lets users authenticate by signing a structured message with their Ethereum wallet. No password. No email. The server verifies the signature came from the claimed address, then creates a session. The standard is [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361).

It works with any wallet that supports personal_sign: MetaMask, WalletConnect, Coinbase Wallet, Rainbow, and others.

## Setup

<Steps>

<Step>

### Add the plugin

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { siwe } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { session: { secret: process.env.SESSION_SECRET! } }, // for creating sessions in your own code
  plugins: [
    siwe({ // [!code highlight]
      domain: 'example.com',             // shown in the wallet prompt // [!code highlight]
      uri: 'https://example.com',        // must match your app's origin // [!code highlight]
      statement: 'Sign in to Example App', // [!code highlight]
    }), // [!code highlight]
  ],
});
```

</Step>

<Step>

### Add a signature verifier (production)

Out of the box, the plugin validates message structure, domain, URI, version, and nonce but does not do secp256k1 signature recovery. For production, pass a `verifySignature` function using [viem](https://viem.sh) or [ethers](https://docs.ethers.org):

<Tabs>
  <Tab title="viem">
```typescript title="lib/theauth.ts"
import { recoverMessageAddress } from 'viem';
import { siwe } from '@glinr/theauth/auth';

siwe({
  domain: 'example.com',
  uri: 'https://example.com',
  verifySignature: async (message, signature) => { // [!code highlight]
    return recoverMessageAddress({ message, signature: signature as `0x${string}` }); // [!code highlight]
  }, // [!code highlight]
})
```
  </Tab>
  <Tab title="ethers">
```typescript title="lib/theauth.ts"
import { ethers } from 'ethers';
import { siwe } from '@glinr/theauth/auth';

siwe({
  domain: 'example.com',
  uri: 'https://example.com',
  verifySignature: async (message, signature) => { // [!code highlight]
    return ethers.verifyMessage(message, signature); // [!code highlight]
  }, // [!code highlight]
})
```
  </Tab>
</Tabs>

<Warning>
  Without `verifySignature`, the plugin trusts the address in the message body and only checks that a signature string of at least 10 characters is present. Anyone can forge a sign-in by submitting a valid-looking message with a made-up signature. Always add signature recovery before going to production.
</Warning>

</Step>

</Steps>

## Sign-in flow

SIWE requires three steps: get a nonce, sign a message in the wallet, then submit both to the server.

<Steps>

<Step>

### Get a nonce

`GET /auth/siwe/nonce`

Request a server-generated nonce before building the sign-in message. Nonces are single-use and expire after 5 minutes (configurable). They are held in memory in the server process, so run the nonce and verify requests against the same instance.

```typescript
const res = await fetch('/api/theauth/auth/siwe/nonce');
const { nonce } = await res.json(); // [!code highlight]
```

</Step>

<Step>

### Build and sign the message

Construct the EIP-4361 message string from the user's address, the nonce, and your app metadata. Pass it to the wallet for signing.

```typescript
import { createSiweModule } from '@glinr/theauth/auth';

// Build the message client-side using the same config as the server
const siweModule = createSiweModule({
  domain: 'example.com',
  uri: 'https://example.com',
  statement: 'Sign in to Example App',
});

const message = siweModule.buildMessage( // [!code highlight]
  address,     // e.g. '0xAbC...' from the connected wallet // [!code highlight]
  nonce,       // from the previous step // [!code highlight]
  1,           // chain ID (1 = Ethereum mainnet) // [!code highlight]
); // [!code highlight]

// Request wallet signature (works with any EIP-1193 provider)
const signature = await window.ethereum.request({ // [!code highlight]
  method: 'personal_sign', // [!code highlight]
  params: [message, address], // [!code highlight]
}); // [!code highlight]
```

The message the user sees in their wallet looks like:

```
example.com wants you to sign in with your Ethereum account:
0xAbC123...

Sign in to Example App

URI: https://example.com
Version: 1
Chain ID: 1
Nonce: a3f9c2e1d8b04f7a
Issued At: 2025-01-01T00:00:00.000Z
```

</Step>

<Step>

### Verify and start a session

`POST /auth/siwe/verify`

Submit the original message and the wallet signature. On success, the server returns the verified Ethereum address and chain ID. The endpoint does not create a user or a session, so create those in your own code from here.

```typescript
const res = await fetch('/api/theauth/auth/siwe/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message, signature }), // [!code highlight]
});

if (!res.ok) {
  const { error } = await res.json();
  console.error(error); // e.g. "Nonce expired" or "Signature does not match address"
} else {
  const { address, chainId } = await res.json(); // [!code highlight]
  // address is the verified Ethereum address. Use it to look up or create the user.
}
```

**Response**, `200 OK`

```json
{
  "address": "0xAbC123...",
  "chainId": 1
}
```

</Step>

</Steps>

## Nonce lifecycle

Every sign-in attempt must use a fresh nonce from the server. Nonces:

- Are 16 random bytes, hex encoded (32 characters, 128 bits of entropy)
- Expire after `nonceTtlSeconds` (default: 300 seconds)
- Are deleted as soon as the message passes the format, domain, URI, version and nonce checks, before the signature is checked, so a failed signature check also consumes the nonce

The nonce is embedded in the signed message, so it cannot be stripped or replaced after signing. This prevents replay attacks: a captured `(message, signature)` pair from one session cannot be submitted again.

If the nonce expires before the user signs, the verify endpoint returns `400` with `"Nonce expired"`. An unknown or already used nonce returns `400` with `"Nonce not found or already used"`. Request a new nonce and rebuild the message.

## Linking wallets to users

SIWE verifies an address, it does not create or look up a user by itself. After a successful `/auth/siwe/verify`, use the returned `address` to find an existing user record or create a new one in your own storage, then create a session with the session manager (available when `auth.session` is configured):

```typescript
const { address, chainId } = await res.json();

// Look up or create the user in your own wallet mapping (your schema)
const userId = await findOrCreateUserByWallet(address.toLowerCase());

// Create a TheAuth session for this user
const { token, session } = await theauth.auth.session!.create(userId);
```

`theauth.auth.session.create(userId)` returns `{ session, token }`. Send `token` to the client as a cookie or bearer token.

## Endpoints

| Endpoint | Method | Auth required | Description |
|----------|--------|--------------|-------------|
| `/auth/siwe/nonce` | `GET` | No | Generate a single-use nonce |
| `/auth/siwe/verify` | `POST` | No | Verify message and signature, return address |

## Configuration reference

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `domain` | `string` | required | Your app's domain, shown in the wallet prompt (e.g. `example.com`) |
| `uri` | `string` | required | Full origin URI (e.g. `https://example.com`). Must match what the wallet signed |
| `statement` | `string` |, | Human-readable statement shown in the wallet (e.g. `Sign in to Example App`) |
| `nonceTtlSeconds` | `number` | `300` | How long a nonce is valid before it expires |
| `verifySignature` | `(message, signature) => Promise<string>` |, | Custom signature recovery function. Returns the recovered address, which must match the address in the message. Required in production |

## Security considerations

**Always add `verifySignature` in production.** Without it, the plugin validates message structure and nonce state, but anyone can submit a well-formed SIWE message for any address without a real signature.

**Domain and URI binding.** The plugin rejects messages where `domain` or `uri` do not match the server's config exactly. This prevents phishing attacks where a malicious site captures a signature meant for a different origin.

**Nonce reuse prevention.** Nonces are deleted once the message passes the structural, domain, URI, version and nonce checks, including when the signature check then fails. A second attempt with the same nonce always fails.

**Chain ID.** The chain ID in the message is returned to your application but is not enforced by the plugin. If your app is chain-specific (e.g. only Ethereum mainnet), check that `chainId === 1` (or your expected value) after verification.

## Related

<CardGroup cols={2}>
  <Card title="Passkey" href="/auth/passkey" icon="fingerprint">
    Device biometric auth as another password-free option.
  </Card>
  <Card title="Magic link" href="/auth/magic-link" icon="link">
    Email-based passwordless sign-in with single-use links.
  </Card>
  <Card title="Anonymous auth" href="/auth/anonymous" icon="user-secret">
    Guest sessions you can upgrade into a real account later.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Device authorization

Source: https://docs.theauth.dev/auth/device

The device authorization grant ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)) lets a device that cannot show a browser, a CLI tool, smart TV, game console, or IoT sensor, authenticate by delegating the sign-in step to a secondary device the user already trusts.

The device displays a short code like `BDFK-RSTV`. The user opens a URL on their phone or laptop, signs in, types the code, and the waiting device's next poll returns `{ authorized: true, user_id }`. No password ever travels through the constrained device. When `auth.session` is configured, the same response also carries an `access_token`, a theAuth session token the device can send as `Authorization: Bearer ...`. To issue a different credential (a JWT, a scoped API token), pass `issueToken` to the plugin. Without either, the response only confirms who approved, and your app decides what to issue next.

## Setup

<Steps>

<Step>

### Add the plugin

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { deviceAuth } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { session: { secret: process.env.SESSION_SECRET! } }, // lets the approval page identify the signed-in user
  plugins: [
    deviceAuth({ // [!code highlight]
      verificationUri: 'https://app.example.com/device', // [!code highlight]
    }), // [!code highlight]
  ],
});
```

Pending grants live in the `deviceCodes` secondary storage, which is process memory by default. Memory is lost on restart and is not shared between instances, so on more than one instance or on serverless set `secondaryStorage: 'database'` (or Redis) on `createTheAuth`. See [Secondary storage](/secondary-storage). Device codes and user codes are stored only as SHA-256 hashes.

</Step>

<Step>

### Build the user-facing approval page

Create a page at your `verificationUri`. It should let a signed-in user enter the code the device is showing and approve or deny the request.

```typescript title="app/device/page.tsx (Next.js)"
export default function DevicePage({
  searchParams,
}: {
  searchParams: { user_code?: string };
}) {
  // user_code is pre-filled when the device uses verification_uri_complete
  const [code, setCode] = useState(searchParams.user_code ?? '');

  async function approve() {
    await fetch('/api/theauth/auth/device/authorize', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ user_code: code, action: 'approve' }), // [!code highlight]
    });
  }

  async function deny() {
    await fetch('/api/theauth/auth/device/authorize', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ user_code: code, action: 'deny' }), // [!code highlight]
    });
  }

  return (
    <form>
      <input value={code} onChange={e => setCode(e.target.value)} placeholder="XXXX-XXXX" />
      <button type="button" onClick={approve}>Approve</button>
      <button type="button" onClick={deny}>Deny</button>
    </form>
  );
}
```

The `/auth/device/authorize` endpoint requires an authenticated user (from `auth.adapter` or an `auth.session` token), and always takes the user from that session. A `user_id` in the body is ignored. The request must use `Content-Type: application/json`, and a browser `Origin` that is neither the origin of `verificationUri` nor listed in `trustedOrigins` is refused with `403`. This stops another site from approving a code on a signed-in user's behalf. `action` is `'approve'` (the default) or `'deny'`.

</Step>

</Steps>

## Device flow

<Steps>

<Step>

### Request codes (on the device)

`POST /auth/device/code`

The device calls this endpoint to start a new authorization attempt. No authentication required. The body is optional JSON (or form-encoded) with `client_id` and `scope`, which are remembered on the grant and passed to `issueToken`.

```typescript title="CLI tool"
const res = await fetch('https://auth.example.com/api/theauth/auth/device/code', {
  method: 'POST',
});

const data = await res.json();
// {
//   device_code: 'a3f9c2e1d8b04f7a...',  // opaque, used for polling
//   user_code: 'BDFK-RSTV',              // shown to the user
//   verification_uri: 'https://app.example.com/device',
//   verification_uri_complete: 'https://app.example.com/device?user_code=BDFK-RSTV',
//   expires_in: 900,                     // seconds until the code expires
//   interval: 5,                         // minimum seconds between poll requests
// }

console.log(`Open ${data.verification_uri} and enter: ${data.user_code}`);
```

Display `user_code` prominently, this is what the user types. `verification_uri_complete` includes the code as a query parameter, so you can also show a QR code for it.

</Step>

<Step>

### Poll for authorization (on the device)

`POST /auth/device/token`

Poll this endpoint at the `interval` returned in the previous step (default: every 5 seconds). Keep polling until you get an `authorized` response or the code expires. While the grant is pending the endpoint answers `400` with `error: 'authorization_pending'`. The body is `{ device_code }`, plus an optional `grant_type` that must be `urn:ietf:params:oauth:grant-type:device_code` if sent.

```typescript title="CLI tool"
const { device_code, expires_in } = data;
let interval: number = data.interval;
const deadline = Date.now() + expires_in * 1000;

while (Date.now() < deadline) {
  await new Promise(resolve => setTimeout(resolve, interval * 1000));

  const pollRes = await fetch('https://auth.example.com/api/theauth/auth/device/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ device_code }), // [!code highlight]
  });

  const result = await pollRes.json();

  if (pollRes.ok && result.authorized) {
    console.log('Authorized! User ID:', result.user_id); // [!code highlight]
    // result.access_token is present when auth.session or issueToken is configured
    break;
  }

  if (pollRes.status === 429) {
    // Rate limited, back off before the next poll
    interval += 5;
    continue;
  }

  if (result.error === 'slow_down') { // [!code highlight]
    // Polled too fast: the server raised the interval by 5 seconds
    interval = result.interval; // [!code highlight]
    continue;
  }

  if (result.error === 'access_denied') {
    console.error('User denied the request.');
    break;
  }

  if (result.error === 'expired_token') {
    console.error('Code expired. Start over.');
    break;
  }

  // 'authorization_pending', keep polling
}
```

</Step>

<Step>

### User approves (on the secondary device)

The user opens `verification_uri` on their phone or laptop, signs in, and enters the code. The approval page calls `/auth/device/authorize` with the user code and `action: 'approve'`. The next poll from the device returns `{ authorized: true, user_id: '...' }` (plus the token fields described below). A denied grant returns `access_denied` instead. A grant can be exchanged once: a second poll after success gets `expired_token`.

</Step>

</Steps>

## User code format

User codes use the format `XXXX-XXXX`, two four-character segments separated by a hyphen. The character set is consonants only (`BCDFGHJKLMNPQRSTVWXZ`), which avoids:

- Visually ambiguous characters (no `0`/`O`, `1`/`I`, `5`/`S`)
- Characters that read awkwardly when pronounced aloud

The alphabet and segment length are fixed. The `codeLength` option controls the length of each segment (default: 4).

Input on the approval page is case-insensitive and whitespace-tolerant, `bdfk rstv`, `BDFK-RSTV`, and `BDFKRSTV` all resolve to the same grant.

## Polling interval and rate limits

The initial interval is returned by the `/auth/device/code` endpoint (default: 5 seconds). Use it as your starting poll delay.

If a device polls again before the interval has passed, `/auth/device/token` answers `400` with `error: 'slow_down'` and a new `interval` that is 5 seconds longer, as RFC 8628 requires. The counter is kept in secondary storage, so it holds across instances. Use the new `interval` for all later polls.

Three more limits apply:

- Guessing user codes: each approving user gets 5 wrong codes per 15 minutes (`userCodeAttemptLimit`, `userCodeAttemptWindowSeconds`). Further attempts get `429`.
- Built in: the plugin throttles `/auth/device/code` and `/auth/device/authorize` to 30 requests per minute per IP and answers `429` with `{ "error": "Rate limit exceeded" }`. These counters are in process memory.
- Optional: add the [`rateLimit()` plugin](/rate-limiting) for shared, configurable limits on all three endpoints (defaults: 10 per minute for `code`, 60 for `token`, 20 for `authorize`).

## Endpoints

| Endpoint | Method | Auth required | Description |
|----------|--------|--------------|-------------|
| `/auth/device/code` | `POST` | No | Start a new device flow, returns device code and user code. Optional body `{ client_id, scope }` |
| `/auth/device/token` | `POST` | No | Poll for authorization status, body `{ device_code }` |
| `/auth/device/authorize` | `POST` | Yes | User approves or denies a device code, body `{ user_code, action }`. Returns `{ authorized: true }` or `{ denied: true }` |

Errors from `/auth/device/authorize`: `401 login_required` (no signed-in user), `403 access_denied` (cross-origin request), `400 invalid_request` (missing or unknown code, code already handled, bad `action`, wrong content type), and `429` after too many wrong codes.

### `/auth/device/code` response

| Field | Type | Description |
|-------|------|-------------|
| `device_code` | `string` | Opaque code used for polling. Keep this secret on the device |
| `user_code` | `string` | Short human-readable code shown to the user (e.g. `BDFK-RSTV`) |
| `verification_uri` | `string` | URL the user visits to approve |
| `verification_uri_complete` | `string` | Same URL with `user_code` as a query param, useful for QR codes |
| `expires_in` | `number` | Seconds until the codes expire (default: 900) |
| `interval` | `number` | Minimum seconds between poll requests (default: 5) |

### `/auth/device/token` responses

An approved grant returns `200` with `{ "authorized": true, "user_id": "..." }`. When a token is issued, the body also has `access_token`, `token_type` (`Bearer`), and, if known, `expires_in`, `refresh_token` and `scope`. Every other state returns `400` with an `error` code and an `error_description`:

| `error` | Meaning |
|---------|---------|
| `authorization_pending` | User has not acted yet, keep polling |
| `slow_down` | Polling too fast, use the larger `interval` in the response |
| `access_denied` | User denied the request, stop polling |
| `expired_token` | Code has expired or was already exchanged, start over with a new code |
| `invalid_request` | `device_code` missing |
| `unsupported_grant_type` | `grant_type` was sent and is not the device code grant |

## Configuration reference

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `verificationUri` | `string` | required | URL the user visits to enter the code and approve |
| `codeLength` | `number` | `4` | Length of each segment in the user code (`4` → `XXXX-XXXX`) |
| `codeExpirySeconds` | `number` | `900` | How long the device code and user code stay valid |
| `pollIntervalSeconds` | `number` | `5` | Minimum interval between polling attempts, returned to the device |
| `storage` | `SecondaryStorage` | `secondaryStorage.deviceCodes` | Where grants are kept. Normally set `secondaryStorage` on `createTheAuth` instead |
| `issueToken` | `(userId, { clientId, scope }) => Promise<{ accessToken, tokenType?, expiresIn?, refreshToken? }>` | session token if `auth.session` is set | Mint the credential returned to the device |
| `userCodeAttemptLimit` | `number` | `5` | Wrong user codes one approving user may try per window |
| `userCodeAttemptWindowSeconds` | `number` | `900` | Length of that window |
| `trustedOrigins` | `string[]` | `[]` | Extra browser origins allowed to call `/auth/device/authorize`, besides the origin of `verificationUri` |
| `onEvent` | `(event) => void` | none | Called with `device.code_issued`, `device.approved`, `device.denied` and `device.token_issued`. Wire it to your audit log |

Without the plugin, `createDeviceAuthModule` takes the same options plus `resolveUser(request)`, which must return the signed-in user for approvals to work. You then route requests through `module.handleRequest(request)`. See [CLI login](/cli-auth) for a ready-made client.

## Related

<CardGroup cols={2}>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
  <Card title="Magic link" href="/auth/magic-link" icon="link">
    Another passwordless flow for devices that can open a browser.
  </Card>
  <Card title="Sessions" href="/sessions" icon="clock">
    How theAuth manages session lifetime and cookie settings.
  </Card>
  <Card title="CLI login" href="/cli-auth" icon="terminal">
    The `theauth login` command and `loginWithDeviceFlow` client.
  </Card>
  <Card title="MCP" href="/mcp" icon="plug">
    Agent-to-tool authentication for MCP servers.
  </Card>
</CardGroup>

---

# Two-factor auth

Source: https://docs.theauth.dev/auth/two-factor

The `twoFactor()` plugin adds TOTP (time-based one-time password) support compatible with Google Authenticator, Authy, 1Password, and any RFC 6238 app. Users enroll once by scanning a QR code, then provide a 6-digit code as a second factor.

theAuth stores and verifies the TOTP secret and backup codes. It does not sit inside your sign-in flow: there is no built-in "two factor required" challenge on the primary sign-in endpoints. You decide in your own sign-in code when to ask for a code, and call the verify endpoint (or `theauth.totp.verify`) to check it.

## Setup

<Steps>

<Step>

### Install

```bash
pnpm add @glinr/theauth
```

</Step>

<Step>

### Add the plugin

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { twoFactor } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  plugins: [
    twoFactor({ // [!code highlight]
      appName: 'My App',  // Shown in the authenticator app // [!code highlight]
    }), // [!code highlight]
  ],
});
```

All plugin endpoints require an authenticated request, resolved from either a configured `auth.adapter` or an `auth.session` token (`Authorization: Bearer` header or the `theauth_session` cookie). `twoFactor()` works with any primary auth method.

Instead of the plugin you can pass the same options as a top-level `totp` key to `createTheAuth`. That exposes `theauth.totp` (`setup`, `enable`, `verify`, `disable`, `isEnabled`, `regenerateBackupCodes`, `handleRequest`), which is `null` when the key is absent. The module's own `handleRequest` serves `POST /auth/2fa/setup`, `/enable`, `/verify`, `/disable` and `/backup-codes` with a `userId` in the body and no session check, so call the methods from server code rather than exposing that handler to browsers.

</Step>

</Steps>

## Enrollment flow

All plugin endpoints require an authenticated request.

<Steps>

<Step>

### Generate a TOTP secret

`POST /auth/2fa/enroll`

```typescript
const res = await fetch('/api/theauth/auth/2fa/enroll', {
  method: 'POST',
  credentials: 'include',
});

const { secret, uri, backupCodes } = await res.json(); // [!code highlight]
```

Render `uri` (an `otpauth://` URL) as a QR code in your UI with a QR library of your choice. The user scans it with their authenticator app, or enters `secret` manually. Show `backupCodes` once at this point. Only hashes are stored, so they cannot be retrieved again. Calling enroll again replaces the secret and backup codes and turns 2FA off until the new secret is confirmed.

</Step>

<Step>

### Confirm with the first code

Ask the user to enter the code their authenticator app shows to confirm enrollment:

`POST /auth/2fa/verify`

```typescript
await fetch('/api/theauth/auth/2fa/verify', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ code: '482910' }),
});
```

While 2FA is not yet enabled, a valid code activates it and the response is `{ "valid": true, "activated": true }`. An invalid code returns `400`.

</Step>

</Steps>

## Checking a code at sign-in

Once 2FA is enabled, the same endpoint checks a code for the authenticated user:

`POST /auth/2fa/verify`

```typescript
const res = await fetch('/api/theauth/auth/2fa/verify', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    code: '482910',   // From the user's authenticator app // [!code highlight]
  }),
});

const { valid, usedBackupCode } = await res.json();
```

The response is `{ valid: boolean, usedBackupCode?: boolean }`. A wrong code returns `200` with `valid: false`, so check the field rather than the status. The endpoint does not issue a session or set a cookie. To gate sign-in, complete the primary sign-in on your server, hold the user in a "pending second factor" state of your own, and call `theauth.totp.verify(userId, code)` before treating them as signed in.

Codes are accepted within one 30-second step either side of the current time by default (`window` and `period` options).

## Backup codes

Ten single-use backup codes are generated at enrollment (`backupCodeCount`). Each is an 8-character code from an alphabet that leaves out `0`, `O`, `1` and `I`. A backup code can be submitted in place of a TOTP code at the verify and disable endpoints, useful when the user has lost access to their authenticator app. A used backup code cannot be reused, and the response reports `usedBackupCode: true`.

### Regenerate backup codes

`POST /auth/2fa/backup-codes`

This invalidates all existing backup codes and issues a new set. It requires a valid TOTP or backup code in the body as confirmation.

```typescript
const res = await fetch('/api/theauth/auth/2fa/backup-codes', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ code: '482910' }),
});

const { backupCodes } = await res.json();
```

## Disabling 2FA

`POST /auth/2fa/disable`

Requires a valid TOTP or backup code as confirmation. Disabling deletes the stored secret and backup codes:

```typescript
await fetch('/api/theauth/auth/2fa/disable', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ code: '482910' }),
});
```

## Check enrollment status

`GET /auth/2fa/status`

```typescript
const res = await fetch('/api/theauth/auth/2fa/status', { credentials: 'include' });
const { enabled } = await res.json();
```

## Endpoints

All endpoints require an authenticated request.

| Endpoint | Description |
|----------|-------------|
| `POST /auth/2fa/enroll` | Generate a TOTP secret, `otpauth://` URI and backup codes |
| `POST /auth/2fa/verify` | Confirm enrollment, or check a code once enabled |
| `POST /auth/2fa/disable` | Disable 2FA, requires a current code |
| `GET /auth/2fa/status` | Check whether 2FA is enabled |
| `POST /auth/2fa/backup-codes` | Regenerate backup codes, requires a current code |

## Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `appName` | `string` | `TheAuth` | App name shown in the authenticator (used as the `otpauth://` label and issuer) |
| `period` | `number` | `30` | TOTP step in seconds |
| `backupCodeCount` | `number` | `10` | Number of backup codes generated at enrollment |
| `window` | `number` | `1` | Adjacent time steps accepted on either side, to tolerate clock drift |

## Related

<CardGroup cols={2}>
  <Card title="Trusted devices" href="/trusted-device" icon="shield-halved">
    Skip 2FA on verified devices for a limited window.
  </Card>
  <Card title="Password breach checking" href="/hibp" icon="shield-halved">
    Block passwords found in known data breaches at sign-up.
  </Card>
  <Card title="Username and password" href="/auth/username" icon="key">
    Built-in primary auth method that pairs with two-factor auth.
  </Card>
  <Card title="Passkey" href="/auth/passkey" icon="fingerprint">
    Phishing-resistant WebAuthn alternative to TOTP.
  </Card>
</CardGroup>

---

# Captcha

Source: https://docs.theauth.dev/auth/captcha

theAuth verifies captcha tokens against Cloudflare Turnstile, hCaptcha, or Google reCAPTCHA. It ships as a verification module, not a plugin: there is no `captcha()` plugin export, and theAuth does not intercept your auth endpoints. You pass the `captcha` config key, then call `theauth.captcha` from your own route handlers before you process a sign-up or sign-in request.

## Supported providers

| Provider | `provider` value |
|----------|------------------|
| Cloudflare Turnstile | `'turnstile'` |
| hCaptcha | `'hcaptcha'` |
| Google reCAPTCHA (v2 or v3) | `'recaptcha'` |

## Setup

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  captcha: { // [!code highlight]
    provider: 'turnstile', // [!code highlight]
    secretKey: process.env.TURNSTILE_SECRET_KEY!, // [!code highlight]
  }, // [!code highlight]
});
```

`theauth.captcha` is `null` unless the `captcha` key is passed, so use `theauth.captcha?.` or `theauth.captcha!.` in handlers.

## Verify a token in your handler

Add the provider's widget to your form and send the token to your server. Check it with `verify`, passing the client IP when you have it:

```typescript title="Protect a sign-up route (server)"
export async function POST(request: Request): Promise<Response> {
  const { captchaToken } = (await request.clone().json()) as { captchaToken?: string };
  const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();

  const result = await theauth.captcha?.verify(captchaToken ?? '', ip); // [!code highlight]
  if (!result?.success) {
    return new Response(JSON.stringify({ error: result?.error ?? 'Captcha failed' }), { status: 403 });
  }

  // ...continue with sign-up
  return new Response('ok');
}
```

`verify(token, ip?)` returns `{ success: boolean, score?: number, error?: string }`. An empty token returns `{ success: false, error: 'Missing captcha token' }`. A network failure or non-2xx response from the provider also returns `success: false` with an `error` message.

## Header-based check

`theauth.captcha.middleware(request)` reads the token from the `X-Captcha-Token` request header and returns `{ valid: boolean, error?: string }`. It takes the client IP from `CF-Connecting-IP` or the first `X-Forwarded-For` entry.

```typescript title="Header check (server)"
const check = await theauth.captcha?.middleware(request);
if (!check?.valid) {
  return new Response(JSON.stringify({ error: check?.error }), { status: 403 });
}
```

```typescript title="Client"
await fetch('/api/sign-up', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'X-Captcha-Token': token },
  body: JSON.stringify({ username: 'ada', password: 'correct horse battery' }),
});
```

## reCAPTCHA v3 score threshold

With `provider: 'recaptcha'`, when the provider response includes a `score` (reCAPTCHA v3), `verify` rejects scores below `minScore` (0.0 to 1.0, default 0.5):

```typescript title="lib/theauth.ts"
const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  captcha: {
    provider: 'recaptcha',
    secretKey: process.env.RECAPTCHA_SECRET!,
    minScore: 0.5, // [!code highlight]
  },
});
```

`minScore` is ignored for Turnstile and hCaptcha. For hCaptcha, a `score` from the provider is returned in the result but not compared with a threshold.

## Configuration reference

<ParamField path="provider" type={`'recaptcha' | 'hcaptcha' | 'turnstile'`} required>Captcha provider to use.</ParamField>
<ParamField path="secretKey" type="string" required>Server-side secret key from the provider dashboard.</ParamField>
<ParamField path="minScore" type="number" default={0.5}>Minimum score for reCAPTCHA v3. Ignored for other providers.</ParamField>
<ParamField path="protectedEndpoints" type="string[]">Endpoint names intended to require captcha (documented default: `sign-up`, `sign-in`, `reset-password`). Accepted in the config type, but theAuth does not currently read it, so enforcement is up to your own handlers.</ParamField>

## Related

<CardGroup cols={2}>
  <Card title="Username and password" href="/auth/username" icon="key">
    A sign-up and sign-in flow you can put captcha checks in front of.
  </Card>
  <Card title="Anonymous auth" href="/auth/anonymous" icon="user-secret">
    Guest sessions as an alternative to requiring sign-up upfront.
  </Card>
  <Card title="Rate limiting" href="/rate-limiting" icon="gauge">
    Request-level rate limiting for agent and auth endpoints.
  </Card>
  <Card title="Hooks" href="/hooks" icon="bolt">
    Lifecycle hooks you can attach to your instance.
  </Card>
</CardGroup>

---

# Anonymous auth

Source: https://docs.theauth.dev/auth/anonymous

The `anonymousAuth` plugin creates a lightweight guest identity on demand. The guest gets a real session and can use your app. When they decide to register, the guest user is upgraded in place by setting an email, so the user ID and any data tied to it are unchanged.

Anonymous users are rows in the users table with a placeholder email (`anon_<id>@theauth.anonymous`) and a `{ anonymous: true }` metadata flag.

## Setup

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { anonymousAuth } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  auth: { session: { secret: process.env.SESSION_SECRET! } }, // [!code highlight]
  plugins: [
    anonymousAuth({ // [!code highlight]
      sessionTtlSeconds: 24 * 60 * 60, // [!code highlight]
    }), // [!code highlight]
  ],
});
```

The plugin throws at startup unless `auth.session` is configured. Its config is optional, `anonymousAuth()` works with defaults.

## Create guest

`POST /auth/anonymous`

Creates a new anonymous user and returns a session token. No request body needed.

```typescript title="Create guest (client)"
const res = await fetch('/api/theauth/auth/anonymous', {
  method: 'POST',
});

const { userId, sessionToken } = await res.json();
// userId is a real user ID, safe to reference in your DB
```

The response is `{ userId, sessionToken }`. The plugin does not set a cookie, so your app stores the token. The endpoint is rate limited per IP address (20 requests per window).

## Check status

`GET /auth/anonymous/status`

Requires an authenticated request. Returns `{ anonymous: boolean }`, which is `true` while the user is an un-upgraded guest.

## Upgrade to real account

`POST /auth/anonymous/upgrade`

Requires an authenticated anonymous session. Sets the real email (and optionally a name) on the guest user. The session stays valid and the user ID does not change.

```typescript title="Upgrade guest (client)"
const res = await fetch('/api/theauth/auth/anonymous/upgrade', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${sessionToken}`,
  },
  body: JSON.stringify({
    email: 'user@example.com', // [!code highlight]
    name: 'Ada Lovelace',
  }),
});

const { upgraded } = await res.json(); // true
```

The body takes `email` (required) and `name`. It does not take a password, so after upgrading you attach a sign-in method (for example magic link or email OTP) to that email. The endpoint does not check whether the email is already used by another account, and users have a unique email constraint, so a duplicate email fails with a `500` error.

**Error responses**

| Status | Meaning |
|--------|---------|
| 400 | `email` missing, or the user is not an anonymous user |
| 401 | No authenticated session |
| 500 | Any other failure, including an email that is already taken |

## Guest cleanup

Anonymous accounts that are never upgraded accumulate. The plugin does not schedule cleanup and does not expose the cleanup function. Use the module directly, with the instance's database and session manager:

```typescript title="Scheduled cleanup"
import { createAnonymousAuthModule } from '@glinr/theauth/auth';

const anonymous = createAnonymousAuthModule({}, theauth.db, theauth.auth.session!);

// Run daily via cron, queue worker, etc.
const removed = await anonymous.cleanup(7 * 24 * 60 * 60 * 1000); // max age in milliseconds
```

`cleanup(maxAgeMs)` deletes anonymous users created before the cutoff (default 24 hours) along with their sessions, and returns the number of users removed. It selects on creation time, not last activity.

<Warning>
  Deleting anonymous users is permanent. Any app data tied to their user ID will become orphaned unless you cascade deletes in your schema.
</Warning>

## Configuration reference

<ParamField path="sessionTtlSeconds" type="number" default="86400 (24 hours)">Intended lifetime of anonymous sessions, in seconds. Currently this value is only recorded in the session metadata. The session itself expires after `auth.session.maxAge` (7 days by default).</ParamField>
<ParamField path="allowAgentCreation" type="boolean" default="false">Whether anonymous users may create agents. Accepted in the config type but not enforced by the plugin endpoints.</ParamField>

## Related

<CardGroup cols={2}>
  <Card title="Email and password" href="/auth/email-password" icon="envelope">
    Full registration flow users can upgrade into from a guest session.
  </Card>
  <Card title="Magic link" href="/auth/magic-link" icon="link">
    Passwordless sign-in that anonymous users can upgrade into.
  </Card>
  <Card title="Sessions" href="/sessions" icon="clock">
    How theAuth manages session lifetime and cookie settings.
  </Card>
  <Card title="Hooks" href="/hooks" icon="bolt">
    Lifecycle hooks you can attach to your instance.
  </Card>
</CardGroup>

---

# Google One-tap

Source: https://docs.theauth.dev/auth/one-tap

Google One-tap lets users sign in with a single tap using their Google account. The frontend shows Google's prompt, the backend verifies the ID token via Google's JWKS. No Google SDK needed server-side.

## Setup

<Steps>
<Step>
### Get a client ID

Go to the [Google Cloud Console](https://console.cloud.google.com/), create an OAuth 2.0 credential, copy the **Client ID**.
</Step>

<Step>
### Configure the plugin

```ts
import { createTheAuth } from '@glinr/theauth';
import { oneTap } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  auth: { session: { secret: process.env.THEAUTH_SECRET! } }, // [!code highlight]
  plugins: [
    oneTap({ clientId: process.env.GOOGLE_CLIENT_ID! }),
  ],
});
```

`oneTap()` issues a session on success, so `auth.session` must be configured. Without it the plugin throws on startup.
</Step>

<Step>
### Add Google's script to your frontend

```html
<script src="https://accounts.google.com/gsi/client" async></script>
<div id="g_id_onload"
  data-client_id="YOUR_CLIENT_ID"
  data-login_uri="https://auth.example.com/auth/one-tap/callback"
  data-auto_prompt="true">
</div>
```
</Step>
</Steps>

## How it works

1. Google's JS shows a sign-in prompt on your page
2. User taps their Google account
3. Google sends a `credential` (JWT ID token) to your callback
4. theAuth verifies the JWT against Google's JWKS (`https://www.googleapis.com/oauth2/v3/certs`)
5. Validates audience, issuer, expiry, and CSRF token
6. Finds the user by email (or creates one when `autoCreateUser` is true) and returns a session as JSON: `{ user: { id, email }, session: { token, expiresAt } }`. The plugin does not set a cookie or redirect

## CSRF protection

Google sends a `g_csrf_token` cookie with the request. theAuth validates that the cookie value matches the `g_csrf_token` field in the POST body.

## Config

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `clientId` | string | required | Google OAuth client ID |
| `autoCreateUser` | boolean | `true` | Create user if not found |
| `csrfCookieName` | string | `"g_csrf_token"` | CSRF cookie name |
| `unsafeAllowUnverifiedEmailLinking` | boolean | `false` | Sign in to an existing account with the same email even when Google or the local address is unverified. Leave it off: it allows account pre-hijacking |

An existing account is matched by email (case-insensitive) only when Google reports `email_verified: true` and the local address is verified. Otherwise the callback answers 409 with code `ACCOUNT_EXISTS` and no session. New users are stored as verified only when Google said so.

## Endpoint

| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/one-tap/callback` | Verify ID token, create session |

<Info>
Google One-tap requires HTTPS in production. It works on localhost for development.
</Info>

<Warning>
  The One-tap handler only matches the exact path `/auth/one-tap/callback`. When theAuth is mounted under an adapter prefix such as `/api/theauth`, the request path includes the prefix and the handler responds with `Unexpected routing error` (500). Serve theAuth with no path prefix for One-tap, or route the callback to it at the root path.
</Warning>

## Related

<CardGroup cols={2}>
  <Card title="Google" href="/auth/google" icon="google">
    Full Google OAuth 2.0 flow with additional scopes.
  </Card>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="Magic link" href="/auth/magic-link" icon="link">
    Another low-friction sign-in option without a password.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# OAuth proxy

Source: https://docs.theauth.dev/auth/oauth-proxy

Mobile apps cannot safely store OAuth client secrets. Embedding a secret in an iOS or Android binary is not safe, it can be extracted. The standard workaround (PKCE without a secret) works for some providers but not all.

The OAuth proxy sits in between: the mobile app kicks off an OAuth flow through theAuth, which holds the client secret and performs the code exchange on the device's behalf. The app gets back the provider's tokens via its custom URL scheme, never touching the secret directly.

<Info>
  `oauthProxy()` takes its own `providers` map of provider instances (the same factories used by `oauth()`, such as `createGoogleProvider`), so it works with any provider you list there. The mobile app only needs to know the provider key and its own redirect URI. The proxy returns the provider's tokens as-is: it does not create a theAuth user or a theAuth session, and it does not need `auth.session`.
</Info>

## How it works

```
Mobile app                TheAuth                    Google
    │                         │                           │
    │ GET /auth/oauth-proxy    │                           │
    │   /start?provider=google │                           │
    │   &redirect_uri=myapp:// │                           │
    │──────────────────────────▶                           │
    │                         │                           │
    │ { authUrl, proxyState } │                           │
    ◀──────────────────────────│                           │
    │                         │                           │
    │ Open authUrl in browser  │                           │
    │─────────────────────────────────────────────────────▶
    │                         │                           │
    │                         │ Redirect to               │
    │                         │ /auth/oauth-proxy/callback│
    │                         ◀─────────────────────────── │
    │                         │                           │
    │                         │ Exchange code (secret     │
    │                         │ never leaves server)      │
    │                         │──────────────────────────▶│
    │                         │ access_token, id_token    │
    │                         ◀───────────────────────────│
    │                         │                           │
    │ 302 → myapp://callback  │                           │
    │   ?access_token=...      │                           │
    ◀──────────────────────────│                           │
```

## Setup

<Steps>

<Step>

### Configure the plugin

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauthProxy, createGoogleProvider } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com', // required: used to build {baseUrl}/auth/oauth-proxy/callback
  plugins: [
    oauthProxy({ // [!code highlight]
      providers: { // [!code highlight]
        google: createGoogleProvider({ // [!code highlight]
          clientId: process.env.GOOGLE_CLIENT_ID!, // [!code highlight]
          clientSecret: process.env.GOOGLE_CLIENT_SECRET!, // [!code highlight]
        }), // [!code highlight]
      }, // [!code highlight]
      allowedRedirectUris: [ // [!code highlight]
        'com.example.myapp://oauth/callback', // [!code highlight]
      ], // [!code highlight]
    }), // [!code highlight]
  ],
});
```

</Step>

<Step>

### Register the server callback URI with your provider

When registering the OAuth application with your provider (Google, GitHub, etc.), add the theAuth callback URL as an allowed redirect URI:

```
https://auth.example.com/auth/oauth-proxy/callback
```

The mobile app's custom scheme (`com.example.myapp://...`) is **not** registered with the provider, only theAuth's server URL is. If you mount theAuth under an adapter prefix such as `/api/theauth`, include it in `baseUrl` and in the URL you register.

</Step>

<Step>

### Implement the flow in your mobile app

```typescript title="Mobile app (React Native / Expo)"
import * as Linking from 'expo-linking';
import * as WebBrowser from 'expo-web-browser';

const BASE_URL = 'https://auth.example.com';

async function signInWithGoogle() {
  // 1. Start the proxy flow
  const redirectUri = Linking.createURL('oauth/callback'); // e.g. com.example.myapp://oauth/callback
  const startRes = await fetch(
    `${BASE_URL}/auth/oauth-proxy/start?provider=google&redirect_uri=${encodeURIComponent(redirectUri)}`
  );
  const { authUrl } = await startRes.json();

  // 2. Open the provider auth page in a browser
  const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUri);

  if (result.type !== 'success') return;

  // 3. Parse tokens from the redirect URL
  const url = new URL(result.url);
  const accessToken = url.searchParams.get('access_token');
  const refreshToken = url.searchParams.get('refresh_token');
  const idToken = url.searchParams.get('id_token');

  // Use the tokens to authenticate with your backend
}
```

</Step>

</Steps>

## Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/auth/oauth-proxy/start` | `GET` | Start a proxy flow, returns the provider auth URL |
| `/auth/oauth-proxy/callback` | `GET` | Provider callback, exchanges the code and redirects to the mobile app |

### `/auth/oauth-proxy/start` query parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `provider` | Yes | Provider ID as configured in `providers` (e.g. `google`, `github`) |
| `redirect_uri` | Yes | Mobile app callback URI. Must be in `allowedRedirectUris` |
| `state` | No | Opaque value forwarded to the mobile app after the flow completes |
| `code_challenge` | No | Accepted but ignored. The server always generates its own PKCE verifier |

### `/auth/oauth-proxy/start` response

```json
{
  "authUrl": "https://accounts.google.com/o/oauth2/v2/auth?...",
  "proxyState": "3f8a2c1d-..."
}
```

Redirect the user to `authUrl`. `proxyState` is managed internally and round-trips through the provider.

### Callback redirect to mobile app

After a successful exchange, the server issues a `302` redirect to the mobile app URI with tokens as query parameters:

```
com.example.myapp://oauth/callback
  ?access_token=ya29.a0ARrdaM...
  &refresh_token=1//0eXz...
  &id_token=eyJhbGci...
  &expires_in=3600
  &state=<your-state>   ← only if state was provided
```

If the user denies the request or the provider returns an error, the redirect includes `?error=access_denied` instead.

## PKCE support

The proxy generates a PKCE code verifier and challenge for every flow. The verifier is stored server-side alongside the proxy state and is used when exchanging the authorization code. The mobile app never needs to supply its own verifier, the server handles this entirely, preventing authorization code interception attacks even for providers that do not require PKCE.

<Warning>
  Tokens are passed as URL query parameters so that custom-scheme handlers on iOS and Android can read them. Treat them as you would any OAuth token, store them in the device's secure keychain, not in plain storage.
</Warning>

## Security

**Redirect URI validation**, only URIs in `allowedRedirectUris` are accepted. Exact matches and scheme-prefix matches (entries ending with `://`) are supported. Everything else returns `400`.

**State TTL**, proxy state entries expire after 10 minutes by default. An expired or unknown state returns `400` and cannot be replayed.

**One-time state**, the state entry is deleted before the token exchange network call, preventing replay attacks even if the callback is called twice. Proxy state is kept in the memory of the server process, so a flow must start and finish on the same instance, and pending flows are lost on restart.

**No open redirects**, the final redirect destination always comes from the stored state entry, never from user-supplied query parameters at callback time.

## Configuration reference

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `providers` | `Record<string, OAuthProvider>` | required | Provider instances keyed by ID |
| `allowedRedirectUris` | `string[]` | required | Allowlist of mobile app redirect URIs |
| `rateLimit.max` | `number` | `20` | Max requests per window per IP |
| `rateLimit.windowSeconds` | `number` | `60` | Rate limit window in seconds |
| `stateTtlSeconds` | `number` | `600` | Proxy state lifetime in seconds |

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Standard browser-based OAuth 2.0 flow with PKCE.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
  <Card title="Sessions" href="/sessions" icon="clock">
    How theAuth manages session lifetime and cookie settings.
  </Card>
  <Card title="Passkey" href="/auth/passkey" icon="fingerprint">
    A fully on-device alternative to OAuth for mobile apps.
  </Card>
</CardGroup>

---

# OAuth overview

Source: https://docs.theauth.dev/auth/oauth

The `oauth()` plugin adds social sign-in to theAuth using the OAuth 2.0 authorization code flow with PKCE (S256). It handles the token exchange, account linking, and session creation.

<Info>
  `oauth()` issues a session on a successful callback, so `auth.session` must be configured in `createTheAuth`. Without it the plugin throws on startup.
</Info>

## Built-in providers

Each provider is a factory exported from `@glinr/theauth/auth`. Pass the resulting instances to `oauth()` as a map keyed by provider ID.

<CardGroup cols={2}>
  <Card title="Google" href="/auth/google" />
  <Card title="GitHub" href="/auth/github" />
  <Card title="Apple" href="/auth/apple" />
  <Card title="Discord" href="/auth/discord" />
  <Card title="Slack" href="/auth/slack" />
  <Card title="Microsoft" href="/auth/microsoft" />
  <Card title="GitLab" href="/auth/gitlab" />
  <Card title="LinkedIn" href="/auth/linkedin" />
</CardGroup>

Other first-class factories (`createXProvider({ clientId, clientSecret, scopes?, redirectUri? })`) exist for Atlassian, Dropbox, Figma, Notion, Reddit, Spotify, Twitch, Twitter / X, and Zoom. Additional services (Facebook, Bitbucket, Yahoo, LINE, Coinbase, and others) have preset helpers such as `facebookProvider(clientId, clientSecret, scopes?)`, and Auth0, Okta, and Cognito take the tenant domain first: `auth0Provider(domain, clientId, clientSecret, scopes?)`. See the individual provider pages.

## Generic setup

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createGoogleProvider } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } }, // [!code highlight]
  plugins: [
    oauth({ // [!code highlight]
      providers: { // [!code highlight]
        google: createGoogleProvider({
          clientId: process.env.GOOGLE_CLIENT_ID!,
          clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
        }),
      }, // [!code highlight]
    }), // [!code highlight]
  ],
});
```

`providers` is a `Record<string, OAuthProvider>`. The key is the provider ID used in the URLs below. The session secret must be at least 32 characters.

The callback URL you register with each provider is:

```
{baseUrl}/auth/oauth/callback/{provider}
```

`baseUrl` is the public URL where theAuth is served. When you mount theAuth under an adapter prefix such as `/api/theauth`, include the prefix in `baseUrl` (for example `https://your-app.com/api/theauth`), so the URL registered with the provider is `https://your-app.com/api/theauth/auth/oauth/callback/google`. To compute a different redirect URI, pass `buildRedirectUri: (provider, baseUrl) => string` to `oauth()`. A provider can also carry its own `redirectUri` override.

## Endpoints

The plugin registers these routes (relative to the adapter prefix):

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/auth/oauth/authorize/:provider` | Redirects (302) to the provider. Rate limited to 20 requests per minute. |
| `GET` | `/auth/oauth/callback/:provider` | Handles the provider callback, creates a session, and redirects. |
| `GET` | `/auth/oauth/providers` | Lists configured providers as `{ providers: [{ id, name }] }`. |
| `POST` | `/auth/oauth/link` | Links a provider account to the signed-in user. Requires authentication. Body: `{ provider, userInfo, tokens }`. |
| `GET` | `/auth/oauth/accounts` | Lists the signed-in user's linked accounts, without token material. |
| `DELETE` | `/auth/oauth/accounts/:provider` | Unlinks an account. Refused with 409 `last_credential` when it is the user's only sign-in method. |

## The sign-in flow

1. Your frontend navigates to `/auth/oauth/authorize/{provider}`. theAuth generates a random state and a PKCE verifier, stores both in the database (default lifetime 10 minutes, set with `stateTtlSeconds`), and redirects to the provider with the S256 challenge.
2. The provider redirects back to `/auth/oauth/callback/{provider}` with an authorization code and the state.
3. theAuth validates and consumes the state, exchanges the code for tokens, and fetches the user profile.
4. theAuth finds the user by email or creates one and links the provider account. It then asks the [sign-in gate](/sign-in-gate) whether a session may be issued. If so it sets the `theauth_session` cookie and redirects to `{baseUrl}/?auth_user=<url-encoded JSON with id and email>`. If the user must change their password or pass a second factor, it redirects to `{baseUrl}/` with the challenge in the URL fragment and sets no cookie.

The provider account is identified by the provider's own user ID, so the same provider account always resolves to the same linked row.

<Info>
  Every first-class provider sends the PKCE challenge except Notion, which does not support PKCE. GitHub accepts the parameter and ignores it.
</Info>

## Account linking

When the email returned by the provider matches an existing user, theAuth links the provider account to that user only if the provider says the address is verified and the existing user's address is verified too. If either side is unverified, the callback answers 409 and links nothing: the user signs in with their existing method and links the provider from account settings. This stops account pre-hijacking, where someone registers a victim's address first and keeps access after the victim signs in with a provider. Otherwise it creates a new user, with the email marked verified only when the provider vouched for it. Providers that do not report verification (Microsoft, Linear, Cloudflare, PayPal, Kick, Spotify and Apple) never link by email. A user can have several linked providers. Only the email and name are copied onto the user record; the avatar is not stored.

Apps that need the old behavior can set `unsafeAllowUnverifiedEmailLinking: true` on `oauth()`. It links by email whatever either side says, so only use it when every sign-up path of your app verifies addresses first.

If a provider returns no email (Reddit, for example), the callback cannot match or create a user. The account row stays unassigned and the callback responds with JSON (`isNewAccount`, `account`, `userInfo`) instead of a session.

<Warning>
  Auto-linking trusts the email reported by the provider and cannot be turned off. If your threat model requires stronger guarantees, only enable providers that verify email addresses.
</Warning>

The plugin adds `GET /auth/oauth/accounts` and `DELETE /auth/oauth/accounts/:provider`, and exposes the OAuth module as `theauth.plugins.getContext().oauth`. From it you get `getAccessToken` (with refresh), `listAccounts` and `unlinkAccount`. See [Social token API](/auth/social-tokens). To work with the module without the plugin, build it with `createOAuthModule(db, { providers })`.

## Custom providers

Any OIDC-compliant provider works with the generic factory `genericOIDC`. Supply an issuer to use discovery, or supply explicit endpoints to skip it:

```typescript
import { oauth, genericOIDC } from '@glinr/theauth/auth';

oauth({
  providers: {
    acme: genericOIDC({
      id: 'acme',
      name: 'Acme SSO',
      issuer: 'https://idp.example.com', // [!code highlight]
      clientId: process.env.ACME_CLIENT_ID!,
      clientSecret: process.env.ACME_CLIENT_SECRET!,
      scopes: ['openid', 'email', 'profile'],
      // authorizationUrl, tokenUrl, userinfoUrl: optional overrides that skip discovery
    }),
  },
})
```

`genericOIDC` reads `sub`, `email`, `name`, and `picture` from the userinfo response and fails if `sub` or `email` is missing. For a provider whose profile endpoint does not return those claims, implement the `OAuthProvider` interface yourself (`getAuthorizationUrl`, `exchangeCode`, and `getUserInfo`, which returns `{ id, email, name?, avatar?, raw }`) and put the instance in `providers`.

## Related

<CardGroup cols={2}>
  <Card title="Google" href="/auth/google" icon="google">
    Sign in with Google using OIDC, the most common provider.
  </Card>
  <Card title="GitHub" href="/auth/github" icon="github">
    OAuth 2.0 for GitHub with optional org and email scopes.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
  <Card title="OIDC provider" href="/auth/oidc-provider" icon="key">
    Turn theAuth itself into an OpenID Connect identity provider.
  </Card>
</CardGroup>

## Native app sign-in

Set `mobile.redirectUris` to let a native app finish the flow in the system browser (RFC 8252 with PKCE):

```typescript
oauth({
  providers: { github: github({ clientId, clientSecret }) },
  mobile: { redirectUris: ['myapp://auth/callback'] },
})
```

| Step | Request |
|---|---|
| 1 | App opens `GET /auth/oauth/authorize/:provider?mobile_redirect=<uri>&code_challenge=<S256>&code_challenge_method=S256` in the browser |
| 2 | After the provider callback, the browser is redirected to `<uri>?code=<one-time code>` (60 seconds, single use) |
| 3 | App calls `POST /auth/oauth/mobile/exchange` with `{ code, code_verifier }` and receives `{ data: { token, user, expiresAt } }` |

`mobile_redirect` must match an entry in `redirectUris` exactly, and the challenge is required. A wrong verifier burns the code. Failures are returned to the app as `?error=`, and a required second factor arrives as the usual `#gate=...` fragment on the app URI. The code is stored in `theauth_one_time_tokens`, which `createTheAuth` now creates whenever the oauth plugin is registered. The [Expo guide](/expo#social-sign-in) shows the client side.

---

# Social token API

Source: https://docs.theauth.dev/auth/social-tokens

When a user signs in with Google or GitHub, theAuth stores the provider's access token and refresh token against the linked account. The social token API is how your server code uses them later: call the provider's API on the user's behalf, show which accounts are linked, and let the user disconnect one.

It lives on the OAuth module, which the `oauth()` plugin exposes through the plugin context.

```ts
import { createTheAuth } from "@glinr/theauth";
import { oauth, createGoogleProvider } from "@glinr/theauth/auth";
import type { OAuthModule } from "@glinr/theauth/auth";

const theauth = await createTheAuth({
  database: { provider: "postgres", url: process.env.DATABASE_URL! },
  baseUrl: "https://app.example.com",
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  plugins: [
    oauth({
      providers: {
        google: createGoogleProvider({
          clientId: process.env.GOOGLE_CLIENT_ID!,
          clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
          scopes: ["openid", "email", "https://www.googleapis.com/auth/calendar.readonly"],
        }),
      },
    }),
  ],
});

const social = theauth.plugins.getContext().oauth as OAuthModule;
```

If you build the module yourself, `createOAuthModule(db, config)` returns the same three methods.

## getAccessToken

```ts
const token = await social.getAccessToken({ userId, provider: "google" });

await fetch("https://www.googleapis.com/calendar/v3/users/me/calendarList", {
  headers: { Authorization: `${token.tokenType} ${token.accessToken}` },
});
```

The result is `{ accessToken, tokenType, expiresAt, refreshed }`. `expiresAt` is `null` when the provider never said, and such a token is returned as is. `refreshed` is `true` when this call had to refresh.

If the stored token expires within `expirySkewSeconds` (default 60), the module exchanges the refresh token for a new one and saves the result before returning. Providers that do not rotate refresh tokens keep the old one. Concurrent calls for the same account in one process share a single refresh, so a provider that rotates refresh tokens sees one use, not several. If a refresh loses a race with another instance and fails, the module re-reads the row and returns the winner's token when it is fresh.

### Errors

Failures throw an error with a typed `code`:

| Code | Meaning | HTTP status on the routes |
| --- | --- | --- |
| `no_account` | The user has no account linked for that provider. | 404 |
| `no_refresh_token` | The access token expired and no refresh token was ever stored. Ask the user to sign in again. | 409 |
| `revoked` | The provider rejected the refresh token (`invalid_grant`). The user must re-authorize. | 409 |
| `refresh_failed` | The refresh failed for another reason, or the provider does not support refresh. May be transient. | 502 |
| `decrypt_failed` | The stored token could not be decrypted, usually a missing key. | 500 |
| `last_credential` | Unlinking would remove the only sign-in method. | 409 |

`OAuthTokenError` and `OAuthRefreshError` are exported from `@glinr/theauth/auth`. The error carries a `code` from the table above and the `provider` id.

```ts
import { OAuthTokenError } from "@glinr/theauth/auth";

try {
  const token = await social.getAccessToken({ userId, provider: "github" });
} catch (err) {
  if (err instanceof OAuthTokenError && (err.code === "revoked" || err.code === "no_refresh_token")) {
    // send the user back through /auth/oauth/authorize/github
  } else {
    throw err;
  }
}
```

### Which providers can refresh

A provider can refresh when its definition has `refreshTokens`. All of these do:

- Atlassian, Discord, Dropbox, Figma, GitHub, GitLab, Google, LinkedIn, Microsoft, Reddit, Spotify, Twitch, Twitter and Zoom
- `genericOIDC` and the presets built on it, such as `facebookProvider`, `tiktokProvider`, `auth0Provider` and `oktaProvider`

These do not: **`createAppleProvider`, `createSlackProvider` and `createNotionProvider`**. For them `getAccessToken` returns the stored token while it is fresh or has no expiry, and fails once an expiry has passed: `no_refresh_token` when none is stored, otherwise `refresh_failed`. The `notionProvider` preset is built on `genericOIDC` and does implement refresh.

Having `refreshTokens` is not the same as holding a refresh token. Providers only issue one in some cases, for example Google returns it on the first consent, and a later sign-in may not include a new one. When none was ever stored, an expired token fails with `no_refresh_token`.

A custom provider opts in by implementing `refreshTokens(refreshToken)` and throwing `OAuthRefreshError` with `reauth: true` on `invalid_grant`.

## listAccounts

```ts
const accounts = await social.listAccounts(userId);
// [{ id, provider, providerAccountId, hasRefreshToken, expiresAt, createdAt, updatedAt }]
```

No token material is ever included, only whether a refresh token exists.

## unlinkAccount

```ts
await social.unlinkAccount(userId, "github");
```

This deletes the stored row. It throws `no_account` when nothing is linked.

### The last-credential guard

Unlinking is refused with `last_credential` when the account is the user's only way to sign in. These count as another way automatically:

- another linked OAuth account
- a username and password account
- a passkey

For anything else (magic link, email OTP, phone), tell the module with `hasOtherSignInMethod`:

```ts
oauth({
  providers: { /* ... */ },
  hasOtherSignInMethod: async (userId) => {
    const user = await db.findUser(userId);
    return user.email !== null && user.emailVerified; // can use a magic link
  },
}),
```

Without that callback the guard cannot see those methods and may block an unlink that would have been safe. It errs toward keeping the user signed in.

Unlinking does not call the provider to revoke the grant. If you need that, revoke it at the provider before you unlink.

## HTTP routes

The `oauth()` plugin also adds two signed-in routes, relative to your adapter prefix:

| Method and path | Result |
| --- | --- |
| `GET /auth/oauth/accounts` | `{ accounts: [...] }`, the same shape as `listAccounts` |
| `DELETE /auth/oauth/accounts/:provider` | `{ success: true }`, or `{ error, code }` with the status in the table above |

Both return 401 without a session. The access token itself is never exposed over HTTP. Read it server side with `getAccessToken`.

## Encrypt tokens at rest

By default provider tokens are stored as plain text. Add `tokenEncryption` to store them sealed with AES-256-GCM, the same envelope the [token vault](/token-vault) uses:

```ts
oauth({
  providers: { /* ... */ },
  tokenEncryption: {
    // 32 random bytes each, base64url
    keys: { k1: process.env.OAUTH_TOKEN_KEY_K1! },
    activeKeyId: "k1",
  },
}),
```

Generate a key with `node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"`.

- Each value is bound to its account id and field, so a ciphertext copied to another row will not decrypt.
- Sealed values carry an `enc:` prefix. Existing plaintext rows keep working and are re-encrypted the next time they are written (sign-in, linking or a refresh).
- Startup throws if encryption is enabled without keys or with an `activeKeyId` that is not in `keys`.
- To rotate, add a new key, point `activeKeyId` at it and keep the old key listed until every row has been rewritten. A row sealed with a key you removed fails with `decrypt_failed`.
- Set `enabled: false` to stop encrypting without removing the keys. Existing sealed rows still need the keys to be read.

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Providers, the sign-in flow and account linking.
  </Card>
  <Card title="Token vault" href="/token-vault" icon="vault">
    Give agents delegated access to provider tokens with consent and audit.
  </Card>
  <Card title="Sign-in gate" href="/sign-in-gate" icon="shield-check">
    The OAuth callback now goes through the gate before a session is issued.
  </Card>
</CardGroup>

---

# Google

Source: https://docs.theauth.dev/auth/google

## Get credentials

<Steps>

<Step>

### Create a project

Go to [Google Cloud Console](https://console.cloud.google.com/) and create a new project (or select an existing one).

</Step>

<Step>

### Enable the People API

Navigate to **APIs and Services > Library**, search for "Google People API", and enable it. This is only needed if you plan to call the People API yourself; sign-in reads the profile from the OpenID userinfo endpoint.

</Step>

<Step>

### Create OAuth credentials

Go to **APIs and Services > Credentials > Create Credentials > OAuth client ID**.

- Application type: **Web application**
- Authorized redirect URIs: `https://auth.example.com/auth/oauth/callback/google`

Copy the **Client ID** and **Client Secret**.

</Step>

<Step>

### Configure the consent screen

Under **OAuth consent screen**, set the app name, support email, and authorized domain. For production, submit for verification if you need access to sensitive scopes.

</Step>

</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createGoogleProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        google: createGoogleProvider({ // [!code highlight]
          clientId: process.env.GOOGLE_CLIENT_ID!, // [!code highlight]
          clientSecret: process.env.GOOGLE_CLIENT_SECRET!, // [!code highlight]
        }), // [!code highlight]
      },
    }),
  ],
});
```

Add to your environment:

```bash
GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-...
```

## Scopes

Default scopes: `openid email profile`. Scopes you pass are added on top of the defaults.

These give you name, email, and profile picture. To request additional permissions:

```typescript
createGoogleProvider({
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  scopes: ['https://www.googleapis.com/auth/calendar.readonly'], // [!code highlight]
})
```

<Info>
  Extra scopes beyond `openid email profile` require your app to complete Google's verification process before they work for users outside your organization.
</Info>

The provider also requests `access_type=offline` and `prompt=consent`, so Google returns a refresh token and shows the consent screen on every sign-in.

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | `sub` claim | Stable Google user ID |
| `email` | `email` claim | Verified by Google |
| `name` | `name` claim | Full display name |
| `avatar` | `picture` claim | Profile photo URL. Not stored on the user record |

## Initiating sign-in

Redirect users to:

```
GET /auth/oauth/authorize/google
```

After the callback, theAuth sets the session cookie and redirects to `{baseUrl}/`. There is no `redirectTo` parameter.

## Related

<CardGroup cols={2}>
  <Card title="Google One-tap" href="/auth/one-tap" icon="google">
    One-tap sign-in widget that uses Google ID tokens server-side.
  </Card>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="Apple" href="/auth/apple" icon="apple">
    Sign in with Apple for iOS apps and web.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# GitHub

Source: https://docs.theauth.dev/auth/github

## Get credentials

<Steps>

<Step>

### Register an OAuth App

Go to [github.com/settings/applications/new](https://github.com/settings/applications/new) (personal account) or **Organization Settings > Developer Settings > OAuth Apps** for an org app.

- **Application name**: your app name
- **Homepage URL**: `https://example.com`
- **Authorization callback URL**: `https://auth.example.com/auth/oauth/callback/github`

</Step>

<Step>

### Copy credentials

After creating the app, copy the **Client ID**. Click **Generate a new client secret** and copy the secret immediately. GitHub only shows it once.

</Step>

</Steps>

<Info>
  GitHub also supports GitHub Apps, which have more granular permissions and work across organizations. OAuth Apps are simpler for sign-in use cases.
</Info>

## Configuration

<Tabs>
  <Tab title="Basic">
    ```typescript title="lib/theauth.ts"
    import { createTheAuth } from '@glinr/theauth';
    import { oauth, createGithubProvider } from '@glinr/theauth/auth';

    const theauth = await createTheAuth({
      database: { provider: 'postgres', url: process.env.DATABASE_URL! },
      secret: process.env.THEAUTH_SECRET!,
      baseUrl: 'https://auth.example.com',
      auth: { session: { secret: process.env.THEAUTH_SECRET! } },
      plugins: [
        oauth({
          providers: {
            github: createGithubProvider({ // [!code highlight]
              clientId: process.env.GITHUB_CLIENT_ID!, // [!code highlight]
              clientSecret: process.env.GITHUB_CLIENT_SECRET!, // [!code highlight]
            }), // [!code highlight]
          },
        }),
      ],
    });
    ```
  </Tab>
  <Tab title="With extra scopes">
    Extra scopes are added on top of the default `user:email`, which is always requested.

    ```typescript title="lib/theauth.ts"
    oauth({
      providers: {
        github: createGithubProvider({
          clientId: process.env.GITHUB_CLIENT_ID!,
          clientSecret: process.env.GITHUB_CLIENT_SECRET!,
          scopes: ['read:org'], // [!code highlight]
        }),
      },
    })
    ```
  </Tab>
</Tabs>

```bash
GITHUB_CLIENT_ID=Ov23li...
GITHUB_CLIENT_SECRET=...
```

## Scopes

Default scope: `user:email`

| Scope | What it unlocks |
|-------|----------------|
| `user:email` | Read the user's email addresses |
| `read:user` | Read the user's profile data |
| `read:org` | Read organization membership |
| `repo` | Access private repositories |

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | `id` field | Stable numeric GitHub user ID |
| `email` | Primary verified email | Fetched separately if not public |
| `name` | `name` field | Display name, may be null |
| `avatar` | `avatar_url` | GitHub avatar URL |

<Warning>
  GitHub users can set their email to private. theAuth fetches the primary verified email from the `/user/emails` endpoint using the `user:email` scope, so you still get it even if the profile email is hidden.
</Warning>

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="GitLab" href="/auth/gitlab" icon="gitlab">
    OAuth 2.0 for GitLab, including self-hosted instances.
  </Card>
  <Card title="Google" href="/auth/google" icon="google">
    Sign in with Google using OIDC.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Apple

Source: https://docs.theauth.dev/auth/apple

Sign in with Apple uses OAuth 2.0 with a few Apple-specific requirements: the client secret is a JWT you generate from a private key, and Apple only returns the user's name on the very first authorization.

<Warning>
  `createAppleProvider` does not currently complete a sign-in through the `oauth()` plugin. Apple has no userinfo endpoint, so the provider reads the user from the `id_token`, but the plugin calls `getUserInfo` with the access token only, and the provider throws `Apple getUserInfo requires the id_token from the token response`. Apple also posts the callback (`response_mode=form_post`), while the plugin registers `GET /auth/oauth/callback/apple` only. The provider builds the authorization URL and exchanges the code, so you can use it with your own callback route, but the steps below do not give you a working end-to-end flow with `oauth()` alone.
</Warning>

## Get credentials from Apple

<Steps>

<Step>

### Register an App ID

1. Sign in to [Apple Developer](https://developer.apple.com/account) and go to **Certificates, Identifiers & Profiles**.
2. Under **Identifiers**, click **+** and choose **App IDs**.
3. Select **App** as the type, then fill in your bundle identifier (e.g. `com.example.app`).
4. Scroll to **Capabilities** and enable **Sign In with Apple**.
5. Save the App ID.

</Step>

<Step>

### Create a Services ID

The Services ID is your OAuth `client_id` for web and non-iOS flows.

1. Under **Identifiers**, click **+** and choose **Services IDs**.
2. Enter a description and an identifier (e.g. `com.example.app.auth`).
3. Enable **Sign In with Apple**.
4. Click **Configure** next to Sign In with Apple:
   - Set your **Primary App ID** to the one you just created.
   - Add your **domain** (e.g. `auth.example.com`, no trailing slash, no protocol).
   - Add your **Return URL** (e.g. `https://auth.example.com/auth/oauth/callback/apple`).
5. Save and register.

</Step>

<Step>

### Create a private key

1. Under **Keys**, click **+**.
2. Name the key and enable **Sign In with Apple**.
3. Click **Configure** and select your Primary App ID.
4. Download the `.p8` key file, **you can only download it once**.
5. Note your **Key ID** and **Team ID** (visible at the top right of the developer portal).

</Step>

</Steps>

<Warning>
  Store the `.p8` file somewhere safe and never commit it to version control. You will use it to sign a JWT locally, then discard the file from your build environment.
</Warning>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createAppleProvider } from '@glinr/theauth/auth'; // [!code highlight]

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        apple: createAppleProvider({ // [!code highlight]
          clientId: process.env.APPLE_CLIENT_ID!,       // Services ID // [!code highlight]
          clientSecret: process.env.APPLE_CLIENT_SECRET!, // Generated JWT (see below) // [!code highlight]
        }), // [!code highlight]
      },
    }),
  ],
});
```

The provider requests the `name` and `email` scopes by default and sends a PKCE challenge. Scopes you pass are added to those.

## Generate the client secret

Apple does not accept a static client secret. Instead, you generate a JWT signed with your `.p8` private key. The JWT is valid for up to 6 months, so you can generate it once and rotate it before it expires.

```typescript title="scripts/generate-apple-secret.ts"
import { SignJWT, importPKCS8 } from 'jose'; // [!code highlight]
import { readFileSync } from 'node:fs';

const teamId = process.env.APPLE_TEAM_ID!;       // 10-character string, e.g. "ABC1234567"
const clientId = process.env.APPLE_CLIENT_ID!;   // Services ID, e.g. "com.example.app.auth"
const keyId = process.env.APPLE_KEY_ID!;         // Key ID from the portal
const privateKeyPem = readFileSync('./AuthKey_XXXXXXXXXX.p8', 'utf-8');

const privateKey = await importPKCS8(privateKeyPem, 'ES256'); // [!code highlight]

const clientSecret = await new SignJWT({}) // [!code highlight]
  .setProtectedHeader({ alg: 'ES256', kid: keyId }) // [!code highlight]
  .setIssuer(teamId) // [!code highlight]
  .setIssuedAt() // [!code highlight]
  .setAudience('https://appleid.apple.com') // [!code highlight]
  .setSubject(clientId) // [!code highlight]
  .setExpirationTime('180d') // [!code highlight]
  .sign(privateKey); // [!code highlight]

console.log(clientSecret); // paste into APPLE_CLIENT_SECRET
```

Run this script locally once, copy the output JWT, and set it as your `APPLE_CLIENT_SECRET` environment variable. Re-run before the 6-month window closes.

<Info>
  The `jose` library is a dependency of theAuth. Install it in your own project if your script imports it directly.
</Info>

## Environment variables

```bash title=".env"
APPLE_CLIENT_ID=com.example.app.auth
APPLE_CLIENT_SECRET=eyJ...  # JWT generated by the script above
APPLE_TEAM_ID=ABC1234567
APPLE_KEY_ID=XXXXXXXXXX
```

Only `APPLE_CLIENT_ID` and `APPLE_CLIENT_SECRET` are needed at runtime. The Team ID and Key ID are only used when regenerating the secret.

## iOS native apps

<Info>
  The provider has no separate setting for native apps. A native flow would use the App ID (bundle identifier) as the client ID, but no endpoint in the `oauth()` plugin accepts a code obtained by Apple's native SDK, so native Sign in with Apple is not supported by the plugin today.
</Info>

## Localhost and development

Apple requires HTTPS for redirect URIs. `localhost` will not work. Options:

- **[ngrok](https://ngrok.com/)**, `ngrok http 3000` gives you a public HTTPS URL instantly.
- **[cloudflared tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/local-management/)**, persistent tunnel with a stable subdomain.
- **mkcert + local proxy**, run a local HTTPS reverse proxy with a self-signed cert trusted by your browser.

Update both your Apple Services ID redirect URI and your `baseUrl` config to match the tunnel URL while developing.

## User data

Apple embeds user claims in the `id_token` JWT returned from the token endpoint. The provider decodes it without verifying the signature, and throws if the `sub` or `email` claim is missing.

| Field | Notes |
|-------|-------|
| `id` | The `sub` claim, a stable Apple user identifier |
| `email` | May be a private relay address (`random@privaterelay.appleid.com`) if the user chose to hide their email. Absent on repeat authorizations, which makes the provider throw |
| `name` | Not available from the `id_token`. Apple delivers it only in the `user` form field on the first authorization, and the provider does not read it |

<Warning>
  Apple returns the name only once, on the very first sign-in, in a `user` form-post field alongside the authorization code. The provider returns `name: undefined`, so you must capture that field in your own callback handler if you need it.
</Warning>

## Endpoints

The `oauth()` plugin registers the same routes for every provider:

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/auth/oauth/authorize/apple` | Redirect to Apple. |
| `GET` | `/auth/oauth/callback/apple` | Callback route. Apple posts to its return URL, so this `GET` route does not receive Apple's response. |

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="Google" href="/auth/google" icon="google">
    Another popular sign-in provider using OIDC.
  </Card>
  <Card title="Microsoft" href="/auth/microsoft" icon="microsoft">
    Sign in with Microsoft Entra ID for personal and work accounts.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Microsoft

Source: https://docs.theauth.dev/auth/microsoft

## Get credentials

<Steps>

<Step>

### Register an application

Go to the [Azure Portal](https://portal.azure.com/) and navigate to **Microsoft Entra ID > App registrations > New registration**.

- **Name**: your app name
- **Supported account types**: choose based on your needs (see below)
- **Redirect URI**: Web, `https://auth.example.com/auth/oauth/callback/microsoft`

</Step>

<Step>

### Create a client secret

Navigate to **Certificates and secrets > New client secret**. Set an expiry and copy the secret value immediately.

</Step>

<Step>

### Copy the Application ID

From the app overview, copy the **Application (client) ID** and the **Directory (tenant) ID**.

</Step>

</Steps>

## Configuration

<Tabs>
  <Tab title="Personal + work accounts">
    ```typescript title="lib/theauth.ts"
    import { createTheAuth } from '@glinr/theauth';
    import { oauth, createMicrosoftProvider } from '@glinr/theauth/auth';

    const theauth = await createTheAuth({
      database: { provider: 'postgres', url: process.env.DATABASE_URL! },
      secret: process.env.THEAUTH_SECRET!,
      baseUrl: 'https://auth.example.com',
      auth: { session: { secret: process.env.THEAUTH_SECRET! } },
      plugins: [
        oauth({
          providers: {
            microsoft: createMicrosoftProvider({ // [!code highlight]
              clientId: process.env.MICROSOFT_CLIENT_ID!, // [!code highlight]
              clientSecret: process.env.MICROSOFT_CLIENT_SECRET!, // [!code highlight]
            }), // [!code highlight]
          },
        }),
      ],
    });
    ```
  </Tab>
  <Tab title="Work accounts only (single tenant)">
    `createMicrosoftProvider` is fixed to the `common` authority and has no `tenant` option. A single-tenant app registration needs its tenant-specific endpoints, so build the provider with the generic OIDC factory instead. Note that this reads the OIDC `sub` and `email` claims from the userinfo response, so the user ID differs from the Graph `id` used by `createMicrosoftProvider`:

    ```typescript title="lib/theauth.ts"
    import { oauth, genericOIDC } from '@glinr/theauth/auth';

    const tenant = process.env.MICROSOFT_TENANT_ID!;

    oauth({
      providers: {
        microsoft: genericOIDC({
          id: 'microsoft',
          name: 'Microsoft',
          issuer: `https://login.microsoftonline.com/${tenant}/v2.0`, // [!code highlight]
          clientId: process.env.MICROSOFT_CLIENT_ID!,
          clientSecret: process.env.MICROSOFT_CLIENT_SECRET!,
          scopes: ['openid', 'profile', 'email'],
          authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`, // [!code highlight]
          tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, // [!code highlight]
          userinfoUrl: 'https://graph.microsoft.com/oidc/userinfo', // [!code highlight]
        }),
      },
    })
    ```
  </Tab>
</Tabs>

```bash
MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
MICROSOFT_CLIENT_SECRET=...
MICROSOFT_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx  # only for single-tenant
```

## Account types and tenant

`createMicrosoftProvider` always uses the `common` authority (`https://login.microsoftonline.com/common/oauth2/v2.0/...`). The segment in the endpoint URLs decides who can sign in, which you can control with the generic factory shown above:

| Authority segment | Who can sign in |
|-------|----------------|
| `common` (what `createMicrosoftProvider` uses) | Personal Microsoft accounts and work/school accounts |
| `organizations` | Work and school accounts only |
| `consumers` | Personal Microsoft accounts only |
| Your tenant ID | Only users in your Azure AD directory |

## Scopes

Default scopes: `openid profile email User.Read`. Scopes you pass are added on top of the defaults.

| Scope | What it unlocks |
|-------|----------------|
| `openid email profile` | Standard OIDC identity |
| `User.Read` | Read the signed-in user's profile from MS Graph |
| `Calendars.Read` | Read calendar events |
| `Mail.Read` | Read email |

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | Graph `/me` `id` field | Stable object ID within the tenant |
| `email` | Graph `mail`, falling back to `userPrincipalName` | Work email or Microsoft account email |
| `name` | Graph `displayName` | Display name |
| `avatar` | Not populated | The provider does not fetch the Graph photo |

<Info>
  The profile photo is not fetched. Graph serves it from a separate `/me/photo/$value` call that you would make yourself with the user's access token.
</Info>

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="SSO" href="/auth/sso" icon="shield-halved">
    SAML 2.0 and OIDC enterprise SSO, including Azure AD.
  </Card>
  <Card title="Google" href="/auth/google" icon="google">
    Sign in with Google for personal and workspace accounts.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Discord

Source: https://docs.theauth.dev/auth/discord

## Get credentials

<Steps>

<Step>

### Create an application

Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Give it a name.

</Step>

<Step>

### Add a redirect URI

Navigate to **OAuth2 > General**. Under **Redirects**, add:

```
https://auth.example.com/auth/oauth/callback/discord
```

</Step>

<Step>

### Copy credentials

From **OAuth2 > General**, copy the **Client ID** and **Client Secret**.

</Step>

</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createDiscordProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        discord: createDiscordProvider({ // [!code highlight]
          clientId: process.env.DISCORD_CLIENT_ID!, // [!code highlight]
          clientSecret: process.env.DISCORD_CLIENT_SECRET!, // [!code highlight]
        }), // [!code highlight]
      },
    }),
  ],
});
```

```bash
DISCORD_CLIENT_ID=1234567890123456789
DISCORD_CLIENT_SECRET=...
```

## Scopes

Default scopes: `identify email`. Scopes you pass are added on top of the defaults.

| Scope | What it unlocks |
|-------|----------------|
| `identify` | Read username, discriminator, avatar |
| `email` | Read the user's email address |
| `guilds` | Read the servers the user belongs to |
| `guilds.members.read` | Read guild membership details |

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | `id` field | Stable Discord snowflake ID |
| `email` | `email` field | Verified email |
| `name` | `global_name`, falling back to `username` (with `#discriminator` for legacy accounts) | Display name |
| `avatar` | `avatar` hash | Constructed CDN URL |

<Info>
  Discord email addresses are verified before the account can use OAuth. You will always receive a verified email when the `email` scope is requested.
</Info>

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="Slack" href="/auth/slack" icon="slack">
    Sign in with Slack using OpenID Connect.
  </Card>
  <Card title="GitHub" href="/auth/github" icon="github">
    Another developer-focused OAuth provider.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Slack

Source: https://docs.theauth.dev/auth/slack

## Get credentials

<Steps>

<Step>

### Create a Slack app

Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App > From scratch**. Name your app and select a development workspace.

</Step>

<Step>

### Configure OAuth and permissions

Navigate to **OAuth and Permissions**. Under **Redirect URLs**, add:

```
https://auth.example.com/auth/oauth/callback/slack
```

Under **Scopes > User Token Scopes**, add `openid`, `email`, and `profile`.

</Step>

<Step>

### Copy credentials

Go to **Basic Information** and copy the **Client ID** and **Client Secret** under **App Credentials**.

</Step>

</Steps>

<Info>
  theAuth uses Slack's OpenID Connect flow (`/openid/connect/authorize`), not the older `identity.basic` scope approach. Make sure you add **User Token Scopes**, not Bot Token Scopes.
</Info>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createSlackProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        slack: createSlackProvider({ // [!code highlight]
          clientId: process.env.SLACK_CLIENT_ID!, // [!code highlight]
          clientSecret: process.env.SLACK_CLIENT_SECRET!, // [!code highlight]
        }), // [!code highlight]
      },
    }),
  ],
});
```

```bash
SLACK_CLIENT_ID=1234567890.1234567890123
SLACK_CLIENT_SECRET=...
```

## Scopes

Default scopes: `openid email profile`

These are standard OIDC scopes that Slack supports. No additional User Token Scopes are needed for basic sign-in.

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | `sub` claim | Stable Slack user ID |
| `email` | `email` claim | Workspace email |
| `name` | `name` claim | Display name |
| `avatar` | `picture` claim | Profile photo URL |

<Info>
  The provider uses the OIDC `sub` claim as the user ID, which Slack documents as the same across workspaces for a given Slack user. Falls back to the `https://slack.com/user_id` claim if `sub` is absent.
</Info>

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="Discord" href="/auth/discord" icon="discord">
    Another workspace-based OAuth provider.
  </Card>
  <Card title="LinkedIn" href="/auth/linkedin" icon="linkedin">
    Professional network OAuth using OpenID Connect.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# LinkedIn

Source: https://docs.theauth.dev/auth/linkedin

## Get credentials

<Steps>

<Step>

### Create an application

Go to [linkedin.com/developers/apps/new](https://www.linkedin.com/developers/apps/new). You will need a LinkedIn Page associated with the app (create a company page if you do not have one).

</Step>

<Step>

### Enable Sign In with LinkedIn

In your app dashboard, go to the **Products** tab and request access to **Sign In with LinkedIn using OpenID Connect**. This is usually granted immediately.

</Step>

<Step>

### Add a redirect URL

Go to **Auth > OAuth 2.0 settings**. Under **Authorized redirect URLs for your app**, add:

```
https://auth.example.com/auth/oauth/callback/linkedin
```

</Step>

<Step>

### Copy credentials

From the **Auth** tab, copy the **Client ID** and **Client Secret**.

</Step>

</Steps>

<Warning>
  LinkedIn's legacy `r_liteprofile` and `r_emailaddress` scopes are deprecated. theAuth uses the OpenID Connect flow with `openid`, `profile`, and `email` scopes, which requires the "Sign In with LinkedIn using OpenID Connect" product to be enabled on your app.
</Warning>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createLinkedInProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        linkedin: createLinkedInProvider({ // [!code highlight]
          clientId: process.env.LINKEDIN_CLIENT_ID!, // [!code highlight]
          clientSecret: process.env.LINKEDIN_CLIENT_SECRET!, // [!code highlight]
        }), // [!code highlight]
      },
    }),
  ],
});
```

```bash
LINKEDIN_CLIENT_ID=...
LINKEDIN_CLIENT_SECRET=...
```

## Scopes

Default scopes: `openid profile email`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OpenID Connect identity |
| `profile` | Name and profile picture |
| `email` | Primary email address |

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | `sub` claim | Stable LinkedIn member ID |
| `email` | `email` claim | Primary email (verified) |
| `name` | `name` claim | Full name |
| `avatar` | `picture` claim | Profile photo URL |

<Info>
  LinkedIn profile photos are hosted on their CDN and may require authentication headers to load in `<img>` tags depending on the user's privacy settings. Store the URL in your database but be prepared for it to become inaccessible.
</Info>

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="Slack" href="/auth/slack" icon="slack">
    Another workspace-focused OAuth provider using OpenID Connect.
  </Card>
  <Card title="Microsoft" href="/auth/microsoft" icon="microsoft">
    Sign in with Microsoft Entra ID for work accounts.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Facebook

Source: https://docs.theauth.dev/auth/facebook

## Setup

<Steps>
<Step>
### Get credentials

Go to the [Facebook Developer Portal](https://developers.facebook.com/) and create an app. Under **Facebook Login > Settings**, add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/facebook
```

Copy the **App ID** and **App Secret** from the app dashboard.
</Step>

<Step>
### Configure

```ts
import { createTheAuth } from '@glinr/theauth';
import { oauth, facebookProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        facebook: facebookProvider( // [!code highlight]
          process.env.FACEBOOK_CLIENT_ID!, // [!code highlight]
          process.env.FACEBOOK_CLIENT_SECRET!, // [!code highlight]
        ), // [!code highlight]
      },
    }),
  ],
});
```
</Step>
</Steps>

## Environment variables

```bash
FACEBOOK_CLIENT_ID=your_app_id
FACEBOOK_CLIENT_SECRET=your_app_secret
```

## Scopes

Default scopes: `email`, `public_profile`

To request additional data, pass a `scopes` array as the third argument. It replaces the defaults, so include `email` and `public_profile` too:

```ts
facebookProvider(
  process.env.FACEBOOK_CLIENT_ID!,
  process.env.FACEBOOK_CLIENT_SECRET!,
  ['email', 'public_profile', 'user_birthday'], // [!code highlight]
)
```

<Warning>
  Facebook requires app review before requesting most extended permissions beyond `email` and `public_profile`.
</Warning>

<Warning>
  This is a preset built on the generic OIDC factory, which reads the user from `https://graph.facebook.com/me?fields=id,email,name,picture` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a numeric `id` field, so sign-in is expected to fail with `Facebook userinfo response missing required "sub" field`. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/facebook` | Redirect to Facebook |
| GET | `/auth/oauth/callback/facebook` | Handle callback |

---

# Spotify

Source: https://docs.theauth.dev/auth/spotify

## Setup

<Steps>
<Step>
### Get credentials

Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and create an app. Under **Edit Settings**, add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/spotify
```

Copy the **Client ID** and **Client Secret** from the app overview.
</Step>

<Step>
### Configure

```ts
import { createTheAuth } from '@glinr/theauth';
import { oauth, createSpotifyProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        spotify: createSpotifyProvider({ // [!code highlight]
          clientId: process.env.SPOTIFY_CLIENT_ID!, // [!code highlight]
          clientSecret: process.env.SPOTIFY_CLIENT_SECRET!, // [!code highlight]
        }), // [!code highlight]
      },
    }),
  ],
});
```
</Step>
</Steps>

## Environment variables

```bash
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
```

## Scopes

Default scopes: `user-read-email`, `user-read-private`

To access additional Spotify data, pass extra `scopes`. They are added on top of the defaults:

```ts
createSpotifyProvider({
  clientId: process.env.SPOTIFY_CLIENT_ID!,
  clientSecret: process.env.SPOTIFY_CLIENT_SECRET!,
  scopes: ['user-library-read'], // [!code highlight]
})
```

<Note>
  The `user-read-email` scope is required to retrieve the user's email address. Without it, the provider returns no email, and the `oauth()` plugin cannot match or create a user for that account (the callback returns JSON instead of a session).
</Note>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/spotify` | Redirect to Spotify |
| GET | `/auth/oauth/callback/spotify` | Handle callback |

---

# Twitch

Source: https://docs.theauth.dev/auth/twitch

## Get credentials

<Steps>

<Step>

### Register an application

Go to the [Twitch Developer Console](https://dev.twitch.tv/console/apps) and click **Register Your Application**. Set the **OAuth Redirect URL** to:

```
https://auth.example.com/auth/oauth/callback/twitch
```

Pick any category, **Website Integration** works for most apps.

</Step>

<Step>

### Copy credentials

After saving, click **Manage** on the app. Copy the **Client ID**. Click **New Secret** to generate and copy the **Client Secret**.

</Step>

</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createTwitchProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        twitch: createTwitchProvider({ // [!code highlight]
          clientId: process.env.TWITCH_CLIENT_ID!, // [!code highlight]
          clientSecret: process.env.TWITCH_CLIENT_SECRET!, // [!code highlight]
        }), // [!code highlight]
      },
    }),
  ],
});
```

```bash
TWITCH_CLIENT_ID=abcdef1234567890abcdef1234567890
TWITCH_CLIENT_SECRET=...
```

## Endpoints

| Endpoint | URL |
|----------|-----|
| Authorization | `https://id.twitch.tv/oauth2/authorize` |
| Token | `https://id.twitch.tv/oauth2/token` |
| User info | `https://api.twitch.tv/helix/users` |

## Scopes

Default scope: `user:read:email`

| Scope | What it unlocks |
|-------|----------------|
| `user:read:email` | Read the user's verified email address |
| `user:read:follows` | Read the channels the user follows |
| `channel:read:subscriptions` | Read the user's channel subscriptions |

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | `data[0].id` | Stable numeric Twitch user ID |
| `email` | `data[0].email` | Only present with `user:read:email` scope |
| `name` | `data[0].display_name` | Localized display name (may differ from login) |
| `avatar` | `data[0].profile_image_url` | Direct CDN URL; changes when user updates profile |

<Info>
  The Twitch Helix API requires a `Client-ID` header on every request alongside the Bearer token. theAuth handles this automatically, you do not need to set it manually.
</Info>

<Warning>
  Twitch email addresses may not be verified. Check the `broadcaster_type` and account age in the raw response if you need higher assurance.
</Warning>

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="key">
    Generic OAuth configuration and custom provider setup.
  </Card>
  <Card title="Google" href="/auth/google" icon="google">
    Another popular social provider with verified email addresses.
  </Card>
  <Card title="GitHub" href="/auth/github" icon="github">
    Developer-focused OAuth provider with org membership scopes.
  </Card>
  <Card title="Reddit" href="/auth/reddit" icon="reddit">
    Social provider in the same nav group.
  </Card>
</CardGroup>

---

# Reddit

Source: https://docs.theauth.dev/auth/reddit

## Get credentials

<Steps>

<Step>

### Create an app

Go to [Reddit App Preferences](https://www.reddit.com/prefs/apps) and scroll to the bottom. Click **Create another app...**. Select **web app** as the type and add your redirect URI:

```
https://auth.example.com/auth/oauth/callback/reddit
```

</Step>

<Step>

### Copy credentials

After saving, the **client ID** appears directly under the app name (a short string like `abc123XYZ`). Click **edit** to reveal or regenerate the **secret**.

</Step>

</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createRedditProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        reddit: createRedditProvider({ // [!code highlight]
          clientId: process.env.REDDIT_CLIENT_ID!, // [!code highlight]
          clientSecret: process.env.REDDIT_CLIENT_SECRET!, // [!code highlight]
        }), // [!code highlight]
      },
    }),
  ],
});
```

```bash
REDDIT_CLIENT_ID=abc123XYZ
REDDIT_CLIENT_SECRET=...
```

## Endpoints

| Endpoint | URL |
|----------|-----|
| Authorization | `https://www.reddit.com/api/v1/authorize` |
| Token | `https://www.reddit.com/api/v1/access_token` |
| User info | `https://oauth.reddit.com/api/v1/me` |

## Scopes

Default scope: `identity`. Scopes you pass are added on top of the default.

| Scope | What it unlocks |
|-------|----------------|
| `identity` | Read the user's account info (username, avatar, karma) |
| `read` | Read posts and comments on the user's behalf |
| `subscribe` | Read and manage subreddit subscriptions |
| `history` | Read the user's post and comment history |

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | `id` | Stable Reddit account ID (base-36 string) |
| `email` |, | Not available. Reddit does not expose email via OAuth |
| `name` | `name` | Reddit username |
| `avatar` | `icon_img` | Query parameters stripped; may be a default avatar |

<Warning>
  Reddit does not expose the user's email address via OAuth. If your app requires an email, prompt the user to enter one after sign-in and store it separately.
</Warning>

<Info>
  Reddit's token endpoint uses HTTP Basic authentication rather than posting credentials in the request body. theAuth handles this automatically.
</Info>

## Handling missing email

The `oauth()` plugin matches or creates a theAuth user by email. Because Reddit returns none, a Reddit callback does not produce a session: the provider account is stored without a user, and the callback responds with JSON (`isNewAccount`, `account`, `userInfo`) instead of redirecting. The same happens on later sign-ins with that account.

To support Reddit sign-in you need to collect an email yourself and attach the account to a user, for example with `POST /auth/oauth/link` from an authenticated session or `linkAccount` on a module built with `createOAuthModule`.

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="Discord" href="/auth/discord" icon="discord">
    Another community-focused OAuth provider.
  </Card>
  <Card title="GitHub" href="/auth/github" icon="github">
    Developer-focused OAuth provider with similar setup.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Twitter / X

Source: https://docs.theauth.dev/auth/twitter

## Setup

<Steps>
<Step>
### Get credentials

Go to the [Twitter Developer Portal](https://developer.twitter.com) and create a project and app. Under **User authentication settings**, enable OAuth 2.0 and set the redirect URI to:

```
https://your-app.com/api/theauth/auth/oauth/callback/twitter
```

Set the app type to **Web App** and enable **Read** permissions at minimum.
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createTwitterProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        twitter: createTwitterProvider({
          clientId: process.env.TWITTER_CLIENT_ID!,
          clientSecret: process.env.TWITTER_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
TWITTER_CLIENT_ID=...
TWITTER_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `users.read`, `tweet.read`. Scopes you pass are added on top of the defaults.

| Scope | What it unlocks |
|-------|----------------|
| `users.read` | Read the user's profile |
| `tweet.read` | Read tweets |
| `offline.access` | Refresh token support |

<Warning>
  Twitter does not return an email address through the standard OAuth 2.0 flow. The provider reports a synthetic non-deliverable address (`username@twitter.invalid`) as the email, and the `oauth()` plugin stores it on the user record because it matches users by email. Do not treat it as a real email.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/twitter` | Redirect to Twitter |
| GET | `/auth/oauth/callback/twitter` | Handle callback |

---

# TikTok

Source: https://docs.theauth.dev/auth/tiktok

## Setup

<Steps>
<Step>
### Get credentials

Go to the [TikTok for Developers](https://developers.tiktok.com) portal and create an app. Under **Login Kit**, add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/tiktok
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, tiktokProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        tiktok: tiktokProvider(
          process.env.TIKTOK_CLIENT_ID!,
          process.env.TIKTOK_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
TIKTOK_CLIENT_ID=...
TIKTOK_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `user.info.basic`

| Scope | What it unlocks |
|-------|----------------|
| `user.info.basic` | Display name and avatar |
| `user.info.profile` | Profile URL and bio |
| `user.info.stats` | Follower and video counts |

<Info>
  TikTok Login Kit requires app review before production use. During development you can test with sandbox accounts added to your app's tester list.
</Info>

<Warning>
  This is a preset built on the generic OIDC factory, which reads the user from `https://open.tiktokapis.com/v2/user/info/` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns an `open_id` nested under `data.user` and no email, so sign-in is expected to fail with `TikTok userinfo response missing required "sub" field`. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/tiktok` | Redirect to TikTok |
| GET | `/auth/oauth/callback/tiktok` | Handle callback |

---

# Kakao

Source: https://docs.theauth.dev/auth/kakao

## Setup

<Steps>
<Step>
### Get credentials

Go to the [Kakao Developers](https://developers.kakao.com) portal and create an application. Under **Kakao Login**, enable the feature and add your redirect URI. Use the app's **REST API key** as the client ID and the Kakao Login client secret as the client secret:

```
https://your-app.com/api/theauth/auth/oauth/callback/kakao
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createKakaoProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        kakao: createKakaoProvider({
          clientId: process.env.KAKAO_CLIENT_ID!,
          clientSecret: process.env.KAKAO_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
KAKAO_CLIENT_ID=...
KAKAO_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `account_email`, `profile_nickname`, `profile_image`

| Scope | What it unlocks |
|-------|----------------|
| `profile_nickname` | Kakao nickname |
| `profile_image` | Profile image |
| `account_email` | Email address (requires business verification) |

<Warning>
  Email access requires business verification in the Kakao Developer Console. Without it, only `profile_nickname` and `profile_image` are available.
</Warning>

## How it works

- Scopes are sent comma separated, as Kakao requires.
- Kakao's REST guide does not describe PKCE, so no code challenge is sent.
- The client secret is required at the token endpoint unless you turn it off for the app in the Kakao console.
- The profile call is `https://kapi.kakao.com/v2/user/me`. The numeric `id` is stored as a string, and the email, nickname and image sit under `kakao_account`.
- The email counts as verified only when both `is_email_verified` is true and `is_email_valid` is not false.
- A refresh returns a new refresh token only when the old one expires within a month.

<Info>
  `kakaoProvider` (the older preset) reads only the OIDC `sub` claim and cannot read Kakao's profile shape. It is still exported for existing code. Use `createKakaoProvider` for new setups.
</Info>

Vendor docs: [Kakao Login REST API](https://developers.kakao.com/docs/en/kakaologin/rest-api).

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/kakao` | Redirect to Kakao |
| GET | `/auth/oauth/callback/kakao` | Handle callback |

---

# Naver

Source: https://docs.theauth.dev/auth/naver

## Setup

<Steps>
<Step>
### Get credentials

Go to [developers.naver.com](https://developers.naver.com) and create an application. Under **API Settings**, add **Naver Login** and set your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/naver
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, naverProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        naver: naverProvider(
          process.env.NAVER_CLIENT_ID!,
          process.env.NAVER_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
NAVER_CLIENT_ID=...
NAVER_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `email`, `profile`

| Scope | What it unlocks |
|-------|----------------|
| `name` | Display name |
| `email` | Email address |
| `profile_image` | Profile image URL |
| `mobile` | Mobile phone number |

<Warning>
  This is a preset built on the generic OIDC factory, which reads the user from `https://openapi.naver.com/v1/nid/me` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns an `id` field nested under `response`, so sign-in is expected to fail with `Naver userinfo response missing required "sub" field`. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/naver` | Redirect to Naver |
| GET | `/auth/oauth/callback/naver` | Handle callback |

---

# VK

Source: https://docs.theauth.dev/auth/vk

## Setup

<Steps>
<Step>
### Get credentials

Go to [dev.vk.com](https://dev.vk.com) and create an application. Set the platform to **Website** and add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/vk
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, vkProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        vk: vkProvider(
          process.env.VK_CLIENT_ID!,
          process.env.VK_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
VK_CLIENT_ID=...
VK_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `email`

| Scope | What it unlocks |
|-------|----------------|
| `email` | Email address |
| `profile` | Name and photo |
| `friends` | Friends list |

<Info>
  VK returns email as part of the access token response rather than a separate userinfo endpoint. theAuth handles this automatically.
</Info>

<Warning>
  This is a preset built on the generic OIDC factory, which reads the user from `https://id.vk.com/oauth2/user_info` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a `user.user_id` field nested under `user`, so sign-in is expected to fail with `VK userinfo response missing required "sub" field`. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/vk` | Redirect to VK |
| GET | `/auth/oauth/callback/vk` | Handle callback |

---

# LINE

Source: https://docs.theauth.dev/auth/line

## Setup

<Steps>
<Step>
### Get credentials

Go to the [LINE Developers Console](https://developers.line.biz) and create a provider and channel. Choose **LINE Login** as the channel type. Add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/line
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, lineProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        line: lineProvider(
          process.env.LINE_CLIENT_ID!,
          process.env.LINE_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
LINE_CLIENT_ID=...
LINE_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `openid`, `profile`, `email`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC identity token |
| `profile` | Display name and profile picture |
| `email` | Email address (requires email permission approval) |

<Info>
  The `email` scope requires an additional approval step in the LINE Developer Console. You must agree to the LINE Login email permission terms and submit for review.
</Info>

<Warning>
  This is a preset built on the generic OIDC factory, which reads the user from `https://api.line.me/v2/profile` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a `userId` field and no email, so sign-in is expected to fail with `LINE userinfo response missing required "sub" field`. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/line` | Redirect to LINE |
| GET | `/auth/oauth/callback/line` | Handle callback |

---

# WeChat

Source: https://docs.theauth.dev/auth/wechat

## Setup

<Steps>
<Step>
### Get credentials

Go to the [WeChat Open Platform](https://open.weixin.qq.com) and register a website app. Set the authorization callback domain (not a full URL, just the domain):

```
your-app.com
```

theAuth will handle the path `/api/theauth/auth/oauth/callback/wechat`.
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, wechatProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        wechat: wechatProvider(
          process.env.WECHAT_APP_ID!,
          process.env.WECHAT_APP_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
WECHAT_APP_ID=...
WECHAT_APP_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `snsapi_login`

| Scope | What it unlocks |
|-------|----------------|
| `snsapi_login` | Web login, returns `openid` and `unionid` |
| `snsapi_userinfo` | Full profile (name, avatar, gender) |

<Warning>
  WeChat uses `appid` and `secret` rather than the standard `client_id` / `client_secret` naming, but theAuth maps these correctly. The WeChat Open Platform requires ICP filing for mainland China deployments.
</Warning>

<Warning>
  This is a preset built on the generic OIDC factory, which reads the user from `https://api.weixin.qq.com/sns/userinfo` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns an `openid` field and no email, so sign-in is expected to fail with `WeChat userinfo response missing required "sub" field`. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/wechat` | Redirect to WeChat |
| GET | `/auth/oauth/callback/wechat` | Handle callback |

---

# Kick

Source: https://docs.theauth.dev/auth/kick

## Setup

<Steps>
<Step>
### Get credentials

Go to [kick.com](https://kick.com) and navigate to **Dashboard > Developer > Applications**. Create an app and set your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/kick
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createKickProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        kick: createKickProvider({
          clientId: process.env.KICK_CLIENT_ID!,
          clientSecret: process.env.KICK_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
KICK_CLIENT_ID=...
KICK_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `user:read`

| Scope | What it unlocks |
|-------|----------------|
| `user:read` | Read user profile and email |
| `channel:read` | Read channel information |
| `chat:read` | Read chat messages |

## How it works

- PKCE with S256 is required by Kick and is always sent. Client credentials go in the form body.
- The auth server is `id.kick.com`. The profile call goes to a different host, `GET https://api.kick.com/public/v1/users`, and returns `{ "data": [{ "user_id", "name", "email", "profile_picture" }] }`. `user_id` is a number and is stored as a string.
- User tokens come with a refresh token.
- Kick does not state whether the email is verified, so the OAuth plugin will not link a Kick login to an existing account by email.

<Info>
  `kickProvider` (the older preset, which reads `id.kick.com/oauth/userinfo`) is still exported for existing code. Use `createKickProvider` for new setups.
</Info>

Vendor docs: [Kick OAuth 2.1 flow](https://docs.kick.com/getting-started/generating-tokens-oauth2-flow) and [Users API](https://docs.kick.com/apis/users).

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/kick` | Redirect to Kick |
| GET | `/auth/oauth/callback/kick` | Handle callback |

---

# Cloudflare

Source: https://docs.theauth.dev/auth/cloudflare

## Setup

<Steps>
<Step>
### Get credentials

Create an OAuth client in the Cloudflare dashboard, add `user-details.read` as a required scope, and register the redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/cloudflare
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createCloudflareProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        cloudflare: createCloudflareProvider({
          clientId: process.env.CLOUDFLARE_CLIENT_ID!,
          clientSecret: process.env.CLOUDFLARE_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
CLOUDFLARE_CLIENT_ID=...
CLOUDFLARE_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `user-details.read`. It must also be a required scope on the OAuth client, or sign in cannot complete.

| Scope | What it unlocks |
|-------|----------------|
| `user-details.read` | Read the account email and name from the Cloudflare API |
| `offline_access` | Opt in through `scopes` to get a refresh token. The client needs the `refresh_token` grant |

Any other Cloudflare API scope can be added through `scopes`, and must be selected on the OAuth client too.

## How it works

- Authorization Code flow with PKCE (S256). The endpoints are `https://dash.cloudflare.com/oauth2/auth` and `https://dash.cloudflare.com/oauth2/token`.
- Client credentials go in an `Authorization: Basic` header by default. If the client is registered for `client_secret_post`, pass `tokenEndpointAuthMethod: 'client_secret_post'`.
- Cloudflare's OIDC userinfo endpoint returns only `sub`, so the profile is read from `GET https://api.cloudflare.com/client/v4/user`. The answer is wrapped in `{ success, errors, result }`. The name is `first_name` and `last_name` joined, with the email as the fallback. There is no avatar.
- `/user` does not say whether the email is verified, so the OAuth plugin will not link a Cloudflare login to an existing account by email.
- Clients without a secret (token auth method `none`) are not supported by this provider.

Vendor docs: [Integrate your OAuth client with Cloudflare](https://developers.cloudflare.com/fundamentals/oauth/integrate-with-cloudflare/).

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/cloudflare` | Redirect to Cloudflare |
| GET | `/auth/oauth/callback/cloudflare` | Handle callback |

---

# Paybin

Source: https://docs.theauth.dev/auth/paybin

## Setup

<Steps>
<Step>
### Get credentials

Register a client in the Paybin developer portal and set the redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/paybin
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createPaybinProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        paybin: createPaybinProvider({
          clientId: process.env.PAYBIN_CLIENT_ID!,
          clientSecret: process.env.PAYBIN_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
PAYBIN_CLIENT_ID=...
PAYBIN_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `openid`, `email`, `profile`.

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC sign in and the `sub` claim |
| `email` | `email` and `email_verified` |
| `profile` | name, picture, locale |
| `offline_access` | Opt in through `scopes` to get a refresh token |

Paybin also defines `merchant:*`, `transaction:*`, `wallet:*` and `kyc:*` scopes. Some need specific roles on the Paybin side. Pass them through `scopes`.

## How it works

- Authorization Code flow with PKCE (S256). Issuer `https://idp.paybin.io`, endpoints under `/connect/authorize`, `/connect/token` and `/connect/userinfo`.
- Client credentials go in the form body. Paybin also accepts them in a basic header.
- The profile comes from the userinfo endpoint. `email_verified` is passed through as `emailVerified`, so a verified Paybin email can link to an existing account.

Vendor docs: [Paybin OpenID Connect](https://developers.paybin.io/knowledge-center/oidc).

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/paybin` | Redirect to Paybin |
| GET | `/auth/oauth/callback/paybin` | Handle callback |

---

# Roblox

Source: https://docs.theauth.dev/auth/roblox

## Setup

<Steps>
<Step>
### Get credentials

Go to [create.roblox.com](https://create.roblox.com) and navigate to **Dashboard > Credentials**. Create an OAuth 2.0 app and set your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/roblox
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, robloxProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        roblox: robloxProvider(
          process.env.ROBLOX_CLIENT_ID!,
          process.env.ROBLOX_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
ROBLOX_CLIENT_ID=...
ROBLOX_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `openid`, `profile`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC identity token |
| `profile` | Display name and profile URL |
| `email` | Email address (if verified) |

<Info>
  Roblox OAuth 2.0 is currently in open beta. User IDs are stable numeric values that persist across username changes.
</Info>

<Warning>
  This is a preset built on the generic OIDC factory, which fails with `Roblox userinfo response missing "email"` when the response has no email. The default scopes (`openid`, `profile`) do not release an email address, so sign-in is expected to fail unless Roblox returns one. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/roblox` | Redirect to Roblox |
| GET | `/auth/oauth/callback/roblox` | Handle callback |

---

# Notion

Source: https://docs.theauth.dev/auth/notion

## Get credentials

<Steps>

<Step>

### Create an integration

Go to [Notion Integrations](https://www.notion.so/profile/integrations) and click **New integration**. Set the type to **Public**, this is required for OAuth with external users.

</Step>

<Step>

### Configure OAuth settings

In the integration settings, scroll to **OAuth Domain & URIs**. Add your redirect URI:

```
https://auth.example.com/auth/oauth/callback/notion
```

Set **Redirect URIs** and save.

</Step>

<Step>

### Copy credentials

Under **Basic Information**, copy the **OAuth client ID** and generate an **OAuth client secret**.

</Step>

</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createNotionProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://auth.example.com',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        notion: createNotionProvider({ // [!code highlight]
          clientId: process.env.NOTION_CLIENT_ID!, // [!code highlight]
          clientSecret: process.env.NOTION_CLIENT_SECRET!, // [!code highlight]
        }), // [!code highlight]
      },
    }),
  ],
});
```

```bash
NOTION_CLIENT_ID=your-notion-oauth-client-id
NOTION_CLIENT_SECRET=secret_...
```

## Endpoints

| Endpoint | URL |
|----------|-----|
| Authorization | `https://api.notion.com/v1/oauth/authorize` |
| Token | `https://api.notion.com/v1/oauth/token` |
| User info | Embedded in token response (`owner.user`) |

## Scopes

Notion does not use granular OAuth scopes, and the provider does not send a PKCE challenge. Permissions are configured at the integration level in the Notion UI. When a user authorizes your integration, they choose which pages and databases to share.

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | `owner.user.id` | Stable Notion user UUID |
| `email` | `owner.user.person.email` | Present only for person-type authorizations |
| `name` | `owner.user.name` | Full display name |
| `avatar` | `owner.user.avatar_url` | May be null if the user has no profile photo |

<Info>
  Notion returns user identity as part of the token exchange response, there is no separate `/me` endpoint. The provider keeps the last token payload in memory so no extra network call is made.
</Info>

<Warning>
  The `email` field is only present when a **person** authorizes the integration. If a workspace bot is the owner (`owner.type === "workspace"`), the email will be absent. Without an email, the `oauth()` plugin cannot match or create a user, so the callback returns JSON instead of a session.
</Warning>

## Workspace data

The token response also includes workspace context (`workspace_id`, `workspace_name`), available in `userInfo.raw` on the result of `createOAuthModule(...).handleCallback`. The `oauth()` plugin does not persist this data, so if you need the workspace per user, drive the flow with the module and store it yourself.

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="GitLab" href="/auth/gitlab" icon="gitlab">
    Another work and productivity OAuth provider.
  </Card>
  <Card title="Slack" href="/auth/slack" icon="slack">
    Sign in with Slack using OpenID Connect.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Figma

Source: https://docs.theauth.dev/auth/figma

## Setup

<Steps>
<Step>
### Get credentials

Go to [figma.com/developers](https://www.figma.com/developers) and click **Create a new app**. Set the redirect URI to:

```
https://your-app.com/api/theauth/auth/oauth/callback/figma
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createFigmaProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        figma: createFigmaProvider({
          clientId: process.env.FIGMA_CLIENT_ID!,
          clientSecret: process.env.FIGMA_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
FIGMA_CLIENT_ID=...
FIGMA_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `file_read`

| Scope | What it unlocks |
|-------|----------------|
| `file_read` | Read files, projects, and user info |

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/figma` | Redirect to Figma |
| GET | `/auth/oauth/callback/figma` | Handle callback |

---

# Dropbox

Source: https://docs.theauth.dev/auth/dropbox

## Setup

<Steps>
<Step>
### Get credentials

Go to the [Dropbox App Console](https://www.dropbox.com/developers/apps) and create an app. Under **OAuth 2**, add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/dropbox
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createDropboxProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        dropbox: createDropboxProvider({
          clientId: process.env.DROPBOX_CLIENT_ID!,
          clientSecret: process.env.DROPBOX_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
DROPBOX_CLIENT_ID=...
DROPBOX_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `account_info.read`

| Scope | What it unlocks |
|-------|----------------|
| `account_info.read` | Read the user's account info |
| `files.metadata.read` | List file metadata |
| `files.content.read` | Read file contents |

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/dropbox` | Redirect to Dropbox |
| GET | `/auth/oauth/callback/dropbox` | Handle callback |

---

# Zoom

Source: https://docs.theauth.dev/auth/zoom

## Setup

<Steps>
<Step>
### Get credentials

Go to the [Zoom App Marketplace](https://marketplace.zoom.us) and click **Develop > Build App**. Choose **OAuth** as the app type. Add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/zoom
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createZoomProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        zoom: createZoomProvider({
          clientId: process.env.ZOOM_CLIENT_ID!,
          clientSecret: process.env.ZOOM_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
ZOOM_CLIENT_ID=...
ZOOM_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scope: `user:read`. Scopes you pass are added on top of the default.

| Scope | What it unlocks |
|-------|----------------|
| `user:read` | Read the user's profile and email (default) |
| `meeting:read` | Read meeting info |

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/zoom` | Redirect to Zoom |
| GET | `/auth/oauth/callback/zoom` | Handle callback |

---

# Atlassian

Source: https://docs.theauth.dev/auth/atlassian

## Setup

<Steps>
<Step>
### Get credentials

Go to the [Atlassian Developer Console](https://developer.atlassian.com) and create an OAuth 2.0 app. Add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/atlassian
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createAtlassianProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        atlassian: createAtlassianProvider({
          clientId: process.env.ATLASSIAN_CLIENT_ID!,
          clientSecret: process.env.ATLASSIAN_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
ATLASSIAN_CLIENT_ID=...
ATLASSIAN_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scope: `read:me`. Scopes you pass are added on top of the default.

| Scope | What it unlocks |
|-------|----------------|
| `read:me` | Read the user's profile |
| `offline_access` | Refresh token support (not requested by default, add it to `scopes`) |
| `read:jira-user` | Read Jira user info |
| `read:confluence-user` | Read Confluence user info |

<Info>
  Atlassian uses a 3-legged OAuth (3LO) flow. Users grant access per product (Jira, Confluence, etc.). The accessible resources endpoint returns which sites the user has authorized.
</Info>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/atlassian` | Redirect to Atlassian |
| GET | `/auth/oauth/callback/atlassian` | Handle callback |

---

# Linear

Source: https://docs.theauth.dev/auth/linear

## Setup

<Steps>
<Step>
### Get credentials

Go to [linear.app/settings/api](https://linear.app/settings/api) and click **Create new OAuth application**. Add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/linear
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createLinearProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        linear: createLinearProvider({
          clientId: process.env.LINEAR_CLIENT_ID!,
          clientSecret: process.env.LINEAR_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
LINEAR_CLIENT_ID=...
LINEAR_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `read`

| Scope | What it unlocks |
|-------|----------------|
| `read` | Read access to the user's data |
| `write` | Write access to issues, comments, etc. |
| `issues:create` | Create issues |
| `app:assignIssues` | Assign issues to the app |

Scopes are sent comma separated, as Linear requires.

## How it works

- Linear has no userinfo endpoint. The profile comes from a GraphQL `viewer { id name email }` query sent as a POST to `https://api.linear.app/graphql`.
- PKCE (S256) is sent. Client credentials go in the form body.
- The code exchange returns a refresh token, and `getAccessToken` refreshes through it. Access tokens last 24 hours.
- Linear does not state whether the email is verified, so the OAuth plugin will not link a Linear login to an existing account by email.

<Info>
  `linearProvider` (the older preset, which cannot read Linear's GraphQL profile) is still exported for existing code. Use `createLinearProvider` for new setups.
</Info>

Vendor docs: [Linear OAuth 2.0 authentication](https://linear.app/developers/oauth-2-0-authentication) and [GraphQL API](https://linear.app/developers/graphql).

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/linear` | Redirect to Linear |
| GET | `/auth/oauth/callback/linear` | Handle callback |

---

# GitLab

Source: https://docs.theauth.dev/auth/gitlab

## Get credentials

<Steps>

<Step>

### Create an application

For **gitlab.com**: Go to [gitlab.com/-/profile/applications](https://gitlab.com/-/profile/applications).

For a **self-hosted instance**: Go to your instance URL, then **User Settings > Applications**.

- **Name**: your app name
- **Redirect URI**: `https://auth.example.com/auth/oauth/callback/gitlab`
- **Scopes**: check `read_user` (and `email` if you request it)

</Step>

<Step>

### Copy credentials

After saving, copy the **Application ID** and **Secret**.

</Step>

</Steps>

## Configuration

<Tabs>
  <Tab title="gitlab.com">
    ```typescript title="lib/theauth.ts"
    import { createTheAuth } from '@glinr/theauth';
    import { oauth, createGitlabProvider } from '@glinr/theauth/auth';

    const theauth = await createTheAuth({
      database: { provider: 'postgres', url: process.env.DATABASE_URL! },
      secret: process.env.THEAUTH_SECRET!,
      baseUrl: 'https://auth.example.com',
      auth: { session: { secret: process.env.THEAUTH_SECRET! } },
      plugins: [
        oauth({
          providers: {
            gitlab: createGitlabProvider({ // [!code highlight]
              clientId: process.env.GITLAB_CLIENT_ID!, // [!code highlight]
              clientSecret: process.env.GITLAB_CLIENT_SECRET!, // [!code highlight]
            }), // [!code highlight]
          },
        }),
      ],
    });
    ```
  </Tab>
  <Tab title="Self-hosted">
    `createGitlabProvider` is fixed to gitlab.com. For a self-hosted instance, use the generic OIDC factory with explicit endpoints (GitLab serves OpenID Connect, so the userinfo response carries the `sub` and `email` claims the factory requires):

    ```typescript title="lib/theauth.ts"
    import { oauth, genericOIDC } from '@glinr/theauth/auth';

    oauth({
      providers: {
        gitlab: genericOIDC({
          id: 'gitlab',
          name: 'GitLab',
          issuer: 'https://gitlab.yourcompany.com', // [!code highlight]
          clientId: process.env.GITLAB_CLIENT_ID!,
          clientSecret: process.env.GITLAB_CLIENT_SECRET!,
          scopes: ['openid', 'read_user', 'email'],
          authorizationUrl: 'https://gitlab.yourcompany.com/oauth/authorize', // [!code highlight]
          tokenUrl: 'https://gitlab.yourcompany.com/oauth/token', // [!code highlight]
          userinfoUrl: 'https://gitlab.yourcompany.com/oauth/userinfo', // [!code highlight]
        }),
      },
    })
    ```
  </Tab>
</Tabs>

```bash
GITLAB_CLIENT_ID=...
GITLAB_CLIENT_SECRET=...
```

## Scopes

Default scope: `read_user`. Scopes you pass are added on top of the default.

| Scope | What it unlocks |
|-------|----------------|
| `read_user` | Read the user's profile |
| `email` | Read the user's primary email (only if you add it to `scopes`) |
| `read_api` | Read access to the API |
| `read_repository` | Read repository data |

## User data returned

| Field | Source | Notes |
|-------|--------|-------|
| `id` | `id` field | Stable numeric GitLab user ID |
| `email` | `email` field | Primary email |
| `name` | `name` field | Display name |
| `avatar` | `avatar_url` field | Profile picture URL |

<Info>
  For self-hosted GitLab instances, make sure your theAuth server can reach the GitLab API. If you are behind a VPN or firewall, the token exchange and user info calls will fail if the instance is not reachable from your server.
</Info>

## Related

<CardGroup cols={2}>
  <Card title="OAuth overview" href="/auth/oauth" icon="lock">
    Generic OAuth 2.0 setup and account linking behavior.
  </Card>
  <Card title="GitHub" href="/auth/github" icon="github">
    Sign in with GitHub OAuth 2.0.
  </Card>
  <Card title="Notion" href="/auth/notion" icon="/brand-icons/notion.svg">
    Another work and productivity OAuth provider.
  </Card>
  <Card title="Authentication overview" href="/auth" icon="key">
    All sign-in methods available in theAuth.
  </Card>
</CardGroup>

---

# Bitbucket

Source: https://docs.theauth.dev/auth/bitbucket

## Get credentials

<Steps>
<Step>
### Create an OAuth consumer

Go to your Bitbucket workspace settings: **Workspace Settings > Apps and features > OAuth consumers > Add consumer**.

Set the **Callback URL** to:

```
https://your-app.com/api/theauth/auth/oauth/callback/bitbucket
```

Under **Permissions**, enable at minimum **Account: Read**.
</Step>

<Step>
### Copy your credentials

After saving, expand the consumer to see the **Key** (client ID) and **Secret** (client secret).
</Step>
</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, bitbucketProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        bitbucket: bitbucketProvider(
          process.env.BITBUCKET_CLIENT_ID!,
          process.env.BITBUCKET_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
BITBUCKET_CLIENT_ID=...
BITBUCKET_CLIENT_SECRET=...
```

## Scopes

Default scopes: `account`, `email`

| Scope | What it unlocks |
|-------|----------------|
| `account` | Read account info, email, profile |
| `email` | Read primary email address |
| `repository` | Read repository list |
| `team` | Read workspace/team memberships |

<Info>
  Bitbucket does not expose the user's email by default through the profile endpoint if it is set to private. The `email` scope fetches it from a separate endpoint. theAuth requests both automatically.
</Info>

<Warning>
  This is a preset built on the generic OIDC factory, which reads the user from `https://api.bitbucket.org/2.0/user` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns `uuid` and `account_id` fields and no email, so sign-in is expected to fail with `Bitbucket userinfo response missing required "sub" field`. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/bitbucket` | Redirect to Bitbucket |
| GET | `/auth/oauth/callback/bitbucket` | Handle callback |

---

# Salesforce

Source: https://docs.theauth.dev/auth/salesforce

## Setup

<Steps>
<Step>
### Get credentials

Go to [developer.salesforce.com](https://developer.salesforce.com) and set up a Connected App in **Setup > App Manager**. Under **OAuth Settings**, enable OAuth and add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/salesforce
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, salesforceProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        salesforce: salesforceProvider(
          process.env.SALESFORCE_CLIENT_ID!,
          process.env.SALESFORCE_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
SALESFORCE_CLIENT_ID=...
SALESFORCE_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `openid`, `email`, `profile`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC identity token |
| `id` | Identity URL and user info |
| `email` | Email address |
| `profile` | Display name and photo |
| `api` | Access Salesforce APIs |

<Info>
  Salesforce uses org-specific domains (e.g. `mycompany.my.salesforce.com`). The default authorization endpoint is `login.salesforce.com` but this can be customized for sandbox orgs using `test.salesforce.com`.
</Info>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/salesforce` | Redirect to Salesforce |
| GET | `/auth/oauth/callback/salesforce` | Handle callback |

---

# Vercel

Source: https://docs.theauth.dev/auth/vercel

## Setup

<Steps>
<Step>
### Get credentials

In your Vercel team, open **Settings**, then **Apps**, and click **Create**. Under **Authorization Callback URLs**, add your redirect URI, then generate a client secret and enable the `openid`, `email` and `profile` permissions:

```
https://your-app.com/api/theauth/auth/oauth/callback/vercel
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createVercelProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        vercel: createVercelProvider({
          clientId: process.env.VERCEL_CLIENT_ID!,
          clientSecret: process.env.VERCEL_CLIENT_SECRET!,
        }),
      },
    }),
  ],
});
```

```bash
VERCEL_CLIENT_ID=...
VERCEL_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `openid`, `email`, `profile`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC identity |
| `email` | Email address |
| `profile` | Name and profile picture |
| `offline_access` | A refresh token (needed for `getAccessToken` refreshes) |

Scopes the app does not have enabled in its dashboard settings are dropped by Vercel.

## How it works

- This uses the Sign in with Vercel authorization server: `https://vercel.com/oauth/authorize`, `https://api.vercel.com/login/oauth/token` and `https://api.vercel.com/login/oauth/userinfo`.
- PKCE with S256 is required and always sent. Client credentials go in the form body.
- The userinfo call is a POST with the access token as a Bearer header. It returns `sub`, `email`, `email_verified`, `name`, `preferred_username` and `picture`. The email counts as verified only when `email_verified` is true.
- Add `offline_access` to `scopes` to receive a refresh token.

<Info>
  `vercelProvider` (the older preset) targets Vercel's marketplace integration endpoints and cannot read the profile. It is still exported for existing code. Use `createVercelProvider` for new setups.
</Info>

Vendor docs: [Sign in with Vercel authorization server API](https://vercel.com/docs/sign-in-with-vercel/authorization-server-api).

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/vercel` | Redirect to Vercel |
| GET | `/auth/oauth/callback/vercel` | Handle callback |

---

# Railway

Source: https://docs.theauth.dev/auth/railway

## Setup

<Steps>
<Step>
### Get credentials

Go to [railway.com](https://railway.com) and navigate to **Account Settings > Developer > OAuth Apps**. Create an app and add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/railway
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, railwayProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        railway: railwayProvider(
          process.env.RAILWAY_CLIENT_ID!,
          process.env.RAILWAY_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
RAILWAY_CLIENT_ID=...
RAILWAY_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `read:user`, `read:project`

| Scope | What it unlocks |
|-------|----------------|
| `read:user` | Read user profile |
| `read:project` | Read project list |

<Warning>
  This is a preset built on the generic OIDC factory, which reads the user from `https://backboard.railway.com/graphql/v2` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a GraphQL `data` object, so sign-in is expected to fail with `Railway userinfo response missing required "sub" field`. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/railway` | Redirect to Railway |
| GET | `/auth/oauth/callback/railway` | Handle callback |

---

# Hugging Face

Source: https://docs.theauth.dev/auth/huggingface

## Setup

<Steps>
<Step>
### Get credentials

Go to [huggingface.co/settings/applications](https://huggingface.co/settings/applications) and click **New OAuth application**. Set the redirect URI to:

```
https://your-app.com/api/theauth/auth/oauth/callback/huggingface
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, huggingfaceProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        huggingface: huggingfaceProvider(
          process.env.HUGGINGFACE_CLIENT_ID!,
          process.env.HUGGINGFACE_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
HUGGINGFACE_CLIENT_ID=...
HUGGINGFACE_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `openid`, `profile`, `email`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC identity token |
| `profile` | Username and full name |
| `email` | Email address |
| `read-repos` | Read access to user repositories |
| `read-billing` | Read billing information |

<Info>
  Hugging Face supports the OIDC discovery endpoint at `https://huggingface.co`. User IDs are stable numeric values.
</Info>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/huggingface` | Redirect to Hugging Face |
| GET | `/auth/oauth/callback/huggingface` | Handle callback |

---

# PayPal

Source: https://docs.theauth.dev/auth/paypal

## Setup

<Steps>
<Step>
### Get credentials

Go to the [PayPal Developer Portal](https://developer.paypal.com) and create an app under **My Apps & Credentials**. Add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/paypal
```

Make sure to enable **Log In with PayPal** for the app.
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, createPaypalProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        paypal: createPaypalProvider({
          clientId: process.env.PAYPAL_CLIENT_ID!,
          clientSecret: process.env.PAYPAL_CLIENT_SECRET!,
          // sandbox: true,  // use the sandbox hosts with sandbox credentials
        }),
      },
    }),
  ],
});
```

```bash
PAYPAL_CLIENT_ID=...
PAYPAL_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `openid`, `email`, `profile`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC identity token |
| `email` | Email address |
| `profile` | Name and locale |
| `address` | Billing address |

## How it works

- Set `sandbox: true` to use `www.sandbox.paypal.com` and `api-m.sandbox.paypal.com` with sandbox credentials. The default is live.
- The login redirect goes to `https://www.paypal.com/connect` with `flowEntry=static`.
- Client credentials are sent in an `Authorization: Basic` header at the token endpoint, for the code exchange and for refresh.
- PayPal's guides do not describe PKCE, so no code challenge is sent.
- Token responses include a refresh token and `expires_in` of 28800 (8 hours).
- The profile is read from `/v1/identity/openidconnect/userinfo?schema=openid`. The id is `user_id` (falling back to `sub`) and the email is `email` (falling back to the primary entry of `emails`).
- PayPal emails are never treated as verified, so a PayPal login is not linked to an existing account by email.

<Warning>
  PayPal's public guides list the userinfo field names but not the exact response for each `schema` value. Check a live sandbox response once before you rely on the `emails` fallback.
</Warning>

<Info>
  `paypalProvider` (the older preset) uses a different authorize URL and reads only the OIDC `sub` claim. It is still exported for existing code. Use `createPaypalProvider` for new setups.
</Info>

Vendor docs: [Log In with PayPal](https://developer.paypal.com/docs/log-in-with-paypal/integrate/) and the [Identity API](https://developer.paypal.com/docs/api/identity/v1).

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/paypal` | Redirect to PayPal |
| GET | `/auth/oauth/callback/paypal` | Handle callback |

---

# Coinbase

Source: https://docs.theauth.dev/auth/coinbase

## Get credentials

<Steps>
<Step>
### Create an OAuth application

Go to [Coinbase Developer Platform](https://www.coinbase.com/settings/api) and create a new OAuth2 application.

Set the **Redirect URI** to:

```
https://your-app.com/api/theauth/auth/oauth/callback/coinbase
```
</Step>

<Step>
### Copy your credentials

After creating the app, copy the **Client ID** and **Client Secret** from the application settings.
</Step>
</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, coinbaseProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        coinbase: coinbaseProvider(
          process.env.COINBASE_CLIENT_ID!,
          process.env.COINBASE_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
COINBASE_CLIENT_ID=...
COINBASE_CLIENT_SECRET=...
```

## Scopes

Default scope: `wallet:user:read`, `wallet:user:email`

| Scope | What it unlocks |
|-------|----------------|
| `wallet:user:read` | Read user profile and account info |
| `wallet:user:email` | Read user's email address |
| `wallet:accounts:read` | Read wallet account balances |

<Warning>
  Coinbase scopes use a namespaced format (`wallet:resource:action`). Request only the scopes your app needs. Users see the full permission list during authorization.
</Warning>

<Warning>
  This is a preset built on the generic OIDC factory, which reads the user from `https://api.coinbase.com/v2/user` and requires the response to contain the OIDC `sub` and `email` fields. That endpoint returns a `data.id` field nested under `data`, so sign-in is expected to fail with `Coinbase userinfo response missing required "sub" field`. This preset has not been verified against the live service.
</Warning>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/coinbase` | Redirect to Coinbase |
| GET | `/auth/oauth/callback/coinbase` | Handle callback |

---

# Polar

Source: https://docs.theauth.dev/auth/polar

## Setup

<Steps>
<Step>
### Get credentials

Go to [polar.sh](https://polar.sh) and navigate to **Settings > OAuth Apps**. Create an app and set your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/polar
```
</Step>

<Step>
### Configure

```ts title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, polarProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        polar: polarProvider(
          process.env.POLAR_CLIENT_ID!,
          process.env.POLAR_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
POLAR_CLIENT_ID=...
POLAR_CLIENT_SECRET=...
```
</Step>
</Steps>

## Scopes

Default scopes: `openid`, `profile`, `email`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC identity token |
| `profile` | Username and avatar |
| `email` | Email address |
| `organizations:read` | Read organization memberships |

<Info>
  Polar is an open source monetization platform for developers. The OAuth integration is useful for gating content based on subscriptions or purchases.
</Info>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/polar` | Redirect to Polar |
| GET | `/auth/oauth/callback/polar` | Handle callback |

---

# Auth0

Source: https://docs.theauth.dev/auth/auth0

## Get credentials

<Steps>
<Step>
### Create an application

Go to the [Auth0 dashboard](https://manage.auth0.com) and create a **Regular Web Application**.

Set the **Allowed Callback URL** to:

```
https://your-app.com/api/theauth/auth/oauth/callback/auth0
```
</Step>

<Step>
### Copy your credentials

From the application settings, copy the **Domain**, **Client ID**, and **Client Secret**.

Your domain looks like `your-tenant.auth0.com`.
</Step>
</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, auth0Provider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        auth0: auth0Provider(
          process.env.AUTH0_DOMAIN!,       // your-tenant.auth0.com
          process.env.AUTH0_CLIENT_ID!,
          process.env.AUTH0_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
AUTH0_DOMAIN=your-tenant.auth0.com
AUTH0_CLIENT_ID=...
AUTH0_CLIENT_SECRET=...
```

## Scopes

Default scopes: `openid`, `profile`, `email`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC authentication, issues ID token |
| `profile` | Name, picture, and profile metadata |
| `email` | Email address and verification status |
| `offline_access` | Refresh token support |

<Info>
  Auth0 supports custom scopes and roles via the Management API. Standard OIDC scopes work out of the box.
</Info>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/auth0` | Redirect to Auth0 |
| GET | `/auth/oauth/callback/auth0` | Handle callback |

---

# Okta

Source: https://docs.theauth.dev/auth/okta

## Get credentials

<Steps>
<Step>
### Create an OIDC app

In the Okta Admin Console, go to **Applications > Create App Integration** and choose **OIDC - OpenID Connect** with application type **Web Application**.

Set the **Sign-in redirect URI** to:

```
https://your-app.com/api/theauth/auth/oauth/callback/okta
```
</Step>

<Step>
### Copy your credentials

From the app settings, copy the **Client ID** and **Client Secret**. Your domain is shown at the top of the console: `your-org.okta.com`.
</Step>
</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, oktaProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        okta: oktaProvider(
          process.env.OKTA_DOMAIN!,        // your-org.okta.com
          process.env.OKTA_CLIENT_ID!,
          process.env.OKTA_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
OKTA_DOMAIN=your-org.okta.com
OKTA_CLIENT_ID=...
OKTA_CLIENT_SECRET=...
```

## Scopes

Default scopes: `openid`, `profile`, `email`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC authentication, issues ID token |
| `profile` | Name, locale, and profile metadata |
| `email` | Email address and verification status |
| `groups` | Group membership (requires group claim in Okta) |
| `offline_access` | Refresh token support |

<Info>
  For Okta Identity Engine orgs, the domain may be a custom domain. Use the exact domain shown in your Okta Admin Console rather than the default `okta.com` subdomain.
</Info>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/okta` | Redirect to Okta |
| GET | `/auth/oauth/callback/okta` | Handle callback |

---

# Yahoo

Source: https://docs.theauth.dev/auth/yahoo

## Get credentials

<Steps>
<Step>
### Create an app

Go to the [Yahoo Developer Console](https://developer.yahoo.com/apps/) and create a new app. Select **Web Application** as the application type.

Set the **Callback Domain** and add your redirect URI:

```
https://your-app.com/api/theauth/auth/oauth/callback/yahoo
```

Enable the **OpenID Connect** API and the **Email** scope.
</Step>

<Step>
### Copy your credentials

After creating the app, copy the **Client ID (Consumer Key)** and **Client Secret (Consumer Secret)**.
</Step>
</Steps>

## Configuration

```typescript title="lib/theauth.ts"
import { createTheAuth } from '@glinr/theauth';
import { oauth, yahooProvider } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.THEAUTH_SECRET!,
  baseUrl: 'https://your-app.com/api/theauth',
  auth: { session: { secret: process.env.THEAUTH_SECRET! } },
  plugins: [
    oauth({
      providers: {
        yahoo: yahooProvider(
          process.env.YAHOO_CLIENT_ID!,
          process.env.YAHOO_CLIENT_SECRET!,
        ),
      },
    }),
  ],
});
```

```bash
YAHOO_CLIENT_ID=...
YAHOO_CLIENT_SECRET=...
```

## Scopes

Default scopes: `openid`, `profile`, `email`

| Scope | What it unlocks |
|-------|----------------|
| `openid` | OIDC authentication |
| `profile` | Display name and profile details |
| `email` | Yahoo email address |

<Info>
  Yahoo requires the OpenID Connect API to be explicitly enabled in your app's API permissions. Without it, the token endpoint will return an error even if `openid` is in the scope.
</Info>

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/oauth/authorize/yahoo` | Redirect to Yahoo |
| GET | `/auth/oauth/callback/yahoo` | Handle callback |

---

# More OAuth providers

Source: https://docs.theauth.dev/auth/more-providers

These presets are built on `genericOIDC`. Register each one in `oauth({ providers })` and use the callback URL `https://your-app.com/api/theauth/auth/oauth/callback/<id>`.

```typescript title="lib/theauth.ts"
import {
  oauth,
  keycloakProvider,
  patreonProvider,
} from '@glinr/theauth/auth';

oauth({
  providers: {
    keycloak: keycloakProvider(
      'https://sso.example.com', 'main',
      process.env.KEYCLOAK_CLIENT_ID!, process.env.KEYCLOAK_CLIENT_SECRET!,
    ),
    patreon: patreonProvider(process.env.PATREON_CLIENT_ID!, process.env.PATREON_CLIENT_SECRET!),
  },
});
```

| Preset | Arguments before `clientId, clientSecret` | Default scopes | Endpoints |
|--------|-------------------------------------------|----------------|-----------|
| `keycloakProvider` | `baseUrl, realm` | openid email profile | Discovery |
| `authentikProvider` | `baseUrl, slug` | openid email profile | Discovery |
| `zitadelProvider` | `domain` | openid email profile | Discovery |
| `oneloginProvider` | `subdomain` | openid email profile | Discovery |
| `giteaProvider` | `baseUrl` | openid email profile | Explicit (`/login/oauth/*`) |
| `patreonProvider` | none | identity, identity[email] | Explicit, API v2 identity |
| `boxProvider` | none | root_readonly | Explicit |
| `yandexProvider` | none | login:email, login:info | Explicit, `OAuth` userinfo scheme |
| `wordpressProvider` | none | auth | Explicit |

Discovery presets fetch `/.well-known/openid-configuration` on first use and cache it. The last four map non-OIDC userinfo responses.

## Writing your own

Two options on `genericOIDC` make most non-standard providers a few lines:

```typescript
import { genericOIDC } from '@glinr/theauth/auth';

const acme = genericOIDC({
  id: 'acme', name: 'Acme', issuer: 'https://acme.example',
  clientId, clientSecret,
  authorizationUrl: 'https://acme.example/oauth/authorize',
  tokenUrl: 'https://acme.example/oauth/token',
  userinfoUrl: 'https://acme.example/api/me',
  userinfoAuthScheme: 'Bearer', // some providers want "OAuth" or "Token"
  mapProfile: (raw) => ({ id: String(raw.uid), email: String(raw.mail), name: String(raw.display) }),
});
```

`mapProfile` returns `null` for an unusable response, which surfaces as an error instead of a half-built user. It may also return `emailVerified: true` when the provider states the address is verified; without it, logins are not linked to existing accounts by email.

Other options cover common quirks: `scopeSeparator` (for example `","`), `defaultScopes` (replaces the `openid email profile` baseline), `authorizationParams` (extra authorize query parameters), `tokenAuth: 'basic'` (credentials in a Basic header, also on refresh), `pkce: false` (no code challenge), and `userinfoMethod: 'POST'` with `userinfoBody` (a JSON body, for GraphQL profile endpoints). Providers that omit the email address (Strava, Pinterest) are not supported by the generic factory, because TheAuth links accounts by email.

Already shipped and not repeated here: Facebook, LinkedIn, Kakao, Naver, LINE, VK, TikTok, PayPal, Hugging Face, Salesforce, Cognito, Okta, Auth0, Zoom, Roblox, Dropbox, Kick, Vercel, Railway, Polar and Coinbase.

---

# Organizations

Source: https://docs.theauth.dev/auth/organizations

## Setup

There are two ways to use organizations, and the `theauth.org` object used in the examples below only exists with the first one.

**Programmatic API (`theauth.org`).** Pass an `org` config object to `createTheAuth`. Without it, `theauth.org` is `null` and every `theauth.org.*` call below fails.

```ts
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  org: { // [!code highlight]
    maxMembers: 100, // [!code highlight]
    maxOrgsPerUser: 5, // [!code highlight]
    allowCustomRoles: true, // [!code highlight]
  }, // [!code highlight]
});

// theauth.org is typed as nullable
const org = await theauth.org?.create({ name: 'Acme', slug: 'acme', ownerId: 'user_123' });
```

**HTTP endpoints (`organization` plugin).** The `organization()` plugin registers `/auth/org/*` routes for an adapter to serve. It does not add `theauth.org` to the instance.

```ts
import { organization } from '@glinr/theauth/auth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  auth: { session: { secret: process.env.SESSION_SECRET! } },
  plugins: [organization({ maxMembers: 100, maxOrgsPerUser: 5, allowCustomRoles: true })],
});
```

The plugin routes act on the authenticated user, resolved from `auth.adapter` or an `auth.session` token.

The code samples below call `theauth.org!.<method>(...)`. They assume the `org` config was passed. Use `?.` instead of `!` if you want to tolerate it being absent.

## Creating organizations

```ts
const org = await theauth.org!.create({
  name: 'Acme Corp',
  slug: 'acme-corp',   // lowercase letters, numbers, hyphens only (no leading, trailing or doubled hyphens)
  ownerId: 'user_abc',
  metadata: { plan: 'pro' },
});
// org.id = 'org_...'
```

The creator is automatically added as a member with the `owner` role.

## Inviting members

```ts
const invitation = await theauth.org!.invite({
  orgId: org.id,
  email: 'alice@acme.com',
  role: 'admin',
  invitedBy: 'user_abc',
});
// invitation.id, invitation.expiresAt (7 days by default, set invitationExpiryMs to change)
```

Accept on the invited user's side:

```ts
const member = await theauth.org!.acceptInvitation(invitation.id, 'user_xyz');
```

## Managing members

```ts
// List all members
const members = await theauth.org!.getMembers(org.id);

// Change a member's role
await theauth.org!.updateMemberRole(org.id, 'user_xyz', 'member');

// Remove a member
await theauth.org!.removeMember(org.id, 'user_xyz');
```

## Roles and permissions

Four built-in roles ship by default:

| Role | Permissions |
|------|-------------|
| `owner` | All permissions including `org:manage`, `org:delete`, `roles:manage` |
| `admin` | `members:invite`, `members:remove`, `agents:create`, `agents:revoke`, `agents:manage` |
| `member` | `agents:create`, `agents:manage` |
| `viewer` | None |

Check permissions at runtime:

```ts
const allowed = await theauth.org!.hasPermission(org.id, userId, 'agents:create'); // [!code highlight]
```

### Custom roles

```ts
await theauth.org!.createRole(org.id, {
  name: 'billing',
  permissions: ['invoices:read', 'invoices:pay'],
});
```

<Note>
  Set `allowCustomRoles: false` in the `org` config (or the plugin options) to restrict orgs to the built-in roles only.
</Note>

## Configuration reference

<ParamField path="defaultRoles" type="OrgRole[]">Roles created for new organizations. Defaults to the four built-in roles.</ParamField>
<ParamField path="maxMembers" type="number" default={100}>Maximum members per organization.</ParamField>
<ParamField path="maxOrgsPerUser" type="number" default={5}>Maximum organizations a user can create.</ParamField>
<ParamField path="allowCustomRoles" type="boolean" default={true}>Whether `createRole` is allowed.</ParamField>
<ParamField path="invitationExpiryMs" type="number" default={604800000}>Invitation lifetime in milliseconds (7 days).</ParamField>
<ParamField path="maxInvitationsPerHour" type="number" default={50}>Maximum invitations per organization per hour.</ParamField>

## Endpoints

### `organization()` plugin

All plugin routes require an authenticated user.

| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/org/create` | Create an organization, body `{ name, slug, metadata? }`. The caller becomes owner |
| GET | `/auth/org/list` | List the caller's organizations as `{ organizations }` |
| POST | `/auth/org/:id/invite` | Send an invitation, body `{ email, role? }` (default role `member`). Owner or admin only |
| POST | `/auth/org/:id/members` | List members as `{ members }`. Caller must be a member (this route lists, it does not add) |
| PATCH | `/auth/org/:id/members/:userId` | Update a member's role, body `{ role }`. Owner or admin only |
| DELETE | `/auth/org/:id/members/:userId` | Remove a member. Owner or admin only |

### `theauth.org.handleRequest` (module)

The module's own handler serves a larger set of paths. It does no authentication, so only mount it behind your own checks.

| Method | Path | Description |
|--------|------|-------------|
| POST | `/auth/org` | Create organization |
| GET | `/auth/org/user/:userId` | List orgs for user |
| GET | `/auth/org/:orgId` | Get organization |
| PATCH | `/auth/org/:orgId` | Update organization |
| DELETE | `/auth/org/:orgId` | Delete organization |
| GET | `/auth/org/:orgId/members` | List members |
| POST | `/auth/org/:orgId/members` | Add member |
| PATCH | `/auth/org/:orgId/members/:userId` | Update member role |
| DELETE | `/auth/org/:orgId/members/:userId` | Remove member |
| POST | `/auth/org/:orgId/invite` | Send invitation |
| GET | `/auth/org/:orgId/invitations` | List invitations |
| POST | `/auth/org/invite/:invitationId/accept` | Accept invitation |
| DELETE | `/auth/org/invite/:invitationId` | Revoke invitation |
| GET | `/auth/org/:orgId/roles` | List roles |
| POST | `/auth/org/:orgId/roles` | Create role |
| GET | `/auth/org/:orgId/permissions/:userId/:permission` | Check permission |

## Related

<CardGroup cols={2}>
  <Card title="SSO" href="/auth/sso" icon="shield-halved">
    Enterprise SSO connections scoped to an organization.
  </Card>
  <Card title="SCIM" href="/auth/scim" icon="database">
    Automated user and group sync from your identity provider.
  </Card>
  <Card title="Admin" href="/auth/admin" icon="gear">
    Cross-organization user management tools.
  </Card>
  <Card title="Permissions" href="/permissions" icon="lock">
    Scoped permissions that integrate with org membership.
  </Card>
</CardGroup>

---

# SSO

Source: https://docs.theauth.dev/auth/sso

## Setup

Configure your identity providers when creating the theAuth instance. `theauth.sso` is `null` unless the `sso` key is passed, so use `theauth.sso!.` (or `?.`) in your code. There is no SSO plugin: routes are served through `theauth.sso.handleRequest`.

<Tabs>
<Tab title="SAML 2.0">
```ts
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  sso: { // [!code highlight]
    saml: [ // [!code highlight]
      {
        id: 'okta', // [!code highlight]
        name: 'Okta', // [!code highlight]
        entryPoint: 'https://your-org.okta.com/app/appId/sso/saml', // [!code highlight]
        issuer: 'https://your-app.com', // your SP entity id // [!code highlight]
        idpEntityId: 'http://www.okta.com/exk123abc', // the IdP Issuer, from its metadata // [!code highlight]
        cert: process.env.OKTA_SAML_CERT!, // X.509 certificate PEM, bare base64, or public key PEM // [!code highlight]
        callbackUrl: 'https://your-app.com/api/theauth/auth/sso/saml/conn_id/acs', // [!code highlight]
      },
    ],
  }, // [!code highlight]
});
```
</Tab>
<Tab title="OIDC">
```ts
import { createTheAuth } from '@glinr/theauth';

const theauth = await createTheAuth({
  database: { provider: 'sqlite-native', url: 'theauth.db' },
  sso: { // [!code highlight]
    oidc: [ // [!code highlight]
      {
        id: 'azure', // [!code highlight]
        name: 'Azure AD', // [!code highlight]
        issuer: 'https://login.microsoftonline.com/your-tenant-id/v2.0', // [!code highlight]
        clientId: process.env.AZURE_CLIENT_ID!, // [!code highlight]
        clientSecret: process.env.AZURE_CLIENT_SECRET!, // [!code highlight]
        callbackUrl: 'https://your-app.com/api/theauth/auth/sso/oidc/conn_id/callback', // [!code highlight]
        scopes: ['openid', 'profile', 'email'], // [!code highlight]
      },
    ],
  }, // [!code highlight]
});
```
</Tab>
</Tabs>

## SSO connections

Connections link an org to an identity provider and route by email domain.

```ts
// Create a connection for an org
const connection = await theauth.sso!.createConnection({ // [!code highlight]
  orgId: 'org_acme',
  providerId: 'okta',    // matches the id in your sso config // [!code highlight]
  type: 'saml',          // 'saml' | 'oidc' // [!code highlight]
  domain: 'acme.com',    // emails at this domain are routed here // [!code highlight]
});

// Look up a connection by domain (e.g., to redirect on login)
const conn = await theauth.sso!.getConnectionByDomain('acme.com');

// List all connections for an org
const conns = await theauth.sso!.listConnections('org_acme');

// Remove a connection
await theauth.sso!.removeConnection(connection.id);
```

## Identity mapping

After a successful SAML or OIDC login, theAuth returns the identity from the IdP response: `{ user: { id, email, name? }, orgId }`. Email and name come from the SAML assertion or the OIDC `email` and `name` claims. An OIDC `id_token` without an `email` claim is rejected.

The `user.id` is derived deterministically, so the same IdP user always maps to the same ID: `saml_` plus a SHA-256 hash of `providerId:email` for SAML, and `oidc_` plus a SHA-256 hash of `providerId:sub` for OIDC.

<Warning>
  The SSO module does not create a user row, a membership, or a session. Your callback code receives the verified identity and decides what to do with it: create or look up your user, add them to the org, and issue a session. Do not treat the derived ID as an existing row in the users table.
</Warning>

## SAML flow

```ts
// 1. Redirect the user to the IdP. RelayState carries a single-use token
//    bound to the AuthnRequest id; '/dashboard' stays on the server.
const authUrl = await theauth.sso!.getSamlAuthUrl(connection.id, '/dashboard');
// redirect to authUrl

// 2. Handle the ACS callback (POST from IdP). Pass the posted RelayState so
//    InResponseTo is checked against the stored request id.
const { user, orgId, relayState, sessionIndex } = await theauth.sso!.handleSamlResponse(
  connection.id,
  form.get('SAMLResponse'),
  { relayState: form.get('RelayState') },
);
// relayState === '/dashboard'
```

The built-in ACS route (`POST /auth/sso/saml/:connectionId/acs`) reads `RelayState` and does this for you. If you track AuthnRequest ids yourself, pass `{ expectedRequestId }` instead (a plain string as the third argument still works).

Pending request ids and the assertion replay cache live in `secondaryStorage` (feature `nonces`). The default is process memory, which only works on a single instance. Behind a load balancer, configure a shared store such as Redis, Cloudflare KV or `'database'`.

## SAML security

### Provider options

| Option | Default | Meaning |
|--------|---------|---------|
| `idpEntityId` | required | IdP entity id. The Assertion Issuer, and the Response Issuer when present, must equal it. Logins fail with `SAML_CONFIG_ERROR` until it is set. |
| `cert` | required | IdP signing credential: X.509 certificate PEM, bare base64 DER (as in metadata), or `PUBLIC KEY` PEM. RSA only, at least 2048 bits. `KeyInfo` in responses is never used. |
| `issuer` / `entityId` | | Your SP entity id. `entityId` wins when set. Checked against `Audience`. |
| `callbackUrl` | required | Your ACS URL. Checked against `Destination` and `SubjectConfirmationData Recipient`. |
| `clockSkewSeconds` | `120` | Tolerance for `NotBefore` and `NotOnOrAfter`. Must be between 0 and 300. |
| `allowLegacySha1` | `false` | Accept RSA-SHA1 signatures and SHA-1 digests. Only for IdPs that cannot sign with SHA-256. |
| `allowIdpInitiated` | `false` | Accept unsolicited responses with no `InResponseTo`. These cannot be bound to a browser session, so leave this off unless you need IdP-initiated login. |
| `xmlLimits` | see below | Parser limits: `maxBytes` (262144), `maxDepth` (32), `maxAttributes` (32), `maxElements` (4096). |

`wantAuthnResponseSigned` is deprecated. Setting it to `false` is a config error: signature verification cannot be turned off.

The `sso` config also takes `storage` (a `SecondaryStorage`, overrides the `nonces` store) and `samlRequestTtlSeconds` (default 600).

### What is ver

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.