Skip to content

feat: self-serve email change with dual confirmation #223

@mbradley

Description

@mbradley

Summary

Users with email/password auth should be able to change their email address through a self-serve flow. The change requires password re-verification and confirmation from both the old and new email addresses before taking effect.

Problem or Opportunity

There is no way for a user to update their email address today. If someone loses access to their email, typos their address during registration, or simply wants to use a different address, they have no path forward without manual intervention.

Proposed Approach

Flow

  1. Initiate -- authenticated user submits new email + current password via POST /api/user/change-email

    • Re-verify password (existing get_credentials + spawn_blocking(bcrypt::verify) pattern)
    • Validate new email with existing normalize_registration_email()
    • Check new email is not already registered (unique index)
    • Generate two tokens via generate_secure_token() -- one for old address, one for new
    • Store pending state on user row (see schema below)
    • Send confirmation link to new address, security notification + confirm/cancel link to old address
    • Return 200 regardless of outcome (anti-enumeration)
  2. Confirm -- POST /api/auth/confirm-email-change (unauthenticated, token-based)

    • Look up user by token, record which side confirmed
    • When both old and new addresses have confirmed: atomically update email, set email_verified = true, clear pending state
    • Log to auth_events

Why dual confirmation

  • Old-address confirmation proves the real account holder initiated the change (not just someone with a stolen session)
  • New-address confirmation proves the new address is reachable and owned
  • Separate tokens per address prevent cross-confirmation (old address holder can't confirm for new, and vice versa)

Schema migration

Six new nullable columns on users (consistent with existing password_reset_token / email_verification_token pattern -- no new tables):

ALTER TABLE users ADD COLUMN pending_email TEXT;
ALTER TABLE users ADD COLUMN pending_email_old_token TEXT;
ALTER TABLE users ADD COLUMN pending_email_new_token TEXT;
ALTER TABLE users ADD COLUMN pending_email_expires_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN pending_email_old_confirmed_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN pending_email_new_confirmed_at TIMESTAMPTZ;

CREATE INDEX idx_users_pending_email_old_token
  ON users (pending_email_old_token) WHERE pending_email_old_token IS NOT NULL;
CREATE INDEX idx_users_pending_email_new_token
  ON users (pending_email_new_token) WHERE pending_email_new_token IS NOT NULL;

Files affected

  • core/src/repositories/user.rs -- new methods for pending email CRUD
  • api/src/api/http/auth.rs -- two new handlers (initiate, confirm)
  • api/src/email_service.rs -- two new methods on EmailSender trait (old-address notification, new-address confirmation)
  • api/src/api/http/routes.rs -- register new routes
  • New migration in database/migrations/

Security considerations

  • Password re-verification before initiating (session hijack can't change email without password)
  • 24h token expiry (matches registration verification)
  • 5-minute resend cooldown (matches existing verification resend)
  • Anti-enumeration on initiation endpoint
  • Reuse existing email normalization to prevent case/dot bypass
  • Unique index prevents setting an already-registered email
  • SendGrid click/open tracking remains disabled (tokens stay off redirect servers)
  • Both addresses notified, so the real owner knows if they didn't request it

Alternatives Considered

  • Single confirmation (new address only): Simpler, but a stolen session + password could silently hijack an account. Dual confirmation is the industry standard (Google, GitHub, etc.).
  • Redis-based token storage: Avoids a migration but Redis is optional in Keycast and tokens would be lost on restart. DB columns are consistent with how all other token flows work.
  • Separate pending_email_changes table: Cleaner normalization but breaks the established pattern of per-row token columns. Not worth the divergence.

Acceptance Criteria

  • Authenticated user can initiate email change with password re-verification
  • Old address receives security notification with confirm/cancel link
  • New address receives confirmation link
  • Email change only completes after both addresses confirm
  • Expired tokens are rejected
  • Duplicate email (already registered) is rejected
  • Initiating a new change cancels any pending change
  • auth_events records both initiation and completion
  • Anti-enumeration: initiation always returns success
  • Tests cover happy path, expiry, duplicate email, wrong password, and partial confirmation

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions