Skip to content

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_verification
  1. POST /register — after creating the account and setting the session cookie, calls createEmailVerification(userId). This generates a 256-bit random token, stores its SHA-256 hash in email_verifications (the raw token never touches the DB), and emails a link: <APP_URL>/verify-email?token=...
  2. GET /verify-email?token=... — calls verifyEmailToken(token), which hashes the token, looks it up, checks the expiry, and on success:
    • Sets email_verified=1 on 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.

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 usercreateEmailVerification deletes any existing tokens for the user before inserting a new one. Requesting a new verification invalidates the old link.

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.

-- 0005_email_verification.sql
ALTER 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.