@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.userIdis the signed-in user's id, ornullfor an anonymous request. A truthy check narrows it tostringand doubles as your "is signed in" guard.ctx.auth.getIdentity()resolves the decoded identity claims, ornullwhen 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/ssoimport { 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:
domainVerificationis off by default. With it off, adomainis trusted the moment it is written, so any signed-in user can register a provider for a domain they do not own:/sso/registeris 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). NarrowprovidersLimittoo if only staff should register providers at all.- Domain matching is suffix-based. A provider registered for
comcatches every.comaddress 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 fromissuerwhen you don't passdiscoveryEndpoint. In a Worker that is a subrequest at registration time, so a firewalled IdP fails there, not at sign-in. trustedOriginsis an SSRF gate, not boilerplate. Discovery URLs are checked for public-routability first (loopback, RFC 1918, link-local, and cloud-metadata hosts like169.254.169.254are refused), then against yourtrustedOrigins. A self-hosted IdP needs its origin listed or registration failsdiscovery_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
PATCHofactive: falseis recorded in SCIM's own projection; it does not setbannedor otherwise stop the user signing in, and loading theadminplugin does not change that. If disabling a user in the IdP must revoke access, act on the projection yourself. DELETEunlinks, 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=AuthDOWrites 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.
ensureMigratedis convenient in dev, but on a real Postgres prefercompileMigrationsSqloutput 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.dbjoins across the two are not available. Resolve identities throughctx.authinstead. - SSO can stay on D1. If you want enterprise SSO without moving your auth tables,
run
sso()onlunoraD1Adapterand 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.