Auth
Better Auth, configured by the Toolkit with email/password and the password-reset
flow (reset emails go through the mailer). The app mounts one
route — /api/auth/$ — and everything else is function calls.
The session choke point
Every server function and route guard reads the session through one function. That is also where deactivation is enforced — live code, the whole module:
import { getRequestHeaders } from "@tanstack/react-start/server";
import { verifyApiToken } from "./api-token";
import { auth } from "./server";
/**
* Returns the current session (if any). Safe to call inside TanStack Start
* server functions or beforeLoad guards. Returns `null` for anonymous users.
*
* Two ways to be authenticated, resolved here so the rest of the app never
* cares which: the Better Auth cookie (browsers), or an `Authorization:
* Bearer sk_…` API token (scripts, CI, integrations — see api-token.ts).
*
* Deactivated users (admin set `deactivatedAt` — Django's `is_active=False`)
* are treated as anonymous at this single choke point every server function
* and route guard goes through: deactivation locks out existing sessions and
* tokens immediately, not just future sign-ins.
*/
export async function getSession() {
const headers = new Headers(getRequestHeaders());
const session = await auth.api.getSession({ headers });
if (session) {
if ((session.user as { deactivatedAt?: Date | null }).deactivatedAt) return null;
return session;
}
// No cookie session — try an API token. Shaped like a Better Auth session
// so callers (requireSession, requireSiteAdmin) are identical either way.
const tokenUser = await verifyApiToken(headers.get("authorization"));
if (tokenUser) {
return { user: tokenUser, session: null } as unknown as NonNullable<typeof session>;
}
return null;
}
/**
* Throws if no session — use inside server functions that require auth.
* The thrown Response is converted to a redirect by TanStack Start.
*/
export async function requireSession() {
const session = await getSession();
if (!session) {
throw new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
return session;
}Two Django parallels worth internalizing:
requireSession()≈login_required— but it is ordinary code that throws aResponse(302 to/login), no decorator machinery.deactivatedAt≈is_active=False— and because the check lives in the choke point, existing sessions are locked out on the next request, not just future sign-ins.
What you get out of the box
- Email/password sign-up, sign-in, sign-out.
- Password reset:
requestPasswordReset→ email with a tokenized link (lands in the outbox until SMTP is configured) → reset page sets the new password. - Session cookies handled by the framework integration (secure, SameSite).
- The four tables in the foundation schema — created by your normal migrations, no separate auth migration step.
The client side is a hook away:
import { authClient } from "@repo/auth/client";
const { data: session } = authClient.useSession();
await authClient.signIn.email({ email, password });
await authClient.signOut();Site admin
user.isSiteAdmin is the coarse staff flag (Django's is_staff): one boolean,
checked by requireSiteAdmin() in the consumer, gating the /admin surface and its
server functions. Finer-grained object permissions are the consumer's concern — the
example uses capability groups + typescript-rules for that layer.
API tokens (Knox / DRF-token)
Browsers authenticate with the session cookie; scripts, CI and integrations
authenticate with a personal access token. The Knox / DRF-token replacement is a
small module on the auth core: mint a token (the raw sk_live_… value is shown
once, only its SHA-256 hash is stored), then send it as a header:
Authorization: Bearer sk_live_…
Verification happens at the same getSession() choke point as the cookie, so
every gated server function and API route accepts a token with no per-endpoint
wiring — the equivalent of adding TokenAuthentication to DRF's
DEFAULT_AUTHENTICATION_CLASSES. A revoked, expired, or deactivated-owner token is
rejected exactly like a logged-out cookie.
/**
* Resolve a raw `Authorization` header value to a user, or null. Rejects
* malformed, unknown, revoked, expired and deactivated-owner tokens — the
* same anonymity rule as the cookie path. Bumps `lastUsedAt` on success
* (best-effort). The hash lookup is constant-work; the timing-safe compare
* guards the unique-hash equality.
*/
export async function verifyApiToken(authorizationHeader: string | null): Promise<User | null> {
if (!authorizationHeader) return null;
const match = /^Bearer\s+(sk_live_[A-Za-z0-9_-]+)$/u.exec(authorizationHeader.trim());
if (!match) return null;
const raw = match[1]!;
const wantHash = hashToken(raw);
const row = await db.query.apiToken.findFirst({
where: and(eq(apiToken.tokenHash, wantHash), isNull(apiToken.revokedAt)),
});
if (!row) return null;
// Defense in depth against a hash collision / lookup quirk.
const a = Buffer.from(row.tokenHash);
const b = Buffer.from(wantHash);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null;
const owner = await db.query.user.findFirst({ where: eq(user.id, row.userId) });
if (!owner || owner.deactivatedAt) return null;
// Best-effort usage stamp; never block the request on it.
void db
.update(apiToken)
.set({ lastUsedAt: new Date() })
.where(eq(apiToken.id, row.id))
.catch(() => {});
return owner;
}Tokens are user-scoped and self-service (mint / list / revoke), every action is
audited, and /api/me is the canonical "does my token work?" endpoint. Try it on
/sandbox/api-tokens; the block spec mints a token in the UI, calls /api/me from
a cookie-less context with the Bearer header, and confirms revocation kills it.
Object & row permissions
isSiteAdmin is the coarse gate; finer access is object/row permissions via
@tikab-interactive/typescript-rules — the
django-guardian / django-rules counterpart. The trick: one predicate yields both
a single-object guard (check) and a list filter (where), so a rule can't drift
between "may I open this?" and "what may I list?".
The example registers rules for projects and the project-scoped entities below them (tasks, attachments) — each derived from project membership, so a new entity is a handful of lines, not a new model:
const viewTask = drizzlePredicate<RuleUser, Task, typeof task>("view_task", {
table: task,
check: async (u, row) => u.isSiteAdmin || (await isProjectMember(u.id, row.projectId)),
where: (u) => (u.isSiteAdmin ? sql`true` : inArray(task.projectId, memberProjectIds(u.id))),
});
const changeTask = drizzlePredicate<RuleUser, Task, typeof task>("change_task", {
table: task,
check: async (u, row) => u.isSiteAdmin || (await isProjectAdmin(u.id, row.projectId)),
where: (u) => (u.isSiteAdmin ? sql`true` : inArray(task.projectId, adminProjectIds(u.id))),
});check guards a server function; where drops straight into a Drizzle query to
scope a list. /sandbox/rules shows the outcome for the signed-in user — its spec
proves a member sees their own project's tasks and is denied another's.
GDPR / data-subject rights
The data-subject obligations are plain server functions (gdpr-server.ts):
- Right of access / portability —
exportMyDatagathers every row tied to the user (memberships, comments, uploads, assignments, AI conversations, audit entries, token metadata…) into one JSON document;adminExportUserDatadoes the same for a subject-access request, audited. - Right to erasure —
deleteMyAccountdeletes the user row after an email confirmation. The schema does the cleanup:onDelete: cascaderemoves owned rows, whileset nullFKs (audit actor, attachment uploader, mailbox recipient) keep shared history intact but anonymized. It refuses the last remaining site admin so erasure can't lock out all administration.
/sandbox/gdpr exercises both; the block spec signs up a throwaway user, exports
(asserting the bundle carries their email), then erases and confirms the credentials
no longer sign in.