Last updated:
Authentication in Lunora is two halves that meet at ctx.auth. On the server,
@lunora/auth resolves the inbound session and stamps a
verified identity onto every function context. In your functions you read that
identity off ctx.auth (the same shape inside a query, a mutation, and an
action) and use it to authorize work. Nothing else in a handler needs to know
how the caller proved who they are.
import { mutation, v } from "@/lunora/_generated/server";
export const send = mutation.input({ channelId: v.id("channels"), text: v.string() }).mutation(async ({ ctx, args }) => {
if (!ctx.auth.userId) throw new Error("must be signed in");
await ctx.db.insert("messages", { ...args, userId: ctx.auth.userId });
});ctx.auth inside functions
Every function context (QueryCtx, MutationCtx, and ActionCtx) carries an
auth handle with the resolved caller:
ctx.auth.userId: the verified user id, ornullwhen the request is anonymous. This is the value to branch on for "is someone signed in?".ctx.auth.getIdentity(): resolves the raw identity claims (the better-auth session payload) as a record, ornullwhen unauthenticated. Reach for it when you need more than the id (email, name, roles, custom claims).
import { query } from "@/lunora/_generated/server";
export const me = query.query(async ({ ctx }) => {
if (!ctx.auth.userId) return null;
const identity = await ctx.auth.getIdentity();
return { id: ctx.auth.userId, email: identity?.email };
});The identity is server-resolved and trusted; it is not a value the client
hands you in args. On an HTTP call it comes from the session the request
carries. On a live subscription it is the identity stamped at the WebSocket
upgrade and replayed on every re-run, so a long-lived socket can never drift to a
different user (see real-time).
@lunora/auth on the server
@lunora/auth is built on better-auth: createAuth
configures a better-auth instance and Lunora forwards requests to it. Out of the
box that gives you:
- Email / password: better-auth's scrypt hashing, which runs on Workers with
no Node polyfills. Swap the algorithm by passing
emailAndPassword.password: { hash, verify }tocreateAuth. - OAuth: GitHub and Google social providers (real code → token → userinfo
exchanges, with
id_tokensignatures verified against the provider's JWKS), plus any other provider viagenericOAuth. - Passkeys / WebAuthn, 2FA, magic-link, email-OTP, admin, organization, and
more: curated better-auth plugins re-exported from
@lunora/auth/plugins, so you don't chase deep import paths. - Invite-only sign-up:
inviteOnly()closes self-serve registration to addresses an administrator invited, on every path that mints a user — read the security section before you rely on it, because an invitation is keyed by email address and carries no secret token. See invite-only sign-up.
// lunora/auth.ts
import { createAuth } from "@lunora/auth";
import { passkey } from "@lunora/auth/plugins";
export const buildAuth = (env: { AUTH_SECRET: string; DB: unknown }) =>
createAuth({
database: env.DB as never,
emailAndPassword: { enabled: true },
plugins: [passkey()],
secret: env.AUTH_SECRET,
});User and session records live in D1 — better-auth's session table, through
the D1 adapter. (SessionDO is a standalone TTL'd token store that ships with
@lunora/do; @lunora/auth has never called it, so binding it gets you a
correctly-configured object nothing uses. Back up and export the auth database,
not that DO.) The Durable Object path for auth is LunoraAuthDO /
.auth({ namespace }), which puts the whole better-auth schema in one object
with real transactions. The full configuration surface (providers, rate
limiting, migrations, the studio user dashboard) is in the
package reference.
Side effects on your own tables
better-auth's databaseHooks receive the row and better-auth's own context —
never a Lunora MutationCtx, and there is no seam to add one. The hook runs
inside better-auth's write path, and a ctx is per-request state that createAuth
(called once, at worker setup) does not have.
So a side effect on an app table is a separate write, dispatched as an internal mutation over the shard client. That is the part worth designing for: a failure between the auth write and yours must leave a state the next attempt can repair, so key the mutation on the user id and make re-running it a no-op rather than assuming it runs exactly once.
databaseHooks: {
user: {
create: {
after: async (user) => {
// Idempotent by construction: upsert on the user id, never insert.
await client.mutation(internal.profiles.ensureForUser, { userId: user.id });
},
},
},
},lunoraDoAdapter is the only configuration where the auth writes are themselves
transactional (a Durable Object has real transactions; D1 does not) — and even
there your app-table write sits outside that transaction, so the idempotency
requirement does not go away.
Routing /api/auth/*
handleAuthRequest(auth, request) is the single entry point that routes every
auth endpoint under /api/auth/* (sign-up, sign-in, OAuth callbacks, session
refresh, and each enabled 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:
// src/server/index.ts
import { ensureMigrated, handleAuthRequest } from "@lunora/auth";
import { buildAuth } from "../../lunora/auth";
export default {
async fetch(request, env, ctx) {
const auth = buildAuth(env);
await ensureMigrated(auth);
const authResponse = await handleAuthRequest(auth, request);
if (authResponse) return authResponse;
return worker.fetch(request, env, ctx);
},
};Because the routes are mounted, the browser talks to better-auth directly. Your
functions never implement sign-in; they only read the resolved ctx.auth.
Two things about ensureMigrated, both of which bite exactly once:
It drives better-auth's own migrator, which speaks only Kysely, so it needs
the raw D1 binding as database — as above. Hand it a Lunora adapter
(lunoraD1Adapter, lunoraAuthAdapter, lunoraDoAdapter) and it throws
AUTH_MIGRATOR_UNSUPPORTED. Build a second, migration-only instance over the raw
binding and keep the adapter on the one that serves requests; the adapter exists
to dodge a dev-runner hang in $context, which the migration instance never
resolves.
And call it once per isolate, holding the promise rather than a flag, because
better-auth emits a bare CREATE TABLE: two concurrent runs against a fresh
database leave the loser with table user already exists, and since this sits
ahead of the router that 500s every route. defineApp().auth(...) does both for
you.
better-auth owns those tables. Don't also declare user / session / account
/ verification / rateLimit in lunora/schema.ts — two owners for one table
means Lunora creates it with its own column layout and better-auth then plans an
ALTER TABLE … ADD COLUMN … NOT NULL that SQLite rejects outright. Lunora's
.global() tables are for your data.
The client side
On the client, identity is a token carried on the shared LunoraClient. After a
sign-in flow against /api/auth/* you hand the resulting token to the client and
every subsequent RPC carries it in the Authorization header.
In React, @lunora/react's useAuth wraps this:
import { useAuth } from "@lunora/react";
function Account() {
const { user, token, setToken } = useAuth();
if (!user) return <SignInForm onToken={setToken} />;
return (
<div>
<span>{user.email}</span>
<button onClick={() => setToken(null)}>Sign out</button>
</div>
);
}setToken(jwt) makes every later query/mutation/subscription authenticate as
that user; setToken(null) signs out. user is resolved from better-auth's
get-session endpoint and refetched whenever the token changes. The value is
shared across every mounted useAuth, so a sign-in in one component re-renders
the rest. Outside React, the underlying primitives are
client.setAuthToken(token) / getAuthToken() / onAuthTokenChange(fn) on the
LunoraClient.
Feeding row-level security
ctx.auth is also the input to authorization. An ad-hoc if (!ctx.auth.userId)
check works for one-off guards, but the systematic answer is
row-level security: a policy's when(...) receives
auth.userId, auth.roles, and auth.can(permission) and returns a predicate
that decides which rows a procedure may read or write.
import { definePolicy } from "./_generated/server";
// "you only ever see messages you sent"
definePolicy({ table: "messages", on: "read", when: ({ auth }) => ({ userId: auth.userId }) });Wiring the same resolved identity into RLS keeps authorization out of every handler body and applies it uniformly, including over live queries, which re-evaluate the policy under the socket's verified identity on each push.
See also
- Queries & mutations: the
ctxshape these functions run under. - Row-level security: turning
ctx.authinto per-row authorization. - Real-time: how identity is stamped on a live socket.
- @lunora/auth: full server configuration.
- @lunora/auth plugins: passkeys, org, admin, 2FA, and more.