@lunora/auth
A thin better-auth wrapper for Lunora — email/password, OAuth, plugins, D1-backed.
@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 — and 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 is a thin wrapper over better-auth. It 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.
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, ensuring 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 — no DNS, no filesystem.
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. Everything is config-gated and defaults sensibly.
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 & security events — sign-in, sign-up, password change, MFA
enable/disable, token refresh, session revoke, account link/unlink — to a
durable, queryable audit trail (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.
See also
- @lunora/auth plugins — passkeys, org, admin, 2FA, and the studio dashboard.
- Getting started
- @lunora/server