Skip to content
DocspackagesDocumentation

@lunora/auth

A thin better-auth wrapper for Lunora — email/password, OAuth, plugins, D1-backed.

Packages@lunora/auth@lunora/auth

@lunora/auth is a thin wrapper around better-auth that runs on your own Cloudflare account. createAuth(options) is betterAuth(options) with a few Cloudflare-friendly defaults; user and session records live in D1, and there is no external auth service to boot. better-auth owns the actual behaviour: sign-in flows, password hashing (scrypt), OAuth, sessions. This package adds the Cloudflare wiring: a D1 adapter, a /api/auth/* router, schema migrations, a ctx.authApi middleware, an admin surface for the studio, and standalone Turnstile helpers.

import { createAuth, lunoraD1Adapter } from "@lunora/auth";
import { passkey } from "@lunora/auth/plugins";

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    emailAndPassword: { enabled: true },
    plugins: [passkey()],
});

createAuth requires secret up front, so a misconfigured deployment fails loudly at setup. Curated plugins (passkeys, 2FA, magic-link, organization, and more) are re-exported from @lunora/auth/plugins.

Mount the routes

handleAuthRequest(auth, request) serves every auth endpoint under /api/auth/* (sign-up, sign-in, OAuth callbacks, session refresh, and each plugin's routes). Call it at the top of your worker's fetch. It returns a Response for an auth request and a falsy value otherwise, so you fall through to the Lunora worker for everything else:

import { ensureMigrated, handleAuthRequest } from "@lunora/auth";

export default {
    async fetch(request, env, ctx) {
        await ensureMigrated(auth);

        const response = await handleAuthRequest(auth, request);
        if (response) return response;

        // … hand off to your Lunora worker
    },
};

Read ctx.auth in functions

The runtime resolves the inbound session and populates ctx.auth on every query / mutation / action. It carries the verified identity:

  • ctx.auth.userId is the signed-in user's id, or null for an anonymous request. A truthy check narrows it to string and doubles as your "is signed in" guard.
  • ctx.auth.getIdentity() resolves the decoded identity claims, or null when anonymous.
if (!ctx.auth.userId) throw new Error("must be signed in");
const identity = await ctx.auth.getIdentity();

Sessions

Sessions are better-auth's, stored in the session table on the same D1 database as the user / account / verification tables. better-auth writes the session cookie and validates it on each request; createAuth applies a secure-by-default cookie posture on top: httpOnly, sameSite: "lax", path: "/", and useSecureCookies forced on for an HTTPS baseURL (the process.env.NODE_ENV heuristic better-auth uses to decide this is unreliable on Workers).

Tune session lifetime and rotation through the session field, a SessionPolicy (a typed alias for better-auth's session option). createAuth validates the durations and forwards them verbatim, and sessionPresets gives you ready-made trade-offs:

import { createAuth, lunoraD1Adapter, sessionPresets } from "@lunora/auth";

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    // 7-day absolute expiry, rotated once per day; override individual fields.
    session: { ...sessionPresets.rolling, freshAge: 60 * 5 },
});

The presets are rolling (7-day expiry, daily rotation), strict (1-hour expiry, 15-minute rotation), and longLived (30-day expiry, daily rotation). The underlying fields are expiresIn, updateAge, freshAge, disableSessionRefresh, and cookieCache; see better-auth's session option for the full list.

OAuth providers

OAuth is entirely better-auth's. Built-in social providers run through its socialProviders config, and anything else goes through the genericOAuth plugin (re-exported from @lunora/auth/plugins). createAuth forwards socialProviders unchanged; the code/token/userinfo exchange, PKCE, and id_token verification are all better-auth's:

import { createAuth, lunoraD1Adapter } from "@lunora/auth";

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    socialProviders: {
        github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET },
        google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },
    },
});

Callbacks are served under /api/auth/callback/<provider> by handleAuthRequest. For providers beyond the built-in list, add genericOAuth({ config: [...] }) to plugins.

Enterprise SSO and SCIM provisioning

Two plugins cover what enterprise buyers ask for: sign-in through the customer's own identity provider, and user lifecycle driven by their directory.

scim ships in the general plugin barrel. sso does not: it statically imports samlify for its SAML path (pulling xml-crypto, node-rsa, and @xmldom/xmldom, ~1.1 MB), and tree-shaking keeps that out of your bundle but not out of your install. So it lives behind its own subpath as an optional peer you install only if you want it:

pnpm add @better-auth/sso
import { createAuth, lunoraD1Adapter } from "@lunora/auth";
import { admin, organization } from "@lunora/auth/plugins";
import { sso } from "@lunora/auth/plugins/enterprise";

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    // The IdP's origin must be trusted before a provider can be registered — see below.
    trustedOrigins: ["https://acme.okta.com"],
    plugins: [sso({ domainVerification: { enabled: true } }), admin(), organization()],
});

SSO runs on any database Lunora supports, D1 included. SCIM does not: it needs an adapter with native transactions, which rules out D1. See SCIM's database requirement before planning around it.

The tables come for free: authTables derives them from the plugin list, so lunora migrate picks them up with no schema edits: ssoProvider for SSO, and seven scim* tables for provisioning. An app that declares neither plugin gets neither.

SSO — one provider per email domain

The plugin's real affordance is domain routing: a user types a work email and lands at their own IdP without picking anything from a list. Register a provider once per customer, keyed by their domain, then let the client resolve it:

// Server — as a signed-in admin of the tenant.
await auth.api.registerSSOProvider({
    body: {
        providerId: "acme-oidc",
        issuer: "https://acme.okta.com",
        domain: "acme.com",
        oidcConfig: { clientId: env.ACME_CLIENT_ID, clientSecret: env.ACME_CLIENT_SECRET, scopes: ["openid", "email", "profile"] },
    },
    headers,
});

// Client — `ssoClient` from `@lunora/auth/plugins/enterprise/client`.
await authClient.signIn.sso({ email: "someone@acme.com", callbackURL: "/dashboard" });

Four things to set deliberately, because the defaults are permissive:

  • domainVerification is off by default. With it off, a domain is trusted the moment it is written, so any signed-in user can register a provider for a domain they do not own: /sso/register is session-authenticated, not admin-gated. Your own login page then redirects that domain's employees to an attacker's IdP. Turning it on gates a provider on proving control of the domain (/sso/request-domain-verification + /sso/verify-domain). Narrow providersLimit too if only staff should register providers at all.
  • Domain matching is suffix-based. A provider registered for com catches every .com address without a more specific provider. Register exact domains.
  • Registration makes an outbound call. better-auth fetches the issuer's OIDC discovery document during registerSSOProvider, unconditionally, deriving the URL from issuer when you don't pass discoveryEndpoint. In a Worker that is a subrequest at registration time, so a firewalled IdP fails there, not at sign-in.
  • trustedOrigins is an SSRF gate, not boilerplate. Discovery URLs are checked for public-routability first (loopback, RFC 1918, link-local, and cloud-metadata hosts like 169.254.169.254 are refused), then against your trustedOrigins. A self-hosted IdP needs its origin listed or registration fails discovery_untrusted_origin.

What the defaults do not expose: takeover of an existing account. better-auth refuses to link an unverified-email identity to an existing user, and an unverified provider is never trusted, so the exposure is phishing-redirect and account creation, not hijacking someone's existing login.

Use provisionUser / organizationProvisioning to shape the just-in-time account (roles, org membership) the first time someone signs in.

SCIM — directory-driven provisioning

scim() serves SCIM 2.0 under /api/auth/scim/v2/ (Users, Groups, Schemas, ResourceTypes, and ServiceProviderConfig), so an IdP can create, update, and deactivate directory users (and sync groups) without anyone signing in.

SCIM needs a database adapter with native transactions, so it does not run on D1. The plugin refuses to serve a request otherwise, with The scim plugin requires a database adapter with native transaction support.

That rules out lunoraD1Adapter and lunoraAuthAdapter (both are single-table CRUD over ctx.db), and D1 itself, whose driver has no interactive transactions at all.

Run SCIM against a transactional database instead. The supported route on Cloudflare is Postgres or MySQL through @lunora/hyperdrive. See SCIM on Hyperdrive below for the wiring.

SSO has no such constraint and runs fine on D1.

Connections are declared in code, not minted at runtime. Each connection carries its own bearer credentials, and you hand the IdP the token you configured:

scim({
    connections: [
        {
            id: "okta-acme",
            // `provisioningDomainId` defaults to the connection id — set it when several
            // connections provision into one tenant boundary.
            credentials: [
                { type: "bearer", id: "primary", token: env.SCIM_TOKEN, scopes: ["scim.users.read", "scim.users.write"] },
                // Keep the outgoing credential listed during a rotation, with an expiry.
                { type: "bearer", id: "retiring", token: env.SCIM_TOKEN_OLD, expiresAt: new Date("2026-09-01") },
            ],
        },
    ],
});

That shape is the fix for GHSA-j8v8-g9cx-5qf4 (HIGH), which affected every @better-auth/scim before 1.7.0-beta.4: the old design exposed a session-authenticated endpoint that minted connection tokens and stored them in plaintext in the database, so any signed-in user could take over another tenant's connection. Declaring credentials in config removes both the endpoint and the secret at rest. Keep your tokens in ctx.secrets / .dev.vars, never in the source.

Scope each credential to what the IdP actually needs: scim.users.read, scim.users.write, scim.groups.read, scim.groups.write (omitting scopes grants all four).

Two lifecycle semantics that surprise people, verified against the shipped plugin, so this is what it actually does rather than what the SCIM spec might imply:

  • Deactivation does not lock the account. A PATCH of active: false is recorded in SCIM's own projection; it does not set banned or otherwise stop the user signing in, and loading the admin plugin does not change that. If disabling a user in the IdP must revoke access, act on the projection yourself.
  • DELETE unlinks, it does not erase. It removes the SCIM resource and leaves the underlying account row in place. Treating it as an off-boarding guarantee would be wrong.

Both PATCH and DELETE answer 204 with no body; re-read the resource with GET if you need the resulting state.

The IdP drives these endpoints with PUT, PATCH, and DELETE. Lunora's dispatch is method-agnostic and runs auth ahead of function routing, so those pass straight through. Auth routes bypass the runtime's request-body limits (the caps in body-readers.ts apply to Lunora's own routes, not to /api/auth/*), so a very large SCIM payload is bounded by the platform, not by Lunora.

SCIM's database requirement, and lunoraDoAdapter

scim() refuses to serve unless its adapter exposes native transactions, failing with The scim plugin requires a database adapter with native transaction support. D1 has none: its driver rejects interactive transactions, and batch() cannot substitute because SCIM reads-then-conditionally-writes. So lunoraD1Adapter and lunoraAuthAdapter are both refused. There are two ways round it.

Postgres or MySQL via Hyperdrive is the conservative option: hand better-auth a kysely dialect with transaction: true. Your auth tables then live outside Cloudflare's first-party storage, away from ctx.db. Walked through in SCIM on Hyperdrive below.

lunoraDoAdapter keeps them on Cloudflare. A Durable Object's storage does have real transactions (state.storage.transaction: async, atomic, rolled back on throw, isolated from concurrent dispatch), so better-auth can run inside an object on its own SQLite:

// lunora/auth-do.ts — a thin subclass; the base class owns everything else.
import { LunoraAuthDO } from "@lunora/auth";
import { admin, scim } from "@lunora/auth/plugins";

export class AuthDO extends LunoraAuthDO {
    constructor(state: DurableObjectState, env: Env) {
        super(
            state,
            () => ({
                secret: env.AUTH_SECRET,
                plugins: [scim({ connections: [{ id: "okta-acme", credentials: [{ type: "bearer", id: "primary", token: env.SCIM_TOKEN }] }] }), admin()],
            }),
            { internalSecret: env.AUTH_DO_SECRET },
        );
    }
}

LunoraAuthDO builds the instance lazily on the first request, creates its own tables, serves /api/auth/*, and answers one internal route the worker uses to resolve identity. Then point the builder at it:

app.auth({
    namespace: (env) => env.AUTH_DO,
    internalSecret: (env) => env.AUTH_DO_SECRET,
    options: (env) => ({ secret: env.AUTH_SECRET, plugins: [scim({ ... }), admin()] }),
});

Re-export AuthDO from your worker entry and the config layer adds the durable_objects binding and the new_sqlite_classes migration for you. Pass d1 or namespace, never both: the builder throws on the ambiguous shape rather than letting one silently win, and namespace without internalSecret throws too (identity resolution is gated on that secret and would otherwise fail closed on every request).

Why the schema is created for you

better-auth's migrator is kysely-only, so ensureMigrated cannot target DO storage. The object derives its DDL from better-auth's own resolved tables instead. That matters more than it sounds: better-auth expresses unique: true as a separate CREATE UNIQUE INDEX, not a column constraint. A materialiser that walks fields and writes columns produces tables that look correct and silently accept duplicate emails, duplicate session tokens, and duplicates across all twelve of SCIM's unique fields. If you hand-roll this, emit the indexes; or call authDoSchemaStatements(options) and execute what it returns.

What DO mode does not give you

ctx.authApi, sign-in, sessions, SCIM, and the studio's audit feed all work: the audit log lives in the object like every other auth table, and the worker reads it back through an internal route.

The studio's auth admin pages do not. authAdmin is a ~30-method surface that reads the auth tables directly from the worker, which DO storage does not permit, so it reports "not configured" rather than returning empty data.

Identity resolution costs a round-trip to the object on requests that touch ctx.auth. Enable better-auth's session.cookieCache to serve most of them from the signed cookie instead, accepting a staleness window on revocation. The vis generate lunora-auth-do scaffold turns it on by default for that reason.

Scaffolding it

vis generate lunora-auth-do --name=AuthDO

Writes lunora/auth-do.ts and reminds you of the two things the file alone does not do: re-export the class from your worker entry (that is what makes the config layer add the durable_objects binding and the new_sqlite_classes migration), and point the builder at it.

lunoraDoAdapter is @experimental, and the trade is architectural rather than cosmetic: user, session and the SCIM tables live inside one Durable Object, so writes serialise through a single object, the object's storage limits apply, and backup/export follows the DO path rather than D1's. Two things it does not do: ensureMigrated (better-auth's migrator is kysely-only, so the object materialises its schema from authTables itself), and sharding (there is one auth object). Measure it before moving an existing deployment.

SCIM on Hyperdrive

SCIM's transaction requirement means its tables live in a real SQL database rather than D1. Hyperdrive is how a Worker reaches one: it pools and routes the connection so a Worker isn't opening a fresh TCP session per request.

Note this is a different use of Hyperdrive from ctx.sql. @lunora/hyperdrive's ctx.sql is an action-only escape hatch for querying an existing database; here you are giving better-auth a kysely dialect to own its own tables. Only createHyperdrive's connection details are shared between the two.

Create the binding, pointed at your database:

# Pass the URL through the environment rather than typing it inline — an inline
# connection string lands in your shell history.
export LUNORA_AUTH_DATABASE_URL='postgres://…'
wrangler hyperdrive create lunora-auth --connection-string="$LUNORA_AUTH_DATABASE_URL"
// wrangler.jsonc
{
    "hyperdrive": [{ "binding": "AUTH_DB", "id": "<id from the create command>" }],
}

Then hand better-auth a kysely dialect over it, with transaction: true:

import { createAuth } from "@lunora/auth";
import { admin, scim } from "@lunora/auth/plugins";
import { createHyperdrive } from "@lunora/hyperdrive";
import { PostgresDialect } from "kysely";
import { Pool } from "pg";

const authFor = (env: Env) => {
    const { connectionString } = createHyperdrive(env.AUTH_DB);

    return createAuth({
        secret: env.AUTH_SECRET,
        database: {
            // `transaction: true` is the whole point — without it the kysely adapter
            // exposes no transaction and `scim()` refuses to serve.
            dialect: new PostgresDialect({ pool: new Pool({ connectionString, max: 5 }) }),
            transaction: true,
            type: "postgres",
        },
        plugins: [scim({ connections: [{ id: "okta-acme", credentials: [{ type: "bearer", id: "primary", token: env.SCIM_TOKEN }] }] }), admin()],
    });
};

pg and kysely are app-level installs (pnpm add pg kysely), and pg needs nodejs_compat in compatibility_flags. Keep max small: Hyperdrive is already pooling, so a large per-isolate pool just multiplies connections.

Practical notes:

  • Build the auth instance per request, not at module scope. The pool closes over a binding from env, which only exists inside a request.
  • Apply the schema at deploy time. ensureMigrated is convenient in dev, but on a real Postgres prefer compileMigrationsSql output applied as a deploy step, so the first request isn't racing DDL.
  • This splits your data. Auth tables live in Postgres while your app's tables stay in Durable Objects / D1, so ctx.db joins across the two are not available. Resolve identities through ctx.auth instead.
  • SSO can stay on D1. If you want enterprise SSO without moving your auth tables, run sso() on lunoraD1Adapter and skip SCIM; only SCIM carries the requirement.

Which to pick: if you already run Postgres, Hyperdrive is less new machinery. If you want to stay entirely on Cloudflare and can accept single-object writes for auth, the DO adapter avoids a second database. SSO needs neither: it has no transaction requirement and runs on lunoraD1Adapter as usual.

SAML status

sso() supports SAML 2.0 as well, and the module loads in workerd: the LUNORA_WORKERD_TESTS=1 suite boots a worker that imports and constructs the plugin (samlify dependency tree included) in the real runtime, and CI runs it.

What is not measured is the SAML code path: assertion verification runs pure-JS RSA, and upstream better-auth#10343 flags ACS as a poor fit for a Worker's CPU budget, proposing a pluggable remote executor.

Treat OIDC/OAuth2 as the supported mode and SAML as unverified until that measurement exists.

Background tasks (waitUntil)

better-auth runs some work after sending the response, most importantly the password-reset email, whose background send keeps reset responses constant-time (so the response doesn't reveal whether the account exists). On Cloudflare Workers a promise not handed to ctx.waitUntil can be cancelled the moment the response returns, dropping that send. Wire the per-request ctx.waitUntil into better-auth's background handler:

const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    advanced: {
        backgroundTasks: { handler: (promise) => ctx.waitUntil(promise) },
    },
});

createAuth can't set this for you: ctx.waitUntil is per-request, but createAuth runs once at worker setup.

Rate limiting

/api/auth/* is rate-limited by default. better-auth enables rate limiting automatically, but only when NODE_ENV === "production", a check that is unreliable on Cloudflare Workers, where there is no Node process.env at request time. So createAuth defaults rateLimit.enabled to true whenever you don't set it explicitly, and auth endpoints are throttled on real deployments (and in dev) rather than silently wide open.

The defaults are better-auth's: a 10-second window with a max of 100 requests per IP, plus stricter per-path rules for sensitive endpoints (sign-in, sign-up, etc.). Configure it through the rateLimit option, which is forwarded to better-auth verbatim:

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    rateLimit: {
        window: 60, // seconds
        max: 100, // requests per window per IP
        customRules: {
            "/sign-in/email": { window: 10, max: 5 },
        },
    },
});

To turn rate limiting off, for example when you front auth with your own limiter (see @lunora/ratelimit), pass an explicit flag; an explicit enabled value always wins over the default:

export const auth = createAuth({
    secret: env.AUTH_SECRET,
    rateLimit: { enabled: false },
});

Password hashing

Password hashing is better-auth's, not Lunora's. By default it hashes with scrypt (no Node polyfills needed on Workers). To swap the algorithm, pass emailAndPassword.password: { hash, verify } to createAuth; it forwards to better-auth unchanged.

Calling the plugin API from procedures

@lunora/auth/middleware exports withAuthPlugins(auth), a Lunora middleware that mounts the full better-auth endpoint surface on ctx.authApi (typed against whatever plugins your instance loaded). Because ctx.authApi is the privileged surface (banUser, setRole, impersonation, …), the middleware installs a runtime guard by default: a call that omits headers throws LunoraAuthHeadersError instead of running as a trusted server-to-server invocation. See plugins for the full pattern and the withoutHeaders() escape hatch.

Disposable / free-email gating

Reject throwaway/disposable signups (and branch on free-vs-business email) at registration by reusing the visulima email lists. The classification is pure-data and edge-safe on the default path, with no DNS and no filesystem access.

Wire it into better-auth's native /sign-up/email endpoint with withEmailGate (or spread emailGateDatabaseHooks(...) into createAuth({ databaseHooks })):

import { createAuth, lunoraD1Adapter, withEmailGate } from "@lunora/auth";

const auth = createAuth(
    withEmailGate(
        { secret: env.AUTH_SECRET, database: lunoraD1Adapter(env.DB), emailAndPassword: { enabled: true } },
        {
            blockDisposable: true, // default: reject disposable domains with `EMAIL_DOMAIN_BLOCKED` (400)
            allowDomains: ["your-company.com"], // never blocked; always classified `business`
            denyDomains: [], // extra domains to treat as disposable
            onClassify: (classification, user) => {
                // classification.emailClass is "disposable" | "free" | "business"
            },
        },
    ),
);

A blocked signup rejects with the coded EMAIL_DOMAIN_BLOCKED error; a business/free address passes. Every option above is config-gated.

For non-auth procedures, @lunora/auth/email-guard exports the building blocks:

  • classifyEmail(email, config): sync, pure-data → { emailClass, domain }.
  • assertEmailAllowed(email, config): async; throws the coded error on a policy failure.
  • emailGateMiddleware({ email: (ctx) => ctx.args.email }): a .use() gate for your own signup mutation.
  • loadEmailDomainLists(): call once at worker init on workerd (the gate helpers already do).

MX / deliverability (opt-in, needs DNS). Passing mx: true runs an MX check via @visulima/email-verifier/checks/mx, which uses node:dns and is therefore not edge-safe; it is loaded through a dynamic import so the DNS module never enters the default bundle. Enable it only where DNS is available (nodejs_compat or a DNS-over-HTTPS shim); an undeliverable domain then fails with EMAIL_UNDELIVERABLE.

The advisor ships a signup_mutation_without_disposable_gating lint that flags a public account-creating mutation with no email gate (pairs with the existing user_creating_mutation_without_captcha lint).

Security / audit trail

Record authentication and security events to a durable, queryable audit trail: sign-in, sign-up, password change, MFA enable/disable, token refresh, session revoke, account link/unlink. That is the compliance/forensics surface Supabase and Firebase expose. Install the better-auth hooks.after recorder with authAuditHook (or compose via withAuthAudit):

import { authAuditHook, createAuth, d1Executor, lunoraD1Adapter, readAuthAuditLog } from "@lunora/auth";

const executor = d1Executor(env.DB); // same D1 as the auth tables

const auth = createAuth({
    secret: env.AUTH_SECRET,
    database: lunoraD1Adapter(env.DB),
    hooks: {
        after: authAuditHook({
            executor,
            retention: 100_000, // CONFIGURABLE, NOT capped — omit for an unbounded (compliance) trail
            onRecord: (entry) => forwardToSiem(entry), // optional export tap for SIEM forwarding
        }),
    },
});

// Read the trail (RLS/admin-gate this in your own query):
const signIns = await readAuthAuditLog(executor, { event: "sign-in", limit: 100 });

Each row captures the actor (id + email), event type, client IP/User-Agent, timestamp, and outcome. The free-form detail payload is scrubbed with @visulima/redact before it is persisted, so a token/password that leaks into an event's context never reaches the durable table. The trail lives in the reserved __lunora_auth_audit__ table (auto-hidden from the data browser); retention is configurable via retention and defaults to unbounded.