@lunora/auth plugins
Org, admin, and other better-auth plugins surfaced as first-class Lunora middleware.
@lunora/auth is a thin wrapper around better-auth.
The Lunora-specific surface is small on purpose:
createAuth(options)—betterAuth(options)with a clearer error whensecretis missing.handleAuthRequest(auth, request)— prefix routing for/api/auth/*.ensureMigrated(auth)/compileMigrationsSql(options)— schema sync for the configured plugins.withAuthPlugins(auth)— middleware that mounts the plugin endpoint API ontoctx.authApi.
The actual plugin behaviour (organizations, admin, OAuth, MFA, …) is
better-auth's. Lunora just re-exports the factories under
@lunora/auth/plugins so you don't have to know the deep import paths.
Supported plugins
Re-exported from @lunora/auth/plugins:
import {
admin,
anonymous,
bearer,
captcha,
createAccessControl,
customSession,
deviceAuthorization,
emailOTP,
genericOAuth,
haveIBeenPwned,
jwt,
magicLink,
mcp,
multiSession,
oAuthProxy,
oidcProvider,
oneTimeToken,
organization,
passkey,
phoneNumber,
siwe,
twoFactor,
username,
withMcpAuth,
} from "@lunora/auth/plugins";Each factory configures one better-auth feature. Add the ones you want to
createAuth({ plugins: [...] }). Lunora only re-exports the factory; the
behaviour and the full option reference are better-auth's, linked per row.
Sign-in methods
| Export | What it adds | Reference |
|---|---|---|
passkey | WebAuthn passkeys (Face ID, fingerprint, security keys) for passwordless login or a 2nd factor. | better-auth: passkey |
twoFactor | TOTP authenticator apps plus backup codes for two-factor auth. | better-auth: 2FA |
magicLink | Passwordless sign-in by emailing a one-time login link. | better-auth: magic link |
emailOTP | Email a one-time numeric code to verify an address or sign in. | better-auth: email OTP |
phoneNumber | Phone-number sign-in and verification over SMS OTP. | better-auth: phone number |
username | A unique username as a login identifier alongside email. | better-auth: username |
anonymous | Throwaway guest sessions you can upgrade to a real account later. | better-auth: anonymous |
siwe | Sign-In with Ethereum (wallet-based auth). | better-auth: SIWE |
Accounts, orgs, and access
| Export | What it adds | Reference |
|---|---|---|
admin | Admin APIs: list and ban users, set roles, impersonate, revoke sessions. | better-auth: admin |
organization | Multi-tenant orgs with members, invitations, and roles. | better-auth: organization |
createAccessControl | Builder for custom roles and permissions (RBAC) passed into admin / organization. | better-auth: access control |
multiSession | Hold several signed-in accounts at once and switch between them. | better-auth: multi session |
customSession | Add custom fields to the session object returned to the client. | better-auth: session |
Tokens and machine clients
| Export | What it adds | Reference |
|---|---|---|
bearer | Accept the session token as an Authorization: Bearer header instead of a cookie (native / CLI clients). | better-auth: bearer |
jwt | Issue verifiable JWTs with a JWKS endpoint for services that expect a token. | better-auth: JWT |
oidcProvider | Turn your app into an OpenID Connect identity provider other apps log in with. | better-auth: OIDC provider |
genericOAuth | Add any OAuth2 / OIDC provider beyond the built-in social ones. | better-auth: generic OAuth |
oAuthProxy | Route OAuth callbacks through one stable URL (useful for preview / branch deploys). | better-auth: OAuth proxy |
oneTimeToken | Issue and verify single-use tokens (short-lived hand-off links). | better-auth: one-time token |
deviceAuthorization | OAuth 2.0 device grant for CLIs, TVs, and other input-constrained clients. | better-auth: device authorization |
mcp / withMcpAuth | OAuth-protect a Model Context Protocol server. Pairs with @lunora/mcp. | better-auth: MCP |
Abuse protection
| Export | What it adds | Reference |
|---|---|---|
captcha | Gate sensitive auth endpoints with Turnstile, reCAPTCHA, hCaptcha, or captchafox. See CAPTCHA / Turnstile. | better-auth: captcha |
haveIBeenPwned | Reject passwords found in the Have I Been Pwned breach corpus (k-anonymity check). | better-auth: HIBP |
captcha, mcp, and withMcpAuth have no dedicated better-auth/plugins/<name>
subpath. They ship via the better-auth/plugins barrel, and @lunora/auth/plugins
re-exports them from there.
Anything else better-auth ships, or a community plugin like Polar, keeps
working too: createAuth(...) passes plugins: through unchanged. For setup
and options on any plugin, follow its better-auth reference above.
Add org + admin
// lunora/auth.ts
import { createAuth, lunoraD1Adapter } from "@lunora/auth";
import { admin, organization } from "@lunora/auth/plugins";
export const buildAuth = (env: { AUTH_SECRET: string; DB: D1Database }) =>
createAuth({
database: lunoraD1Adapter(env.DB),
emailAndPassword: { enabled: true },
plugins: [organization({ allowUserToCreateOrganization: true }), admin({ defaultRole: "user" })],
secret: env.AUTH_SECRET,
});That's the whole change — the new tables (organization, member,
invitation, …) are discovered by ensureMigrated next time the worker
boots.
To define custom roles + permissions beyond the built-in user / admin,
use createAccessControl (re-exported from @lunora/auth/plugins) and pass the
resulting roles into admin({ ac, roles }) / organization({ ac, roles }) —
it's better-auth's standard access-control builder, unchanged.
CAPTCHA / Turnstile
There are two ways to add a Cloudflare Turnstile (or other CAPTCHA) check, and they cover different layers.
captcha plugin — for the auth flow
For the auth flow, prefer the captcha plugin. It hooks better-auth's
request pipeline directly and protects the sensitive endpoints
(/sign-up/email, /sign-in/email, /request-password-reset by default in
better-auth 1.6.18). It reads the token from the x-captcha-response request
header:
// lunora/auth.ts
import { createAuth, lunoraD1Adapter } from "@lunora/auth";
import { captcha } from "@lunora/auth/plugins";
export const buildAuth = (env: { AUTH_SECRET: string; DB: D1Database; TURNSTILE_SECRET_KEY: string }) =>
createAuth({
database: lunoraD1Adapter(env.DB),
emailAndPassword: { enabled: true },
plugins: [captcha({ provider: "cloudflare-turnstile", secretKey: env.TURNSTILE_SECRET_KEY })],
secret: env.AUTH_SECRET,
});Pass endpoints: [...] to override which routes are gated. The secret lives in
a plain env var / .dev.vars (conventionally TURNSTILE_SECRET_KEY) — Turnstile
has no Cloudflare binding.
Standalone helpers — for non-auth procedures
For non-auth procedures and mutations (where there's no better-auth
pipeline to hook), @lunora/auth ships standalone Turnstile helpers from the
package root:
verifyTurnstile({ secret, token, remoteip?, fetch? })— a pure server-sidesiteverifyhelper, usable from any mutation/action. Asuccess: false(bot / invalid / expired) verdict is returned, not thrown; it only throws a structuralLunoraErroron transport failure (network error / non-2xx).verifyTurnstileMiddleware({ secret, token, ... })— Lunora procedure middleware (.use()) that gates any procedure on a Turnstile check. It reads the token fromctxvia thetokenselector (the token travels in the functionargs, since the procedure context carries no rawHeaders). Fails closed by default; passfailOpen: trueto admit on a siteverify outage.
// lunora/contactForm.ts — gate a plain mutation, no auth involved
import { mutation } from "@/lunora/_generated/server";
import { verifyTurnstileMiddleware } from "@lunora/auth";
import { v } from "lunorash/values";
export const submit = mutation
.input({ message: v.string(), turnstileToken: v.string() })
// The procedure context carries no raw Headers, so the token rides in args
// and the selector reads it back off ctx once it's been surfaced there.
.use(
verifyTurnstileMiddleware({
secret: env.TURNSTILE_SECRET_KEY,
token: (ctx) => ctx.turnstileToken,
}),
)
.mutation(async ({ args }) => {
/* args.message has passed the Turnstile check */
});Use the captcha plugin for the auth flow. It hooks better-auth's pipeline and reads the token from the x-captcha-response header. The standalone
helpers above are for non-auth procedures, where you hand them the token explicitly — so reach for them only outside the sign-in/sign-up flow.
Studio user dashboard
The studio ships a full user-management dashboard —
browse/search users, set roles, ban, revoke sessions, impersonate, create and
delete, plus an Organizations section and per-user linked-accounts / 2FA /
passkey panels. Wiring it is one line: hand createWorker an authAdmin
built from your auth instance.
// src/server/index.ts
import { createAuthAdmin } from "@lunora/auth";
createWorker({
shardDO: env.SHARD,
adminToken: env.LUNORA_ADMIN_TOKEN, // the dashboard's authorization gate
authAdmin: createAuthAdmin(auth), // backed by better-auth's adapter
});The dashboard follows your plugins
There is no studio-side switch — the plugins you enable in createAuth(...)
are the switch. createAuthAdmin reads the enabled plugin set from
auth.$context, the worker exposes a GET /_lunora/admin/auth/capabilities
endpoint, and the studio renders only the surfaces that apply:
Enable in createAuth(...) | Dashboard surface that appears |
|---|---|
admin() | User actions: role, ban/unban, set password, impersonate, create, delete |
organization() | The Organizations section (orgs → members → invitations) |
twoFactor() | "Disable two-factor" in the user detail drawer |
passkey() | The user's passkeys (list + revoke) in the detail drawer |
(always — core account table) | Linked accounts (providers + unlink) |
Turn a plugin off and its surface disappears (the Organizations tab shows an
"Organizations are not enabled" empty state instead of erroring). Auth-method
plugins that have no admin entity — magicLink, emailOTP, username,
phoneNumber, … — need no dedicated panel; they show up through the user's
fields and linked accounts.
Force a surface off
To hide a surface even when its plugin is enabled, pass a features override:
authAdmin: createAuthAdmin(auth, { features: { organization: false } }),Safe by construction
Every /_lunora/admin/auth/* endpoint is gated by LUNORA_ADMIN_TOKEN, and
createAuthAdmin is a trusted server-side operator — it talks to better-auth's
adapter directly, so it is not an end-user API. Password hashes, session
tokens, and OAuth access/refresh tokens are stripped from every response; the
only token ever returned is the one from an explicit Impersonate.
authIntrospector option still works (browse-only) but is deprecated — prefer authAdmin.The withAuthPlugins middleware
@lunora/auth/middleware exports a Lunora-style middleware factory that
surfaces every plugin endpoint on ctx.authApi:
import { httpAction } from "lunorash/server";
import { withAuthPlugins } from "@lunora/auth/middleware";
import { auth } from "./auth.js";
// Compose once with .use(...) and every handler downstream sees authApi.
export const createOrg = httpAction(async (ctx, request) => {
const { name } = await request.json();
const org = await ctx.authApi.createOrganization({
body: { name, slug: name.toLowerCase().replace(/\s+/g, "-") },
headers: request.headers,
});
return Response.json(org);
});ctx.authApi is typed against the plugin set loaded on the auth instance —
createOrganization, inviteMember, listMembers, banUser,
impersonateUser, etc. all show up automatically.
Why callers still pass headers
Lunora's procedure context carries the resolved identity (ctx.auth.userId,
ctx.auth.getIdentity()) but not the raw inbound Headers. Better-auth
endpoints need the headers to read the caller's session cookie, so the
middleware does not pre-bind them — pass them through from the transport
that has them:
- HTTP action —
request.headers(as shown above). - WebSocket subscription — propagated from the upgrade request.
- Server-to-server (cron job, queue consumer) — pass
new Headers()and authenticate via a bearer token or service-key plugin instead.
Migrations
ensureMigrated(auth) does a schema diff and applies missing DDL on the
configured database — fine for dev and small deployments:
// src/server/index.ts
import { ensureMigrated, handleAuthRequest } from "@lunora/auth";
import { buildAuth } from "../../lunora/auth.js";
export default {
async fetch(request, env, ctx) {
const auth = buildAuth(env);
await ensureMigrated(auth); // idempotent, cached per options
const authResponse = await handleAuthRequest(auth, request);
if (authResponse) return authResponse;
return worker.fetch(request, env, ctx);
},
};For production, prefer pre-applying the schema at deploy time:
node -e "
import('@lunora/auth').then(async ({ compileMigrationsSql }) => {
const sql = await compileMigrationsSql({
database: undefined, // schema-only, no live DB
secret: 'placeholder',
plugins: [/* same list as runtime */],
});
console.log(sql);
});
" > schema.sql
wrangler d1 execute my-db --file schema.sqlThe migration runner discovers every loaded plugin's tables, so adding
organization() / admin() / Polar / etc. picks them up for free.
Full demo
The examples/auth-playground
app shows the whole flow end-to-end: sign-up, create org, invite member,
admin-only ban panel.
Polar billing
Intentionally not bundled. If you want Polar in the mix:
pnpm add @polar-sh/better-authnpm install @polar-sh/better-authyarn add @polar-sh/better-authbun add @polar-sh/better-auththen add polar(...) to the plugins: array in your createAuth(...)
call. ensureMigrated will pick up Polar's tables on the next boot.