Email verification
New registrations get email_verified=0. A verification email is sent
immediately after registration (best-effort — a mail outage does not block
account creation). The user clicks the link to verify, and the token is
consumed.
Register ──► createEmailVerification ──► 256-bit token (hashed at rest) │ └─ sendMail ──► "Verify your email" │ └─ click link ──► GET /verify-email?token=... │ └─ verifyEmailToken │ ├─ valid ──► email_verified=1 ──► redirect /login?notice=email_verified └─ invalid ──► redirect /login?notice=invalid_verificationPOST /register— after creating the account and setting the session cookie, callscreateEmailVerification(userId). This generates a 256-bit random token, stores its SHA-256 hash inemail_verifications(the raw token never touches the DB), and emails a link:<APP_URL>/verify-email?token=...GET /verify-email?token=...— callsverifyEmailToken(token), which hashes the token, looks it up, checks the expiry, and on success:- Sets
email_verified=1on the user row. - Deletes all verification tokens for that user (consumed).
- Redirects to
/login?notice=email_verified. - On failure (wrong token, expired): redirects to
/login?notice=invalid_verification.
- Sets
Token security
Section titled “Token security”Same pattern as password reset:
- 256-bit random token (
randomBytes(32).toString("hex")) — unguessable. - Hashed at rest — the DB stores
SHA-256(token), not the raw token. A DB leak does not expose valid verification links. - 24-hour expiry (
EMAIL_VERIFICATION_TTL_MS) — expired tokens are deleted on verification attempt. - One token per user —
createEmailVerificationdeletes any existing tokens for the user before inserting a new one. Requesting a new verification invalidates the old link.
Best-effort delivery
Section titled “Best-effort delivery”The sendMail call in the register handler is wrapped in .catch():
await sendMail({ ... }).catch((err) => console.error("[mail] failed to send verification email:", err),);If the mail provider is down, the error is logged and registration succeeds — the user is logged in and can use the app. The verification token is valid for 24 hours, so the user can request a new verification email once the provider recovers.
Schema
Section titled “Schema”-- 0005_email_verification.sqlALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0;
CREATE TABLE email_verifications ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')));email_verified is a SQLite boolean (0/1). The PublicUser type exposed
to the client maps it to a real boolean (row.emailVerified === 1).
Google OAuth users skip this flow entirely — their email is verified by
Google at OAuth time, so email_verified is set to 1 on account creation.