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. The invitation carries a 256-bit secret token, so read the security section before you rely on it: the link is a bearer credential anyone who receives it can spend, and withoutrequireEmailVerificationwhoever spends it holds a session immediately. 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.
// `shard` is a createShardClient(env.SHARD).forShard(user.id) handle.
await shard.call(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 { setToken, status, user } = useAuth();
if (status === "loading") return <Spinner />;
if (status === "unauthenticated") return <SignInForm onToken={setToken} />;
return (
<div>
<span>{user?.email}</span>
<button onClick={() => setToken(null)}>Sign out</button>
</div>
);
}Branch on status, not on whether user is null. user is null both when
there is no session and when a credential is held whose identity has not
resolved — a reload while offline, or the window while a cookie session
resolves. Gating on user renders the sign-in form to a signed-in user in both
cases. The four states and what each one means are tabulated in the
@lunora/client reference; unreachable is the one
worth knowing about, and it gates as authenticated.
setToken(jwt) stamps the bearer on every later query, mutation and action;
setToken(null) signs out. It does not re-authenticate an open
subscription: the WebSocket credential is fixed at upgrade time and lives in the
URL, so rotating it is client.setWsToken(token), which closes the shard
sockets to force a reconnect. 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.
Cookie sessions: how the client learns the session changed
With a cookie session (better-auth's default, and what @lunora/auth-ui uses)
the client holds no token, so signing out or signing in as someone else changes
nothing it can see. It learns who is signed in from the server: from
/get-session, and from the identity frame every socket sends when it
connects. Any change it hears about (a sign-out, a sign-in after a sign-out, a
different user) retires the previous session. Every live query is blanked, the
sockets reconnect on the new cookie, and @lunora/react clears its query cache.
A sign-in or sign-out in place, without a reload, is reported for you:
@lunora/auth-ui's flows tell everyLunoraClientin the page themselves.createLunoraAuthClientinstallslunoraSessionSync(), a better-auth client plugin that does the same after every auth request that can change the session. If you callcreateAuthClientyourself, add it:
import { lunoraSessionSync } from "@lunora/auth/plugins/client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({ plugins: [lunoraSessionSync()] });The report also reaches the browser's other tabs on the same origin. Each one re-checks the session without reloading, and retires the previous user's rows the same way. A sign-out made through better-auth's own client in another same-origin tab is picked up even without the plugin.
A tab on a different origin that shares the cookie (for example
admin.example.com beside app.example.com under a parent-domain cookie) is
not told: browsers scope cross-tab messages to one origin. It switches identity
when its socket next reconnects. Its offline writes stay safe meanwhile,
because the worker refuses a replay whose cookie now belongs to someone else.
The same applies to a session changed any other way, such as a custom endpoint.
Call notifyLunoraSessionChange() from @lunora/auth/plugins/client to report
it sooner.
/get-session answering 401 or with no user counts as signed out. Any other
failure, including a 403 from a WAF or bot challenge in front of the auth
route, is not a verdict on anyone. The client keeps the identity it has.
Offline writes cannot change hands. A write replayed under a cookie session
names the user who queued it (x-lunora-expect-subject), and the worker
refuses it with 409 IDENTITY_MISMATCH when the cookie now belongs to someone
else. For that check to work, your worker's resolveIdentity must return the
same userId as better-auth's user.id, and RPC requests must carry the
session cookie (a cross-origin client needs credentials: "include").
Otherwise every replay is refused, and the client logs a one-time warning that
names this cause.
If you set security.cors.allowedHeaders, your list is added to the headers the
client sends on its own. It no longer replaces them, so a list written before a
new client header existed cannot block that header.
Expired credentials
When the identity your resolveIdentity returns carries an exp (JWT epoch
seconds) or expiresAtMs (epoch milliseconds) that has already passed, the
worker answers 401 TOKEN_EXPIRED and nothing runs. This covers /_lunora/rpc,
/_lunora/rpc-batch, the REST surface and serverQuery. A live subscription
gets the same code as a socket close (4001) once its credential lapses.
@lunora/client treats the code as a refusal of the credential, not of the
write: a queued offline write stays queued, onTokenExpired fires, and the
write is sent again after setAuthToken.
The held write survives the refresh only if the identity is keyed on a
subject. Pass the user id with the new token (or have passed it before; the
subject is sticky):
client.onTokenExpired(async () => {
const { token, userId } = await refreshSession();
client.setAuthToken(token, userId); // same subject: the held writes are kept
});Without a subject the identity is a hash of the token, so a refresh looks the
same as switching accounts, and the held writes are rejected
OFFLINE_IDENTITY_CHANGED rather than sent as someone else. The client logs a
warning when this happens right after a refusal.
A cookie session, such as a Cloudflare Access edge, has no token to
replace. Renew the cookie, and the held write is sent again on a backoff with
whatever cookie the browser then holds. In @lunora/db that backoff runs for
the same one-minute window a bearer refresh gets.
The worker can only answer this if your resolver tells it the credential
expired. Returning null for an expired token makes the call anonymous, and
the UNAUTHORIZED your own check then throws reads to the client as a verdict
on the write. For a token you verified that has only expired, do one of these:
// Return the identity with its past expiry: the worker refuses it.
return { exp: payload.exp, userId: payload.sub };
// Or refuse it yourself.
throw new LunoraError("token expired", { code: "TOKEN_EXPIRED", status: 401 });@lunora/cloudflare-access already does the first for a correctly signed
Access JWT whose only fault is its exp. The better-auth session resolvers
cannot tell an expired session from no session (getSession answers null
for both), so a lapsed cookie session still reads as signed out.
An httpRouter route does not get the refusal, because its context is built
for every page, including the sign-in page. There a lapsed identity reads as
anonymous (auth.userId is null), and a route that needs a caller answers
its own 401. The admin routes ignore the user's expiry, because the admin
credential authorizes them.
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.