Authentication
How Valcraven handles sign-up, login, OAuth, two-factor, and sessions with Better Auth — plus the user table, custom fields, and the admin plugin.
Authentication
Authentication in Valcraven is built on Better Auth. The server instance lives in apps/web/lib/auth.ts and the browser client in apps/web/lib/auth-client.ts. It's already wired end to end: email/password, social OAuth, email verification, password reset, two-factor, and admin impersonation all work out of the box against your database (SQLite in dev, D1 or Postgres in prod).
This is not custom JWT and not NextAuth. If you've used an older Valcraven clone, note the migration to Better Auth (Feb 2026). The database table is
user(singular), notusers.
What's included
- Email/password sign-up and login (minimum password length 8)
- OAuth with Google, GitHub, and Apple (Facebook is also wired in the config)
- Two-factor authentication (TOTP — works with Google Authenticator, Authy, 1Password)
- Email verification, sent automatically on sign-up
- Password reset (forgot-password → emailed link → reset form)
- Session cookies (httpOnly, secure, managed by Better Auth)
- Account linking across providers
- An admin plugin that powers role gating and user impersonation
- Native mobile support via the Better Auth Expo plugin
Checking the session on the server
Every API route and server component reads the session the same way — pass the request headers to auth.api.getSession. Always scope data to session.user.id; never trust a user id from the request body.
import { auth } from "@/lib/auth";
export async function GET(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return new Response("Unauthorized", { status: 401 });
const userId = session.user.id;
const email = session.user.email;
const plan = session.user.plan; // "free" | "pro" | "lifetime"
// ...
}
lib/auth.tsis server-only — never import it into a client component. Uselib/auth-client.tson the client.
Client-side auth
authClient (from @/lib/auth-client) is a React client. Use it for sign-up, login, OAuth, sign-out, and 2FA in client components.
import { authClient } from "@/lib/auth-client";
// Sign up
await authClient.signUp.email({ email, password, name });
// Sign in
await authClient.signIn.email({ email, password });
// OAuth (provider: "google" | "github" | "apple")
await authClient.signIn.social({ provider: "google", callbackURL: "/app" });
// Sign out
await authClient.signOut();
// Reactive session in a component
const { data: session } = authClient.useSession();
// Two-factor
await authClient.twoFactor.enable({ password });
await authClient.twoFactor.verifyTotp({ code });Route protection
apps/web/middleware.ts gates /app/* and /settings/*. It uses getSessionCookie() from better-auth/cookies (a fast cookie presence check — no DB round-trip) and redirects unauthenticated visitors to /auth?tab=login.
import { getSessionCookie } from "better-auth/cookies";
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
return NextResponse.redirect(new URL("/auth?tab=login", request.url));
}The cookie check confirms a session exists; it does not validate it. Always do the full
auth.api.getSession(...)check inside the API route or server component that actually reads user data.
OAuth providers
Social providers are configured in lib/auth.ts under socialProviders. Each provider activates only when you set its client id and secret; unset providers stay dormant. Account linking is enabled, so a user who signs in with Google and later with GitHub (same email) ends up on one account.
| Provider | Env vars |
|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | |
| GitHub | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
| Apple | APPLE_CLIENT_ID, APPLE_CLIENT_SECRET |
FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET |
The redirect/callback URLs must be registered with each provider for both your production URL and your local dev URL, or login fails on whichever isn't registered. The /configure-sso Claude Code skill automates most of this setup.
Email verification and password reset
Both flows are enabled in lib/auth.ts and delegate to the email helpers in lib/email.ts:
- Verification —
sendOnSignUp: truesends a verification email on every sign-up, andautoSignInAfterVerification: truesigns the user in once they click the link. Note thatrequireEmailVerificationis false by default, so unverified users can still sign in — flip it if your app needs verified email before access. - Password reset —
sendResetPasswordfires the reset email; the user follows the link to a reset form.
See Email for how the underlying transactional email is sent.
Two-factor authentication
The twoFactor plugin (server) and twoFactorClient (client) provide TOTP-based 2FA. Enabling requires the user's password; verification takes a 6-digit code from an authenticator app. The twoFactorEnabled flag lives on the user row, and secrets/backup codes live in the twoFactor table. Enable/disable actions are recorded to the audit log as auth.2fa_enable / auth.2fa_disable.
The user table and custom fields
The auth schema (lib/auth-schema.ts) defines the core Better Auth tables: user, session, account, verification, and twoFactor. On top of the standard columns, Valcraven declares app-specific fields on user via additionalFields in lib/auth.ts. These are camelCase and marked input: false (they can't be set by the client at sign-up):
| Field | Purpose |
|---|---|
plan | Billing plan, defaults to "free" |
stripeCustomerId | Stripe customer id |
stripeSubscriptionId | Stripe subscription id |
subscriptionStatus | e.g. inactive, active, past_due |
The session user object also exposes id, email, name, image, and emailVerified. The typed Session and User types are shared from @valcraven/core and re-exported by lib/auth-client.ts.
The admin plugin owns a separate set of columns —
role,banned,banReason,banExpiresonuser, andimpersonatedByonsession. Those are not declared inadditionalFields; the plugin manages them. See below.
Admin plugin and impersonation
lib/auth.ts registers the Better Auth admin() plugin (mirrored by adminClient() on the client). It adds a role column used to gate admin-only endpoints and enables impersonation — an admin can sign in as another user to debug support issues:
// Client-side (admin only)
await authClient.admin.impersonateUser({ userId });
await authClient.admin.stopImpersonating();Impersonation sessions auto-expire after 30 minutes, and destructive/billing actions (change password, change email, delete account, Stripe checkout/portal, enable/disable 2FA) are blocked while impersonating. Valcraven's source of truth for "is this user an admin" is the integer isAdmin column; the plugin's role is kept in lockstep (role = "admin" wherever isAdmin = 1). The full mechanics — guardrails, the impersonation banner, and the audit contract — are covered on the Admin panel page and in the dev wiki at /admin/docs/impersonation.
Auth-triggered audit events
Better Auth hooks in lib/auth.ts write security events to the audit log automatically:
auth.login— on every new session (email/password, OAuth, or 2FA completion)auth.logout— on sign-outauth.password_change,auth.2fa_enable,auth.2fa_disable,user.update
See /admin/docs/audit-log for the full event catalog.
Environment variables
| Variable | Required | Notes |
|---|---|---|
BETTER_AUTH_SECRET | Yes | Signing secret for sessions/cookies |
BETTER_AUTH_URL | Yes | The app's base URL (used as a trusted origin) |
ADMIN_EMAILS | Optional | Comma-separated emails auto-promoted to admin on sign-up |
| OAuth client id/secret pairs | Optional | Per provider (see table above) |
In development, Better Auth also trusts localhost, 127.0.0.1, 0.0.0.0 (on DEV_PORT) and any *.ts.net Tailscale hostname, so localhost and phone/tailnet QA both work without swapping BETTER_AUTH_URL.
Where to look
| Concern | File |
|---|---|
| Server auth instance, plugins, hooks | apps/web/lib/auth.ts |
| Browser client | apps/web/lib/auth-client.ts |
| Auth table bootstrap SQL | apps/web/lib/auth-schema.ts |
| Route gating | apps/web/middleware.ts |
| Admin/RBAC helpers | apps/web/lib/admin.ts, apps/web/lib/rbac.ts |
Related
- Admin panel — roles, RBAC, impersonation, and the audit log
- Billing — how
planandsubscriptionStatusare set - Email — the verification and reset email delivery