Skip to content

Add Email verification on User/Password signup #217

Description

@gkorland

Problem

POST /auth/signup/email creates the account and logs the browser straight in. Nothing checks that the person signing up controls the address they typed.

api/routes/auth.pyemail_signup() validates the field set, the email format (_validate_email), and a password length of 8, rejects an address that already has an account under any provider, then:

is_new_identity, user_info = await ensure_user_in_organizations(...)
password_hash = _hash_password(password)
await _set_mail_hash(email, password_hash)
establish_browser_session(..., provider="email", provisioned=True)
return JSONResponse({"success": True}, status_code=201)

So someone@company-i-do-not-work-for.com becomes a real, immediately usable account.

This gap is specific to the email provider. The Google and GitHub paths receive an already-verified address from the identity provider, so they are unaffected.

Why it matters

  • Accounts can be created on addresses the registrant does not own, in someone else's name.
  • Every downstream count — the org graph, the analytics dashboard, any waiting-list or quota logic (Add waiting list #63, Users management #10) — treats an unverified address as a real user.
  • It is the normal precondition for anything sent by email later (password reset, notifications). Building those on unverified addresses means building on sand.

There is no mail infrastructure yet

Nothing in api/ sends email — no SMTP, SendGrid, Mailgun, SES or Resend. Choosing and wiring a provider is part of this issue, and probably the larger part. Whatever is chosen needs to work in local dev without real delivery (log the link, or a console transport) so the signup flow stays testable.

Suggested shape

  1. Persist a verification state on the Identity node alongside password_hashemail_verified: bool plus a hashed, single-use, expiring token. Store the hash, not the token, the same way passwords are handled.
  2. Send on signup. Keep account creation as it is, add the send.
  3. Decide what an unverified account can do — see below.
  4. GET /auth/verify/email?token=… — constant-time compare, single use, expire after e.g. 24h, redirect into the app with a result. Do not leak whether an address exists.
  5. Resend endpoint, rate-limited per address.
  6. UIapp/src/components/modals/AuthModal.tsx, app/src/services/auth.ts, app/src/contexts/AuthContext.tsx: a post-signup "check your inbox" state, a resend control, and a banner while unverified.

Decision needed before implementing

Does an unverified account get a session? Two defensible answers:

  • Verify-then-use — signup returns 201 with no session; the account is inert until the link is clicked. Strongest, and it changes today's flow, since signup currently logs the user in.
  • Grace period — log them in as now, but gate the things that cost us (query execution, graph creation) behind verification and show a persistent banner. Gentler on conversion.

There is precedent for a pending state in this file already: establish_browser_session(..., provisioned=…) and _retry_pending_provisioning().

Note on existing work

#330 ("Add login and signup with email") is still open, DIRTY, CHANGES_REQUESTED, last touched 4 January — but email signup has since shipped by another route and is live on staging. That PR should be closed rather than revived; it is not the foundation for this work.

Done when

  • A new email signup cannot reach a verified state without the link being clicked
  • Tokens are hashed at rest, single-use, and expire
  • Resend exists and is rate-limited
  • Verification responses do not reveal whether an address is registered
  • Local dev can complete the flow without a real mail provider
  • The chosen gate (verify-then-use, or grace period) is applied consistently in the API, not only in the UI

References

api/routes/auth.pyemail_signup(), _set_mail_hash(), _email_account_exists(), _validate_email(), establish_browser_session()
app/src/components/modals/AuthModal.tsx · app/src/services/auth.ts · app/src/contexts/AuthContext.tsx
Related: #330 (close), #63, #10

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions