Skip to content
DocsconceptsDocumentation

Authentication

How ctx.auth threads the signed-in user through queries, mutations, and actions — and feeds row-level security.

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, or null when 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, or null when 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 } to createAuth.
  • OAuth: GitHub and Google social providers (real code → token → userinfo exchanges, with id_token signatures verified against the provider's JWKS), plus any other provider via genericOAuth.
  • 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 without requireEmailVerification whoever 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.

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 every LunoraClient in the page themselves.
  • createLunoraAuthClient installs lunoraSessionSync(), a better-auth client plugin that does the same after every auth request that can change the session. If you call createAuthClient yourself, 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