# Auth Plugins

Quickback ships with a curated set of Better Auth plugins and helper wiring so your auth stack is production-ready by default. Enable these in `quickback.config.ts` under `providers.auth`.

## Available Plugins

### Email OTP (with AWS SES)

Quickback wires the Better Auth Email OTP flow to AWS SES. When `emailOtp` is enabled, the compiler emits the SES plugin and combo-auth email flow (magic link + OTP).

**Enable in config:**

```ts
import { defineAuth, defineConfig, defineDatabase, defineRuntime, defineStorage } from "@quickback/compiler";

export default defineConfig({
  name: "quickback-api",
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("cloudflare-d1", {
      vars: {
        AWS_SES_REGION: "us-east-2",
        EMAIL_FROM: "noreply@yourdomain.com",
        EMAIL_FROM_NAME: "Your App | Account Services",
        APP_NAME: "Your App",
        APP_URL: "https://account.yourdomain.com",
        BETTER_AUTH_URL: "https://api.yourdomain.com",
      },
    }),
    auth: defineAuth("better-auth", {
      emailAndPassword: { enabled: true },
      plugins: ["emailOtp"],
    }),
  },
});
```

**Endpoints:**
- `POST /auth/v1/email-otp/send-verification-otp`
- `POST /auth/v1/email-otp/verify-otp`

**Email readiness check:**

Use this to show UI warnings when SES isn't configured.

- `GET /api/v1/system/email-status`
- Response: `{ "emailConfigured": true|false }`

### Upgrade Anonymous

Adds a first-class endpoint to convert an anonymous user into a full user. This flips `isAnonymous` to `false` and refreshes the session cache immediately.

**Enable in config:**

```ts
auth: defineAuth("better-auth", {
  emailAndPassword: { enabled: true },
  plugins: ["anonymous", "upgradeAnonymous"],
}),
```

**Endpoint:**
- `POST /auth/v1/upgrade-anonymous`

**Note:** If you send `Content-Type: application/json`, include a body (an empty `{}` is fine). This avoids request parsing errors in some clients.

### SMS OTP (with AWS SNS)

When `phoneNumber` is enabled, the compiler wires Better Auth's built-in `phoneNumber` plugin to AWS SNS for SMS delivery via [`@quickback-dev/better-auth-aws-sns`](/stack/auth/plugins/aws-sns). The plugin adds `phoneNumber` / `phoneNumberVerified` columns to the user table, handles OTP generation + storage + verify-attempt limits, and exposes endpoints for phone-only sign-in, phone+password sign-in, and password reset via SMS.

**Enable in config:**

```ts
export default defineConfig({
  name: "my-app",
  sms: {
    provider: "aws-sns",
    region: "us-east-1",
  },
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("cloudflare-d1"),
    auth: defineAuth("better-auth", {
      plugins: { phoneNumber: true },
    }),
  },
});
```

**Endpoints (managed by Better Auth):**
- `POST /auth/v1/phone-number/send-otp`
- `POST /auth/v1/phone-number/verify`
- `POST /auth/v1/sign-in/phone-number`
- `POST /auth/v1/phone-number/request-password-reset`
- `POST /auth/v1/phone-number/reset-password`

**Why use SMS:** corporate email gateways frequently break transactional auth emails (link rewriting, click-tracking, MITM scanners). SMS is the most reliable channel for a six-digit code that has to land in under a minute. See the [AWS SNS plugin page](/stack/auth/plugins/aws-sns) for IAM, 10DLC registration, and sender-ID details.

### AWS SES Plugin

This is a Quickback-provided Better Auth plugin for Email OTP and other transactional emails. It handles SES signing and delivery over the Web Crypto API so it runs in Cloudflare Workers without the AWS SDK. It is wired up only when you opt in via `email.provider: 'aws-ses'` — the default transport is Cloudflare Email Sending (`send_email` binding).

Required vars (only when `email.provider: 'aws-ses'` is set; use Wrangler secrets for credentials):
- `AWS_ACCESS_KEY_ID` (secret)
- `AWS_SECRET_ACCESS_KEY` (secret)
- `AWS_SES_REGION`
- `EMAIL_FROM`

Optional vars:
- `EMAIL_FROM_NAME`
- `EMAIL_REPLY_TO` - Reply-to address for emails (defaults to `EMAIL_FROM`)
- `APP_NAME`
- `APP_URL`
- `BETTER_AUTH_URL`

### Magic Links

Magic links provide passwordless authentication by sending a unique login link to the user's email. When clicked, the link authenticates the user without requiring a password.

**Enable in config:**

```ts
auth: defineAuth("better-auth", {
  plugins: ["magicLink"],
}),
```

**Endpoints:**
- `POST /auth/v1/magic-link/send` - Send magic link email
- `GET /auth/v1/magic-link/verify` - Verify magic link token

**Email customization:**

Magic link emails use the same AWS SES configuration as Email OTP. Customize the email template via environment variables:

- `APP_NAME` - Application name in email subject
- `APP_URL` - Base URL for magic link redirect
- `EMAIL_FROM` - Sender email address
- `EMAIL_FROM_NAME` - Sender display name

**Frontend integration:**

```ts
// Request magic link
await fetch('/auth/v1/magic-link/send', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'user@yourdomain.com' })
});

// User clicks link in email, which redirects to your app
// The token is verified automatically via the callback URL
```

### Passkeys

Passkeys provide passwordless authentication using WebAuthn/FIDO2. Users authenticate with biometrics (fingerprint, face) or device PIN instead of passwords.

**Enable in config:**

```ts
auth: defineAuth("better-auth", {
  plugins: ["passkey"],
}),
```

**Required environment variable:**
- `ACCOUNT_URL` - Your account/frontend URL (used as the relying party origin for WebAuthn)

**Endpoints:**
- `POST /auth/v1/passkey/register/options` - Get registration challenge
- `POST /auth/v1/passkey/register/verify` - Complete registration
- `POST /auth/v1/passkey/authenticate/options` - Get authentication challenge
- `POST /auth/v1/passkey/authenticate/verify` - Complete authentication
- `GET /auth/v1/passkey/list-user-passkeys` - List user's registered passkeys
- `POST /auth/v1/passkey/delete-passkey` - Delete a registered passkey

**Browser support:**

Passkeys are supported in all modern browsers:
- Chrome 67+
- Safari 14+
- Firefox 60+
- Edge 79+

**Registration flow:**

```ts
// 1. Get registration options
const optionsRes = await fetch('/auth/v1/passkey/register/options', {
  method: 'POST',
  credentials: 'include'
});
const options = await optionsRes.json();

// 2. Create credential using WebAuthn API
const credential = await navigator.credentials.create({
  publicKey: options
});

// 3. Send credential to server
await fetch('/auth/v1/passkey/register/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify(credential)
});
```

**Authentication flow:**

```ts
// 1. Get authentication options
const optionsRes = await fetch('/auth/v1/passkey/authenticate/options', {
  method: 'POST'
});
const options = await optionsRes.json();

// 2. Get credential using WebAuthn API
const credential = await navigator.credentials.get({
  publicKey: options
});

// 3. Verify credential
await fetch('/auth/v1/passkey/authenticate/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify(credential)
});
```

### JWT Plugin

Better Auth's JWT plugin adds asymmetric-signed tokens and a JWKS endpoint for external services that need to verify user identity without sharing a shared secret.

**Enable in config:**

```ts
auth: defineAuth("better-auth", {
  plugins: ["jwt"],
}),
```

**Endpoints:**
- `GET /auth/v1/jwks` — public keys for verifying BA-issued tokens
- `POST /auth/v1/token` — mint an asymmetric-signed JWT from the current session

> This is **separate from** Quickback's built-in JWT fast-path. Quickback's middleware already validates a custom HMAC-SHA256 JWT (with embedded org context) on every request — enabling the BA plugin does **not** change how the compiled API authenticates. See [JWT Optimization → Relation to Better Auth's JWT Plugin](/stack/auth/jwt-optimization#relation-to-better-auths-jwt-plugin) for the full comparison and when to use each.


**When to enable:** you have external services (non-Quickback workers, third-party integrations) that need to verify a user's identity via JWKS without holding `BETTER_AUTH_SECRET`. The BA-issued tokens won't carry Quickback's `orgId`/`role` claims, so they're for identity, not API access.

### API Keys

Static, long-lived credentials for cron jobs, scripts, and machine-to-machine integrations. Better Auth's `apiKey` plugin is auto-included when you enable the organizations feature, so no explicit config is required for the standard B2B SaaS shape.

**Authenticating requests:**

Pass the key as the `x-api-key` header (preferred) or as an `?api_key=` / `?key=` query parameter:

```bash
# Header form (preferred — keys don't leak into access logs or Referer)
curl -H "x-api-key: ${KEY}" https://api.example.com/v1/jobs

# Query-param form (for clients that can't inject custom headers,
# e.g., claude.ai's MCP connector)
curl "https://api.example.com/v1/jobs?api_key=${KEY}"
```

The header takes precedence when both are present. The query-param form lands in nginx / Cloudflare access logs and `Referer` headers when the page navigates away — prefer headers whenever the client supports them.

**Endpoints (managed by Better Auth):**
- `POST /auth/v1/api-key/create` — issue a new key (returns the secret once; store it)
- `GET /auth/v1/api-key/list` — list keys for the current user
- `POST /auth/v1/api-key/delete` — revoke a key

Keys can carry organization context via `metadata.organizationId` — when set, the auth middleware looks up the user's role in that org and populates `ctx.roles` accordingly, so a single key inherits the same firewall + access scope as the user who created it.

### OAuth Provider (MCP-ready)

Turns your Quickback worker into a standards-compliant OAuth 2.1 + OIDC authorization server. The plugin ships RFC 7591 dynamic client registration plus the well-known discovery documents an MCP client needs to bootstrap itself, so every future MCP client (Cursor, Claude Desktop, Cowork, custom integrations) Just Works without bespoke per-client header injection.

**Enable in config:**

```ts
auth: defineAuth("better-auth", {
  plugins: ["oauthProvider"],
}),
```

The compiler auto-includes Better Auth's `jwt` plugin alongside `oauthProvider` — `@better-auth/oauth-provider` delegates access-token signing to it, and schema generation fails without it.

**Custom configuration** (object form):

```ts
auth: defineAuth("better-auth", {
  plugins: {
    oauthProvider: {
      consentPage: "/consent",                       // path on ACCOUNT_URL — default
      loginPage: "/login",                           // path on ACCOUNT_URL — default
      allowDynamicClientRegistration: true,          // default
      allowUnauthenticatedClientRegistration: true,  // default — MCP needs this
      validAudiences: ["https://api.example.com"],   // optional — defaults to BETTER_AUTH_URL
      scopes: ["openid", "profile", "email", "offline_access"],
    },
  },
}),
```

**Endpoints:**

Discovery (public, no auth):
- `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata
- `GET /.well-known/oauth-protected-resource` — MCP resource metadata
- `GET /.well-known/openid-configuration` — OIDC discovery

Authorization flow:
- `POST /oauth2/register` — RFC 7591 dynamic client registration
- `POST /oauth2/authorize` — start auth code flow
- `POST /oauth2/token` — exchange code or refresh for access token
- `POST /oauth2/consent` — accept/deny (called by the consent page)
- `POST /oauth2/introspect` — RFC 7662 token validity check
- `POST /oauth2/revoke` — RFC 7009 token revocation
- `GET  /oauth2/userinfo` — OIDC user info (requires `openid` scope)

Client management (authenticated):
- `GET  /oauth2/get-clients`, `GET /oauth2/get-client?client_id=`, `GET /oauth2/public-client?client_id=`
- `POST /oauth2/create-client`, `POST /oauth2/update-client`, `POST /oauth2/delete-client`
- `POST /oauth2/client/rotate-secret`

Consent management:
- `GET  /oauth2/get-consents`, `GET /oauth2/get-consent?id=`
- `POST /oauth2/update-consent`, `POST /oauth2/delete-consent`

**Account SPA wiring:**

The compiled Account SPA already serves `/consent`. When `oauthProvider` is enabled, Better Auth redirects unauthenticated users to `${ACCOUNT_URL}/login?redirect=...` and authenticated users straight to `${ACCOUNT_URL}/consent?client_id=...&scope=...&oauth_query=...`. The bundled consent screen reads those params, fetches client metadata via `/oauth2/public-client`, and POSTs back to `/oauth2/consent`.

**Verifying tokens in your own Workers / route handlers:**

Issued tokens are JWTs by default — verify them locally without a DB hit:

```ts
import { createAuthClient } from "better-auth/client";
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
import { auth } from "./auth";

const serverClient = createAuthClient({
  plugins: [oauthProviderResourceClient(auth)],
});

const token = req.headers.get("authorization")?.replace("Bearer ", "");
const payload = await serverClient.verifyAccessToken(token, {
  verifyOptions: { issuer: env.BETTER_AUTH_URL, audience: env.BETTER_AUTH_URL },
});
// payload.sub → userId, payload.scope → granted scopes
```

**Why this and not the deprecated `mcp` plugin?**

Better Auth's older `better-auth/plugins/mcp` is being deprecated in favour of `@better-auth/oauth-provider`. The new package is a full OAuth 2.1 server (the MCP plugin only exposed a subset), supports dynamic registration that MCP clients now require, and lets you scope/revoke per-client.

Keep `apiKey` enabled alongside it — server-to-server flows (cron, scripts) want a static key; OAuth is for interactive clients.

### Custom plugins (escape hatch)

The named plugins above cover the curated set Quickback ships first-class wiring for. For anything else — third-party packages (`@better-auth-kit/audit`, …), Better Auth built-ins outside the registry, or project-local plugins — use `customPlugins`.

```ts
auth: defineAuth("better-auth", {
  emailAndPassword: { enabled: true },
  plugins: { passkey: true, organization: true },
  customPlugins: [
    // (1) Project-local plugin file at quickback/plugins/long-session.ts
    { from: "./plugins/long-session", export: "longSessionPlugin" },
    // (2) Third-party npm package
    {
      from: "@better-auth-kit/audit",
      export: "auditPlugin",
      args: { events: ["sign-in"] },
      dependencies: { "@better-auth-kit/audit": "^1.0.0" },
    },
  ],
}),
```

Each entry in `customPlugins` is an explicit import descriptor. The compiler runs each through Zod, then emits an `import { <export> } from "<from>"` line in the generated `src/lib/auth.ts` and appends the call (or bare symbol) to the `plugins: [...]` array — after the named plugins, in declared order.

#### `from`

- **Relative path** (must start with `./` or `../`, must resolve under `./plugins/`): refers to a file you author in `quickback/plugins/`. The CLI auto-discovers `quickback/plugins/**/*.ts` and ships matching files; the compiler copies the source into the generated output at `src/plugins/<name>.ts` and rewrites the emitted import path. Files in `quickback/plugins/` that aren't referenced by any descriptor are uploaded but ignored — the compiler picks only what's needed.
- **Bare specifier** (any value not starting with `./` or `../`): treated as an npm import, emitted verbatim. Combine with `dependencies` so the package lands in the generated `package.json`.

#### `export`

The named export to import. Must be a valid JS identifier (e.g. `longSessionPlugin`, `auditPlugin`). Use the symbol's source name from the file or package — Quickback emits the import as-is.

#### `args` (optional)

- **Omitted** → emits the symbol uncalled (`plugins: [..., longSessionPlugin]`). Use this when your export is already a `BetterAuthPlugin` instance.
- **Provided** → emits a factory call with JSON-serialized args (`plugins: [..., auditPlugin({"events":["sign-in"]})]`). The value must be JSON-serializable; functions and other non-serializable values fail at compile time. (The user config crosses an HTTP boundary as JSON, so this constraint exists by construction.)

#### `dependencies` (optional)

`Record<string, string>` of npm packages and version specs. Merged into the generated `package.json` alongside the named-plugin deps. Only meaningful for bare-specifier `from` — relative-path entries don't need to declare anything.

#### Project-local plugin shape

A plugin file in `quickback/plugins/` is plain Better Auth — the compiler doesn't transform it. Author it like any other BA plugin and let TypeScript check it against the `BetterAuthPlugin` type:

```ts
// quickback/plugins/long-session.ts
import type { BetterAuthPlugin } from "better-auth";

export const longSessionPlugin = {
  id: "long-session",
  databaseHooks: {
    session: {
      create: {
        before: async (session) => ({
          data: {
            ...session,
            // 5-year session for an event-app use case
            expiresAt: new Date(Date.now() + 5 * 365 * 24 * 60 * 60 * 1000),
          },
        }),
      },
    },
  },
} satisfies BetterAuthPlugin;
```

#### Out of scope

- **No schema generation.** If your plugin needs database tables, declare them as a Quickback feature — the compiler does not run schema introspection on custom plugins.
- **No conditional inclusion.** Every entry in `customPlugins` is always emitted. Gate environment-specific plugins inside the plugin file itself.
- **No name collision check against the named registry.** Duplicate registration is a Better Auth runtime concern, not a compile-time one — don't reach for `customPlugins` to override a named plugin's options; use the object form on `plugins` instead.

## Where This Runs

All Better Auth plugin routes are served under your auth base path:

```
/auth/v1/*
```
