Authentication & Sessions
VeloCMS has two authentication systems that must never be conflated (P-022). Admin auth handles blog owners — the people who write posts, configure themes, and manage billing. Member auth handles readers — subscribers who log in to read gated content. They use different PocketBase collections, different session cookies, and different verification flows. Mixing them is a silent security bug.
Two auth systems at a glance
| Property | Admin auth | Member auth |
|---|---|---|
| PocketBase collection | users | blog_members |
| Session cookie | pb_auth (set by PB SDK) | pb_member_auth (custom JSON cookie) |
| Login method | Email + password | Magic link (passwordless) |
| Protected routes | /admin/* | /member/* |
| Source file | src/lib/auth/actions.ts | src/lib/auth/magic-link.ts |
Admin auth flow
Blog owners sign up at /signup with email + password. VeloCMS creates a users record in PocketBase and sends a verification email. After email verification, the owner can log in at /login. The loginAction server action calls PocketBase's users.authWithPassword(), which sets the pb_auth cookie via the PocketBase JS SDK. Every subsequent server-rendered page calls getSession() from src/lib/auth/session.ts to read and validate this cookie.
import { cookies } from "next/headers";
import PocketBase from "pocketbase";
export async function getSession() {
const cookieStore = await cookies();
const authCookie = cookieStore.get("pb_auth");
if (!authCookie?.value) return null;
const pb = new PocketBase(process.env.POCKETBASE_URL);
pb.authStore.loadFromCookie(`pb_auth=${authCookie.value}`);
if (!pb.authStore.isValid) return null;
return {
user: pb.authStore.record,
token: pb.authStore.token,
};
}Member magic link flow
Readers do not have passwords — they authenticate via magic link. The flow starts when a visitor enters their email on a /member/login page. sendMagicLink() in src/lib/auth/magic-link.ts creates or retrieves a blog_members record, generates a random 32-byte token, stores its SHA-256 hash in the verification_tokens collection, and emails the raw token to the reader. Clicking the link opens /member/verify, which shows a confirmation card. The reader clicks 'Sign in', which POSTs to verifyAction. The server hashes the incoming token, looks it up in verification_tokens, verifies expiry, sets the pb_member_auth cookie, and deletes the token.
import { randomBytes } from "node:crypto";
import { hashToken } from "./hash-token";
// TOKEN_EXPIRY_MS = 15 minutes
const rawToken = randomBytes(32).toString("hex");
const tokenHash = hashToken(rawToken); // SHA-256 hex
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_MS).toISOString();
// Store HASH in DB — raw token travels in the email URL only
await pb.collection("verification_tokens").create({
token: tokenHash,
email,
type: "member_login",
expires_at: expiresAt,
signup_metadata: { tenant_id: tenantId, member_id: member.id },
});
// Email contains the RAW token — never the hash
const signInUrl = `${siteUrl}/member/verify?token=${encodeURIComponent(rawToken)}`;Session cookie model
The pb_member_auth cookie is a JSON object containing { id, email, tenant_id, tier }. It is httpOnly, sameSite=lax, secure in production, and expires after 30 days. The cookie is a hint — not the source of truth for access gating. getMemberSession() live-reads blog_members.tier from the database on every call and refreshes the cookie if it has drifted (e.g. after a Stripe webhook upgraded the member). If PocketBase is unreachable, the function falls back to the cached cookie value so the member stays authenticated.
| Cookie attribute | Admin (pb_auth) | Member (pb_member_auth) |
|---|---|---|
| httpOnly | yes (set by PB SDK) | yes |
| sameSite | lax | lax |
| secure | true in production | true in production |
| maxAge | PocketBase default (1h token) | 30 days |
| path | / | / |
2FA TOTP enrollment
Admin accounts can enable Time-based One-Time Password (TOTP) 2FA at /admin/settings/security. The enrollment uses two server actions. enrollTotpAction() generates a 20-byte (160-bit) secret, encodes it in base32, and returns an otpauth:// URI for QR display. No database write happens yet. The user scans the QR in their authenticator app (Google Authenticator, 1Password, Authy, Bitwarden), then submits the first 6-digit code. confirmTotpEnrollmentAction() verifies the code, encrypts the secret using AES-256-GCM (enc: prefix, same pattern as BYOK keys), stores it in users.totp_secret, generates 8 single-use backup codes, hashes them with SHA-256, and stores the hashes in users.totp_backup_codes.
import { encrypt } from "@/lib/crypto/encrypt";
export async function confirmTotpEnrollmentAction(
input: { secret: string; code: string }
): Promise<ConfirmTotpEnrollmentResult> {
const session = await getSession();
if (!session) return { success: false, error: "Not authenticated" };
// Verify the first TOTP code against the claimed secret
const valid = verifyTotpCode(input.secret, input.code);
if (!valid) {
return { success: false, error: "Invalid code. Check your authenticator app clock." };
}
const plainBackupCodes = generateBackupCodes(); // 8 random codes
const hashedBackupCodes = plainBackupCodes.map(hashBackupCode);
await pb.collection("users").update(session.user.id, {
totp_enabled: true,
totp_secret: encrypt(input.secret), // stored encrypted, never plain
totp_backup_codes: JSON.stringify(hashedBackupCodes),
});
// plainBackupCodes returned once — show to user, never stored again
return { success: true, backupCodes: plainBackupCodes };
}TOTP login step
When a user with totp_enabled = true logs in, the middleware detects that the pb_auth cookie is set but the totp_verified cookie is absent, and redirects to /login/totp. The user enters their 6-digit code or a 10-character backup code. verifyTotpLoginAction() decrypts the stored secret, verifies the code within ±1 TOTP step (±30 seconds clock skew tolerance), and sets a totp_verified cookie. Backup codes are single-use — on consumption they are removed from the stored hash list. The TOTP window is ±1 step (RFC 6238 compliant). All comparisons use crypto.timingSafeEqual to prevent timing attacks.
Rate limiting
All auth endpoints are protected by src/lib/auth/rate-limit.ts, which implements an in-memory sliding window. The limits are: 10 attempts per 60 seconds per IP, and 5 attempts per 60 seconds per email address. These limits apply to loginAction, memberLoginAction, and the /forgot-password endpoint. Both limits must pass for a request to proceed — failing either returns a 429 response.
Security invariants summary
- Tokens stored as SHA-256 hex in verification_tokens.token — raw token travels in email URL only, never persisted
- TOTP secrets encrypted at rest using AES-256-GCM (enc: prefix) via src/lib/crypto/encrypt.ts
- Backup codes hashed with SHA-256 before storage — each usable only once
- TOTP comparisons use crypto.timingSafeEqual to prevent timing side-channels
- Magic link verify is a two-step flow (GET renders card, POST consumes) to block email prefetchers
- getMemberSession() live-reads tier from DB — cookie tier is never trusted for access gating
- Anti-enumeration on forgot-password: identical response for valid and invalid email addresses
- Rate limiting on all auth endpoints (IP + email sliding window)
Reference
| File | Responsibility |
|---|---|
| src/lib/auth/session.ts | Read pb_auth cookie, validate PocketBase session |
| src/lib/auth/magic-link.ts | sendMagicLink(), verifyMagicLink(), getMemberSession() |
| src/lib/auth/totp.ts | enrollTotpAction(), confirmTotpEnrollmentAction(), verifyTotpLoginAction() |
| src/lib/auth/actions.ts | loginAction, logoutAction, verifyAction (server actions) |
| src/lib/auth/rate-limit.ts | checkRateLimit(ip, email) — sliding window |
| src/lib/auth/hash-token.ts | hashToken(raw) — SHA-256 hex |
| src/lib/auth/prefetch-detect.ts | isLinkPrefetcher(userAgent) — detects Outlook SafeLinks, Slackbot, etc. |