Skip to content

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.

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 Success
ColumnTypeNotes
idUUIDPrimary key
emailVARCHARUnique — one pending signup per email
nameVARCHARUser’s display name
organization_nameVARCHAROrg name for tenant creation
password_hashVARCHARbcrypt hash, computed at request time
code_hashVARCHARSHA-256 hex of the 6-digit code
invitation_tokenVARCHAR?If set, user joins an existing org
created_atTIMESTAMPTZRow creation time
expires_atTIMESTAMPTZcreated_at + 24h
attemptsINTEGERFailed verification count (max 5)

Migration: m20260514_120000_create_pending_signup.rs

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:

VariantStatusMeaning
VerificationSent { email }202Code sent, awaiting verification
EmailAlreadyRegistered409Email exists in email_password_account
PasswordTooShort400Password under 8 characters
InvalidEmail400Fails format validation
InvalidUserName400Blank after trimming
InvalidOrganizationName400Blank after trimming
InternalError(String)500DB or email transport failure

Stale pending_signup rows (past expires_at) are swept inline at the top of each request — no separate cron needed.

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:

VariantStatusMeaning
Success(session_token)201Provisioned + logged in (sets session cookie)
CodeInvalid400Hash mismatch — attempts counter incremented
CodeExpired410Row past expires_at
TooManyAttempts4295+ failed attempts — must resend
NotFound404No pending signup for this email
TwilioSubaccountError(String)500Twilio provisioning failed
InternalError(String)500Other failure

The attempt counter uses an atomic SQL UPDATE ... SET attempts = attempts + 1 WHERE attempts < 5 to prevent TOCTOU races under concurrent requests.

New endpoint. Generates a fresh code, resets the attempt counter, and extends the expiry.

Request: email

Response variants:

VariantStatusMeaning
Success200New code sent
NotFound404No pending signup for this email
InternalError(String)500DB or email failure
  • Generation: 6 random digits (000000999999) via uuid::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

finalize_signup in finalize_signup_service.rs runs after code verification. It handles two paths:

New tenant (no invitation):

  1. Create user row
  2. Insert email_password_account
  3. Create Stripe customer
  4. Create organization + Twilio subaccount + twilio settings + event sink
  5. Seed default contact tags
  6. Create member (owner)
  7. Seed notification preferences
  8. Send welcome notification
  9. Create session → return token
  10. Auto-subscribe to Free tier

Invited user (invitation_token present):

  1. Create user row
  2. Insert email_password_account
  3. Process invitation (joins existing org, skips Stripe/Twilio)
  4. Seed notification preferences
  5. Send welcome notification
  6. Create session → return token

On failure at any step, cleanup functions remove the partially-created user and org rows.

SignupCard (signup_card_component.rs) now toggles between two panels:

  • Form panel — the existing name/email/password/org form. On submit, calls /sign-up and 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-code with 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.