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, 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 — PBKDF2-SHA-256 hashing that runs in WebCrypto, no Node polyfills.
- 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.
// 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 records live in D1; session lifecycle lives in a dedicated SessionDO. The
full configuration surface (providers, rate limiting, migrations, the studio
user dashboard) is in the package reference.
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.
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.