Email Verification

Mandatory for new signups. A self-provisioned account cannot sign in until users.email_verified is stamped — authorize() throws EMAIL_UNVERIFIED. The proof is a 6-digit code, collected as the last step of the registration wizard.

Two proof paths exist, and they are not interchangeable:

Code row Link row
email_verifications.code_hash sha256 of 6 digits NULL
Delivered as 6 digits in the body 48-char token in a URL
Where the token lives httpOnly sendoka_ev cookie, never mailed the emailed URL
Window 10 min (expires_at), 15 min pending session 24h
Guess defence attempts cap of 5 + supersede on resend ~276 bits of entropy
Minted by sendEmailCode sendVerificationEmail
Redeemed by POST /api/auth/verify-email-code GET /api/auth/verify-email?token=…
Used for every new signup legacy accounts already signed in with an unverified address

verifyEmailCode ignores link rows (no attempt cap → six digits would be ~1e6 free guesses) and consumeVerificationToken ignores code rows (a token pulled from a cookie must not be replayable as a link that skips the wizard step).

Files

  • Schema: src/lib/db/schema/email-verifications.tstoken PK, user_id, email, code_hash, attempts, expires_at, used_at, created_at.
  • Helpers: src/lib/auth/email-verification.tssendEmailCode, verifyEmailCode, resolvePendingEmailSession, reserveEmailSendSlot, createVerificationToken, consumeVerificationToken, sendVerificationEmail.
  • Routes: POST /api/auth/verify-email-code, POST /api/auth/resend-email-code, POST /api/auth/email-challenge, GET /api/auth/verify-email, POST /api/auth/resend-verification (session-authenticated, link path).
  • UI: src/components/auth/email-code-step.tsx (shared), src/app/(auth)/verify-email/page.tsx (standalone), src/app/(auth)/register/form.tsx (inline, last step), src/app/overview/settings/sign-in-email.tsx (Settings → Your sign-in email: status badge + "Send verification link", the only UI caller of POST /api/auth/resend-verification).

Flow (new signup)

With SYSTEM_SMS_FROM set, the code is minted by the phone step so its 10-minute window starts when the user reaches the screen:

  1. POST /api/auth/register → account committed, SMS code texted, phoneVerificationRequired: true, emailVerificationRequired: true.
  2. POST /api/auth/verify-phone → stamps phone_verified, then calls sendEmailCode and returns emailVerificationRequired: true + emailCodeSent + verificationSessionReady, setting the sendoka_ev cookie. That send is made un-reserved and AFTER the phone code is consumed, so it can come back rate_limited / unavailable with no row to name — in which case createPendingEmailAnchor writes a session marker anyway, because the alternative was a cleared sendoka_pv, no sendoka_ev, and a body still asking for a code step that answers "session expired" from every control.
  3. POST /api/auth/verify-email-code → stamps email_verified, mints the short-lived trusted-device cookie (skipped while the phone step is still owed — that cookie is the emailed-login-code exemption), writes audit user.email_verified, and calls the signup-notification gate. The form signs in.

emailCodeSent: false is carried into the standalone screen as /verify-email?sent=0. Dropped, it made that page claim a code had been emailed and start a 45-second cooldown over the resend button — on the one screen that could recover the failed send.

With SYSTEM_SMS_FROM unset there is no phone step, so registration spends the email send token itself (reserveEmailSendSlot, before the transaction — an account whose code cannot go out is one that cannot sign in) and mails the code in step 1.

sendEmailCode orders its writes insert → send → supersede:

  • Insert first, so a provider failure still leaves a row for the pending cookie to name. Without it, "Send a new code" — the only control on the screen the user is looking at — would answer "session expired". The phone flow needs createPendingAnchor for this case; this ordering does not.
  • Supersede last, so a failed send cannot retire a working code the user already holds. On success every OLDER unconsumed row for that user (including stale links) is marked used_at — bounded by the new row's created_at, not merely by "not my token": two concurrent sends each retired the other's brand-new row, so both mailed codes stopped verifying and the pending cookie resolved to a consumed row. A timestamp tie leaves both alive, which is the safe direction.

Recovery

Abandoning the email step is not a lockout:

  • Within the 15-minute pending window: POST /api/auth/resend-email-code (cookie-anchored, address read from the user row — never the request body).
  • After it: sign in with the password. authorize() throws EMAIL_UNVERIFIED, the login form posts to POST /api/auth/email-challenge, and that re-issues the cookie and a fresh code before redirecting to /verify-email. email-challenge is an unauthenticated password door, so it re-implements every guard authorize() applies, in the same order — keep the two in step, and keep them in step with phone-challenge.

Rate limits

  • email_code:email 5/h, email_code:ip 10/h — spent by sendEmailCode (reserved: true when the caller already spent one).
  • verify_email:ip 20/15min — a ceiling on cycling codes, on top of the per-code attempts cap of 5.
  • email_challenge:ip 10/15min, plus the shared login and login_email buckets, so attempts there count against the account's sign-in budget.

Env

  • SYSTEM_FROM_EMAIL — verified SES identity. Fallback: no-reply@sendoka.com (won't work without DKIM set up in SES for that domain).
  • NEXTAUTH_URL — base URL for link construction.

Retention

The nightly retention cron deletes email_verifications rows older than 7 days. The row is the personal data (an address plus a dead hash or token) and nothing reads it after its window closes.

Notes

  • /verify-email is the standalone code screen. The emailed link points at /api/auth/verify-email. These were confused once: sendVerificationEmail built its link as <baseUrl>/verify-email?token=…, where no page existed, so every link ever mailed answered 404 and email_verified was unreachable for password signups. That is why the sign-in gate is scoped to users.signup_source — see authentication.md.
  • Accounts with signup_source IS NULL (invited, SAML, SCIM, or created before the gate) are not gated and keep the link path via POST /api/auth/resend-verification, which the Settings card above calls. Not being gated at sign-in does not make an unverified address harmless: the org's sandbox sender only delivers to members whose email_verified is set (emails.md), so an owner who never verified cannot mail even their own address from it until they do.