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.ts—tokenPK,user_id,email,code_hash,attempts,expires_at,used_at,created_at. - Helpers:
src/lib/auth/email-verification.ts—sendEmailCode,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 ofPOST /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:
POST /api/auth/register→ account committed, SMS code texted,phoneVerificationRequired: true,emailVerificationRequired: true.POST /api/auth/verify-phone→ stampsphone_verified, then callssendEmailCodeand returnsemailVerificationRequired: true+emailCodeSent+verificationSessionReady, setting thesendoka_evcookie. That send is made un-reserved and AFTER the phone code is consumed, so it can come backrate_limited/unavailablewith no row to name — in which casecreatePendingEmailAnchorwrites a session marker anyway, because the alternative was a clearedsendoka_pv, nosendoka_ev, and a body still asking for a code step that answers "session expired" from every control.POST /api/auth/verify-email-code→ stampsemail_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 audituser.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
createPendingAnchorfor 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'screated_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()throwsEMAIL_UNVERIFIED, the login form posts toPOST /api/auth/email-challenge, and that re-issues the cookie and a fresh code before redirecting to/verify-email.email-challengeis an unauthenticated password door, so it re-implements every guardauthorize()applies, in the same order — keep the two in step, and keep them in step withphone-challenge.
Rate limits
email_code:email5/h,email_code:ip10/h — spent bysendEmailCode(reserved: truewhen the caller already spent one).verify_email:ip20/15min — a ceiling on cycling codes, on top of the per-codeattemptscap of 5.email_challenge:ip10/15min, plus the sharedloginandlogin_emailbuckets, 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-emailis the standalone code screen. The emailed link points at/api/auth/verify-email. These were confused once:sendVerificationEmailbuilt its link as<baseUrl>/verify-email?token=…, where no page existed, so every link ever mailed answered 404 andemail_verifiedwas unreachable for password signups. That is why the sign-in gate is scoped tousers.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 viaPOST /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 whoseemail_verifiedis set (emails.md), so an owner who never verified cannot mail even their own address from it until they do.