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 your email provider. When emailOtp is enabled, the compiler emits an OTP-only sign-in email — a six-digit code, no magic-link companion.
Enable in config:
import { defineAuth, defineConfig, defineDatabase, defineRuntime } from "@quickback/compiler";
export default defineConfig({
name: "quickback-api",
providers: {
runtime: defineRuntime("cloudflare"),
database: defineDatabase("cloudflare-d1"),
auth: defineAuth("better-auth", {
emailAndPassword: { enabled: true },
plugins: ["emailOtp"],
}),
},
bindings: {
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",
},
},
});Endpoints:
POST /auth/v1/email-otp/send-verification-otpPOST /auth/v1/email-otp/verify-otp
Codes are stored hashed and expire after 300 seconds by default; set plugins: { emailOtp: { expiresIn: 600 } } to change it — the email states the configured lifetime.
Email readiness check:
Use this to show UI warnings when SES isn't configured.
GET /api/v1/system/email-status- Response:
{ "emailConfigured": true|false }
Disabling sign-up via OTP:
By default the Email OTP plugin auto-creates an account when a code is
verified for an unknown email — verifying an OTP doubles as registration. This
is a separate code path from emailAndPassword.disableSignUp (which only gates
POST /auth/v1/sign-up/email), so disabling password sign-up alone does not
close the OTP path.
To make OTP sign-in only (existing users can sign in; unknown emails are rejected instead of registered), use the object form:
auth: defineAuth("better-auth", {
emailAndPassword: { enabled: true },
plugins: {
emailOtp: { disableSignUp: true },
},
}),emailOtp.disableSignUp inherits emailAndPassword.disableSignUp: true, so
the OTP path can't bypass a hard password-signup block. Set
emailOtp: { disableSignUp: false } explicitly to opt the OTP path back open.
account.auth.signup: false is different: it sets the invite-only
signup policy, which covers every
sign-in method and still admits invitees.
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 the vendored AWS SNS transport (emitted into src/plugins/aws-sns/, not installed from npm). 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:
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-otpPOST /auth/v1/phone-number/verifyPOST /auth/v1/sign-in/phone-numberPOST /auth/v1/phone-number/request-password-resetPOST /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 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_REGIONEMAIL_FROM
Optional vars:
EMAIL_FROM_NAMEEMAIL_REPLY_TO- Reply-to address for emails (defaults toEMAIL_FROM)APP_NAMEAPP_URLBETTER_AUTH_URL
Magic Links
Magic links provide passwordless authentication by emailing a single-use sign-in link.
Enable in config:
auth: defineAuth("better-auth", {
plugins: { magicLink: { expiresIn: 900 } }, // seconds; default 300
}),Endpoints:
POST /auth/v1/sign-in/magic-link- Send the magic link email ({ email, callbackURL? })GET /auth/v1/magic-link/verify- Spend the token, set the session, redirect tocallbackURL
The emailed link opens a confirm page. Mail scanners fetch every link in a message, which would spend a single-use token before the recipient clicks. So the email links to <ACCOUNT_URL>/magic-link?token=… — the Account UI confirm page — and only its Continue signing in button navigates to /magic-link/verify. If you serve your own frontend at ACCOUNT_URL, give it a /magic-link page that does the same: forward the query string to /auth/v1/magic-link/verify on a user click.
Tokens are stored hashed, and the email states the configured expiresIn.
Disabling sign-up via magic link:
Like Email OTP, the magic-link plugin auto-creates an account when a link is
verified for an unknown email, on a code path independent of
emailAndPassword.disableSignUp. Gate it with the object form:
auth: defineAuth("better-auth", {
emailAndPassword: { enabled: true },
plugins: {
magicLink: { disableSignUp: true }, // sign-in only; unknown emails rejected
},
}),It inherits emailAndPassword.disableSignUp: true the same way
emailOtp.disableSignUp does; set magicLink: { disableSignUp: false } to opt
it back open. The invite-only signup policy
applies to magic links too.
Email delivery:
Magic link emails go through your configured email.provider — Cloudflare Email Sending by default, or AWS SES with the same variables as Email OTP (APP_NAME, EMAIL_FROM, EMAIL_FROM_NAME). ACCOUNT_URL sets where the confirm page lives.
Frontend integration:
// Request a magic link
await fetch('/auth/v1/sign-in/magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email: 'user@yourdomain.com', callbackURL: 'https://app.yourdomain.com/' }),
});
// The user opens the link, lands on <ACCOUNT_URL>/magic-link, clicks
// "Continue signing in", and is redirected to callbackURL signed in.Passkeys
Passkeys provide passwordless authentication using WebAuthn/FIDO2. Users authenticate with biometrics (fingerprint, face) or device PIN instead of passwords.
Enable in config:
auth: defineAuth("better-auth", {
plugins: ["passkey"],
}),Relying party resolution:
WebAuthn needs a relying party ID and origin. The generated plugin resolves them in this order:
advanced.crossSubDomainCookies.domain(leading dot stripped) becomes therpIDwhen configured — credentials then work across sibling subdomains.- The
ACCOUNT_URLenv var, when set on the deploy target:rpIDis its registrable hostname (account.example.com→example.com; a two-label host likeexample.comis kept whole) and the origin is its bare origin — a path suffix such as/accountis dropped, so the same variable can keep serving as the email link base. - The compile-time account origin —
account.domainwhen configured, elsedomain(or the firstroutescustom domain). Baked automatically; no env var required.
rpID: 'localhost' applies only when the project has no configured domain at all (pure local dev). If passkey registration returns 500 on a deployed worker, check that one of the three sources above resolves to the hostname the Account UI is actually served from.
Endpoints:
POST /auth/v1/passkey/register/options- Get registration challengePOST /auth/v1/passkey/register/verify- Complete registrationPOST /auth/v1/passkey/authenticate/options- Get authentication challengePOST /auth/v1/passkey/authenticate/verify- Complete authenticationGET /auth/v1/passkey/list-user-passkeys- List user's registered passkeysPOST /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:
// 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:
// 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 owns the keyring every Quickback token is signed with. The compiler enables it automatically whenever the project signs JWTs — an auth.jwt lane, authz.scopes, or agent auth — so there is nothing to declare.
Endpoints:
GET /auth/v1/jwks— the public keys. External services verify Quickback tokens from here without holding any secret.GET /auth/v1/token— mint an identity JWT for the current session (cookie or session-token bearer). Carries the same claims the middleware mints:sid,ict,orgIdbeside the rawrole,userRole,tiers,email,name. TTL isauth.jwt.expiresIn(default 180 s). Present only whenauth.jwtis declared.
The keyring is one system with Quickback's fast-path, not a second token format: identity tokens, the session-path refresh, and scope tokens are all EdDSA-signed with the same key and checked by the same verifier. See JWT Optimization → Relation to Better Auth's JWT Plugin.
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, or a prefixed key as Authorization: Bearer <key> (query parameters are not accepted, so keys never land in access logs or Referer):
curl -H "x-api-key: ${KEY}" https://api.example.com/api/v1/jobsSending x-api-key together with a session cookie or an Authorization header is a 400 AUTH_METHOD_CONFLICT.
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 userPOST /auth/v1/api-key/delete— revoke a key
These need a real session (cookie or session-token bearer). Under /auth/v1/* a key authenticates only GET /auth/v1/get-session; every other Better Auth endpoint ignores x-api-key, so a leaked key cannot mint JWTs, manage keys, or act on organizations. See API Keys.
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. metadata.scopes narrows a key to named resources and read/write — see API Keys → Scopes. New keys carry a project prefix (plugins: { apiKey: { prefix } }), a banned owner's keys stop working, and each key is rate limited.
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:
auth: defineAuth("better-auth", {
oauth: {
enabled: true,
issuerUrl: {
env: "OAUTH_ISSUER_URL",
fallback: "http://localhost:8787/auth/v1",
},
loginPage: {
env: "OAUTH_LOGIN_URL",
fallback: "http://localhost:8787/account/login",
},
consentPage: {
env: "OAUTH_CONSENT_URL",
fallback: "http://localhost:8787/account/consent",
},
scopes: ["openid", "profile", "email", "offline_access"],
audiences: [
{ env: "OAUTH_API_AUDIENCE", fallback: "http://localhost:8787" },
{ env: "OAUTH_MCP_AUDIENCE", fallback: "http://localhost:8787/mcp" },
],
allowDynamicClientRegistration: true,
},
}),The compiler auto-includes Better Auth's oauthProvider, jwt, and bearer
plugins, protects POST /mcp, emits RFC 8414 and RFC 9728 discovery, and
accepts the provider's JWKS-signed access tokens in the standard Quickback
auth middleware.
audiences (RFC 8707 resource indicators) is unioned with this worker's own
origin + /mcp — you list only the other resource servers you mint tokens for,
never losing your own. To run a split authorization-server / resource-server
topology (one SSO AS, many /mcp resource servers sharing one auth DB), a resource
server sets oauth.issuerUrl to the AS and agents.mcp.requireAuth: true — it then
verifies AS-issued bearers against the shared JWKS. See
Agents → Split authorization server.
The legacy plugin object remains available for provider-specific options:
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://crm.example.com/mcp"], // optional — unioned with this AS's own origin + /mcp
scopes: ["openid", "profile", "email", "offline_access"],
},
},
}),Endpoints:
Discovery (public, no auth):
GET /.well-known/oauth-authorization-server— RFC 8414 metadataGET /.well-known/oauth-authorization-server/auth/v1— RFC 8414 path-insert discoveryGET /.well-known/oauth-protected-resource— MCP resource metadataGET /.well-known/openid-configuration— OIDC discovery
Authorization flow:
POST /oauth2/register— RFC 7591 dynamic client registrationPOST /oauth2/authorize— start auth code flowPOST /oauth2/token— exchange code or refresh for access tokenPOST /oauth2/consent— accept/deny (called by the consent page)POST /oauth2/introspect— RFC 7662 token validity checkPOST /oauth2/revoke— RFC 7009 token revocationGET /oauth2/userinfo— OIDC user info (requiresopenidscope)
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-clientPOST /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 sends unauthenticated users to ${ACCOUNT_URL}/login and authenticated users to ${ACCOUNT_URL}/consent. Both URLs carry the signed authorize query (client_id, scope, repeated ba_param keys, sig, and so on). The bundled consent screen fetches client metadata via /oauth2/public-client, then POSTs the page's query back to /oauth2/consent as oauth_query. The login page does the same on sign-in. The SPA router keeps that query verbatim, because any rewrite fails the signature check with invalid_signature.
Token and tenant context:
Generated middleware validates the provider JWKS, canonical issuer, configured
audience, expiration, subject, and backing Better Auth session. It then builds
the same AppContext used by session and Quickback JWT authentication.
Organization membership and role hierarchy are resolved automatically.
For application-specific tenant selection, add
quickback/hooks/resolve-oauth-context.ts; see
Auth Hooks.
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.
Subscriptions (Stripe)
Subscription tiers, Stripe Checkout, the billing portal, and state sync through the inbound webhook surface. Tiers are declared in config — lowest to highest, with a Stripe priceId on every paid tier — and the compile fails without them.
database: defineDatabase("cloudflare-d1", { webhooksBinding: "WEBHOOKS_DB" }),
auth: defineAuth("better-auth", {
plugins: {
subscriptions: {
tiers: {
free: { name: "Free", limits: { projects: 3 } },
pro: { name: "Pro", priceId: "price_1234", limits: { projects: 100 } },
},
},
},
}),Requires webhooksBinding — Stripe events are the only thing that renews, downgrades or cancels a subscription. Secrets: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET. See the Subscriptions plugin page for the endpoint table, webhook wiring, and the org-vs-personal precedence rule.
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.
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 inquickback/plugins/. The CLI auto-discoversquickback/plugins/**/*.tsand ships matching files; the compiler copies the source into the generated output atsrc/plugins/<name>.tsand rewrites the emitted import path. Files inquickback/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 withdependenciesso the package lands in the generatedpackage.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 aBetterAuthPlugininstance. - 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:
// 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 or extra fields, declare them as a Quickback feature. The compiler never loads project-local plugin files while it generates the auth schema, so a plugin's
schemais not read. A referenced plugin whose source declares aschemagets a compile warning. (Bare-specifier plugins are loaded, from the compiler's pinned dependencies — never from anything your project uploads — so a package the compiler bundles, such asbetter-auth/plugins, still contributes its schema.) - No conditional inclusion. Every entry in
customPluginsis 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
customPluginsto override a named plugin's options; use the object form onpluginsinstead.
Where This Runs
All Better Auth plugin routes are served under your auth base path:
/auth/v1/*