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
-
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)
-
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
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
Initiate -- authenticated user submits new email + current password via
POST /api/user/change-emailget_credentials+spawn_blocking(bcrypt::verify)pattern)normalize_registration_email()generate_secure_token()-- one for old address, one for newConfirm --
POST /api/auth/confirm-email-change(unauthenticated, token-based)email, setemail_verified = true, clear pending stateauth_eventsWhy dual confirmation
Schema migration
Six new nullable columns on
users(consistent with existingpassword_reset_token/email_verification_tokenpattern -- no new tables):Files affected
core/src/repositories/user.rs-- new methods for pending email CRUDapi/src/api/http/auth.rs-- two new handlers (initiate, confirm)api/src/email_service.rs-- two new methods onEmailSendertrait (old-address notification, new-address confirmation)api/src/api/http/routes.rs-- register new routesdatabase/migrations/Security considerations
Alternatives Considered
pending_email_changestable: Cleaner normalization but breaks the established pattern of per-row token columns. Not worth the divergence.Acceptance Criteria
auth_eventsrecords both initiation and completion