Skip to content

feat(account): allow password users to change their email address#313

Open
stophecom wants to merge 3 commits into
devfrom
feat/change-email
Open

feat(account): allow password users to change their email address#313
stophecom wants to merge 3 commits into
devfrom
feat/change-email

Conversation

@stophecom

Copy link
Copy Markdown
Owner

What

Adds a self-serve change email address flow on the account profile page (/account/profile). Previously the email field was permanently read-only for everyone.

Why it's not a one-line change

The zero-knowledge auth verifier is salted with the user's email (deriveAuthVerifier(password, email) in packages/core/src/key-management.ts). The client sends this verifier to the server in place of the password, and it's hashed into user.passwordHash. So if the email changes, the same password derives a different verifier → password login breaks.

The flow therefore re-derives and re-stores the credential. Critically, the encryption master key is untouched — it's wrapped with a random pdkSalt, independent of email — so existing secrets stay decryptable and the key is not re-wrapped (unlike a password change).

Flow

  1. Step 1 — requestEmailChange: user enters new email + current password. Server re-authenticates (current-email verifier vs passwordHash), checks the new address is free, and emails a 6-digit OTP to it (reuses the existing email_verification_request flow).
  2. Step 2 — confirmEmailChange: user enters the OTP. Client re-derives the verifier with the new email salt. Server verifies the OTP, re-checks the password (so step 2 can't bypass step 1's password gate), then commits user.email + new passwordHash in a transaction and syncs userSettings.email. External systems (Stripe customer email, Resend audience) are best-effort synced after.

The plaintext password is held in memory across the two steps (same pattern as the encryption flows) so the client can re-derive verifiers; it never leaves the browser.

Eligibility (deliberate limitation)

Only accounts that have a password and are not linked to Google can change their email. A Google-linked account re-matches on Google's email at the next OAuth login (createOrUpdateUser conflicts on email), which would fork into a second account. For those users the field stays read-only with an explanatory tooltip. The session already exposes hasPassword and googleId, so no schema change was needed.

No DB migration

user.email already exists and is unique.

Files

  • formSchemas.tschangeEmailRequestFormSchema, changeEmailConfirmFormSchema
  • actions.tsrequestEmailChange, confirmEmailChange
  • change-email-form.svelte — new two-step dialog form
  • account-card.svelte — editable email for eligible users
  • profile/+page.server.ts / +page.svelte — wire actions + forms
  • messages/*.json — 16 new keys, translated into all 13 locales via /translate-keys

Verification

  • pnpm check — no errors in changed files (only pre-existing repo errors remain)
  • pnpm lint — clean
  • pnpm build — succeeds

The end-to-end OTP flow wasn't exercised in a live browser (needs a seeded password account + Resend email delivery); worth a manual smoke test on the preview deploy.

Security notes / follow-ups

  • Both steps require the password, so a hijacked session alone can't move the account email.
  • The current session is intentionally not invalidated (sessions key on userId). A "your email was changed" notification to the old address would be a good hardening follow-up.

🤖 Generated with Claude Code

Adds a self-serve "change email" flow on the account profile page.

Because the auth verifier is salted with the user's email
(deriveAuthVerifier), changing the email requires re-deriving and
re-storing the credential — otherwise password login would break.
The encryption master key is unaffected (random pdkSalt), so existing
secrets stay decryptable and the key is not re-wrapped.

Flow:
- Step 1 (requestEmailChange): re-authenticate with the current
  password and email a 6-digit OTP to the new address.
- Step 2 (confirmEmailChange): verify the OTP, re-check the password,
  then commit the new email + the verifier re-derived with the new
  email salt. Also syncs userSettings.email, the Stripe customer, and
  the Resend audience.

Eligibility is limited to accounts that have a password and are not
linked to Google — a Google-linked account re-matches on Google's
email on the next OAuth login, which would fork into a second account,
so the field stays read-only for those users.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
scrt-link-v2 Ready Ready Preview, Comment Jul 8, 2026 7:05pm

Request Review

The confirm step's submit silently no-op'd: `email` was a required field
set programmatically (not rendered), and after step 1 the request form's
default `invalidateAll` re-ran the page load, handing the confirm superForm
a fresh empty form and wiping the email back to "". Client-side validation
then failed on the invisible field and superforms cancelled the submit
with no visible error.

- Make `email` client-optional in the confirm schema (it's always attached
  in onSubmit from in-memory state); the server now guards its presence.
- Disable `invalidateAll` on both change-email forms (the confirm success
  path calls invalidateAll manually) so state isn't reset mid-flow and the
  code field isn't cleared on a wrong-code error.
- Strip the invalid email `pattern` attribute on the new-email input to
  clear the console regex warning (matches signin-form).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…field

- Move the post-success invalidateAll out of the confirm form's onResult
  and into onSuccess (after the dialog closes). Reloading while the form
  was mounted re-synced it and fired the success toast a second time.
- Restore the plaintext password in the request form's onUpdated/onError:
  jsonData() replaces the store field with the auth verifier and the
  server echoes it back, so a wrong-password error was repainting the
  input with the 64-char hash. Mirrors the signin-form pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant