Authentication & Sessions

12 min readUpdated 27 Apr 2026

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

PropertyAdmin authMember auth
PocketBase collectionusersblog_members
Session cookiepb_auth (set by PB SDK)pb_member_auth (custom JSON cookie)
Login methodEmail + passwordMagic link (passwordless)
Protected routes/admin/*/member/*
Source filesrc/lib/auth/actions.tssrc/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.

src/lib/auth/session.ts (simplified)
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,
  };
}
typescript

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.

src/lib/auth/magic-link.ts (token generation excerpt)
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)}`;
typescript

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 attributeAdmin (pb_auth)Member (pb_member_auth)
httpOnlyyes (set by PB SDK)yes
sameSitelaxlax
securetrue in productiontrue in production
maxAgePocketBase 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.

src/lib/auth/totp.ts (enrollment confirmation, abbreviated)
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 };
}
typescript

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

FileResponsibility
src/lib/auth/session.tsRead pb_auth cookie, validate PocketBase session
src/lib/auth/magic-link.tssendMagicLink(), verifyMagicLink(), getMemberSession()
src/lib/auth/totp.tsenrollTotpAction(), confirmTotpEnrollmentAction(), verifyTotpLoginAction()
src/lib/auth/actions.tsloginAction, logoutAction, verifyAction (server actions)
src/lib/auth/rate-limit.tscheckRateLimit(ip, email) — sliding window
src/lib/auth/hash-token.tshashToken(raw) — SHA-256 hex
src/lib/auth/prefetch-detect.tsisLinkPrefetcher(userAgent) — detects Outlook SafeLinks, Slackbot, etc.