Email Verification (Signup)
Email/password signups go through a two-step verification flow before any resources are provisioned. OAuth signups (Google, Facebook) skip this — the identity provider already proves email ownership.
Flow overview
Section titled “Flow overview”POST /api/auth/sign-up → validate inputs → hash password (bcrypt) → sweep stale pending rows → insert pending_signup row → email 6-digit code → return 202 VerificationSent
POST /api/auth/verify-signup → look up pending_signup by email → check expiry + attempt cap → compare code hash (constant-time) → on match: finalize_signup → provision everything → return 201 Success + session cookie
POST /api/auth/resend-signup-code → look up pending_signup by email → generate fresh code, reset attempts → email new code, then update DB → return 200 Successpending_signup table
Section titled “pending_signup table”| Column | Type | Notes |
|---|---|---|
id | UUID | Primary key |
email | VARCHAR | Unique — one pending signup per email |
name | VARCHAR | User’s display name |
organization_name | VARCHAR | Org name for tenant creation |
password_hash | VARCHAR | bcrypt hash, computed at request time |
code_hash | VARCHAR | SHA-256 hex of the 6-digit code |
invitation_token | VARCHAR? | If set, user joins an existing org |
created_at | TIMESTAMPTZ | Row creation time |
expires_at | TIMESTAMPTZ | created_at + 24h |
attempts | INTEGER | Failed verification count (max 5) |
Migration: m20260514_120000_create_pending_signup.rs
Endpoints
Section titled “Endpoints”POST /api/auth/sign-up
Section titled “POST /api/auth/sign-up”Changed behavior — no longer provisions any resources. Returns 202 with a VerificationSent response instead of 201 with a session token.
Request: name, email, password, organization_name (unchanged)
Response variants:
| Variant | Status | Meaning |
|---|---|---|
VerificationSent { email } | 202 | Code sent, awaiting verification |
EmailAlreadyRegistered | 409 | Email exists in email_password_account |
PasswordTooShort | 400 | Password under 8 characters |
InvalidEmail | 400 | Fails format validation |
InvalidUserName | 400 | Blank after trimming |
InvalidOrganizationName | 400 | Blank after trimming |
InternalError(String) | 500 | DB or email transport failure |
Stale pending_signup rows (past expires_at) are swept inline at the top of each request — no separate cron needed.
POST /api/auth/verify-signup
Section titled “POST /api/auth/verify-signup”New endpoint. Confirms the 6-digit code, then provisions user, organization, Twilio subaccount, Stripe customer, member, session, and Free-tier subscription.
Request: email, code
Response variants:
| Variant | Status | Meaning |
|---|---|---|
Success(session_token) | 201 | Provisioned + logged in (sets session cookie) |
CodeInvalid | 400 | Hash mismatch — attempts counter incremented |
CodeExpired | 410 | Row past expires_at |
TooManyAttempts | 429 | 5+ failed attempts — must resend |
NotFound | 404 | No pending signup for this email |
TwilioSubaccountError(String) | 500 | Twilio provisioning failed |
InternalError(String) | 500 | Other failure |
The attempt counter uses an atomic SQL UPDATE ... SET attempts = attempts + 1 WHERE attempts < 5 to prevent TOCTOU races under concurrent requests.
POST /api/auth/resend-signup-code
Section titled “POST /api/auth/resend-signup-code”New endpoint. Generates a fresh code, resets the attempt counter, and extends the expiry.
Request: email
Response variants:
| Variant | Status | Meaning |
|---|---|---|
Success | 200 | New code sent |
NotFound | 404 | No pending signup for this email |
InternalError(String) | 500 | DB or email failure |
Code generation and hashing
Section titled “Code generation and hashing”- Generation: 6 random digits (
000000–999999) viauuid::Uuid::new_v4()→ first 4 bytes →u32 % 1_000_000 - Hashing: SHA-256, hex-encoded — stored in
code_hash - Comparison: constant-time byte comparison to prevent timing side-channels
Source: pending_signup_code_service.rs
Finalization service
Section titled “Finalization service”finalize_signup in finalize_signup_service.rs runs after code verification. It handles two paths:
New tenant (no invitation):
- Create
userrow - Insert
email_password_account - Create Stripe customer
- Create organization + Twilio subaccount + twilio settings + event sink
- Seed default contact tags
- Create member (owner)
- Seed notification preferences
- Send welcome notification
- Create session → return token
- Auto-subscribe to Free tier
Invited user (invitation_token present):
- Create
userrow - Insert
email_password_account - Process invitation (joins existing org, skips Stripe/Twilio)
- Seed notification preferences
- Send welcome notification
- Create session → return token
On failure at any step, cleanup functions remove the partially-created user and org rows.
UI changes
Section titled “UI changes”SignupCard (signup_card_component.rs) now toggles between two panels:
- Form panel — the existing name/email/password/org form. On submit, calls
/sign-upand transitions to the Verify panel. - Verify panel — a 6-digit input (client-side non-digit filtering). “Verify” calls
/verify-signup. “Resend Code” calls/resend-signup-codewith a 30-second UI cooldown. “Back” returns to the Form panel with fields preserved.
Form field state is hoisted to the SignupCard level so navigating back from Verify retains the user’s input.