DEV Community

Cover image for Changing an Email Address Is the Most Dangerous Button in Your App
horlartundhey
horlartundhey

Posted on

Changing an Email Address Is the Most Dangerous Button in Your App

a short summary of the blog written
Most apps treat "change my email" as a settings-page chore. A text box, a save button, maybe a confirmation link. It looks harmless.

It isn't. In almost every product, the email address is the master key to the account. It receives password resets, security alerts, and receipts. Whoever controls it can take the account permanently. So the button that rewrites it deserves more suspicion than the login screen.

This post walks through how we redesigned the flow, the reasoning behind each decision, and the trade-offs we accepted.


The threat we were actually worried about

Start with a precise question: who are we defending against?

Not the stranger who guesses a password. Login has its own defences. The attacker we cared about is quieter:

Someone who has a valid session but is not the real owner.

That can be a stolen token, a phone left unlocked, a shared computer that never logged out. They are "logged in", so every ordinary check passes. And if they can swap the email address, they lock the real owner out forever, because every recovery route now leads to the attacker's inbox.

The usual flow, "type a new address, click the link we send there", only proves the person controls the new inbox. A thief controls their own inbox by definition. That check protects nothing.

The core idea: prove the old door before opening a new one

The fix is simple to say. Before the system accepts a new address, the person must prove they can read the current one.

Think of a flat in a secure building. The email address is where the building sends mail and where it sends recovery keys. Moving that address is a sensitive act, so the building asks twice:

  1. Show me you can open the mailbox I already trust.
  2. Now show me you can open the new one.

A thief with only a session token can do neither. The real owner does both in about a minute.

So the flow has four steps:

Step What happens
1 A code is sent to the current address
2 The user enters it, which opens a short window of trust
3 The user names a new address, and a second code is sent there
4 The user enters the second code, and only now does the address change

Nothing on the account changes until step 4.


Design decision 1: the proof lives on the server

After step 2, the system needs to remember "this person passed the old-inbox check". The tempting shortcut is to let the client carry that fact: a flag, a hidden field, a value in the request.

Don't. Anything the client sends, the client can forge.

Instead, the proof is a row in the database: a record saying this user verified the old inbox, until this moment, and it has not been used yet. The server consults that row at step 3 and again at step 4. The client cannot claim it, only earn it.

Two properties fall out of this for free:

  • It expires on its own. We give the window 15 minutes, long enough to type an address and fetch a second code, short enough that an abandoned flow goes cold.
  • It is single use. Completing the change spends the proof. A stolen session can't replay it to change the address again.

Design decision 2: a code belongs to one address

Here is a subtle hole in many implementations. The user requests a code for address A, then confirms with address B. If the system only checks "is this code valid for this user?", an attacker can request a code to their own inbox, then submit it alongside any address they like.

The fix is to bind every code to the exact address it was sent to. At confirmation the system asks, "Is there a live code for this user and this address, and does it match?" A code for A is worthless for B.

Design decision 3: assume people will guess

Six-digit codes have a million possibilities. That sounds large until you remember computers are patient. So we layered limits, none of which is enough alone:

  • Codes expire after 10 minutes.
  • Each code allows 5 wrong guesses, then it is dead, even if the right digits follow.
  • A user can ask for at most 3 codes an hour, no sooner than a minute apart.
  • The endpoints also carry ordinary request throttles.
  • And the attacker needs a valid session just to get started.

Put together, the odds of guessing are on the order of a few thousandths of a percent per hour. The point isn't any single wall. It's that every wall is cheap, and they multiply.

We also store only a keyed hash of each code, never the code. If the database leaks, the attacker gets hashes of codes that have already expired. Honestly, a six-digit code can be brute-forced offline if someone has both the database and the key, so the hash is defence in depth. The real protection is the short life and the guess limit.

The bug that would have silently disabled all of this

The most instructive moment came from the guess counter.

We wrapped the check in a database transaction so concurrent guesses couldn't both slip under the limit. Inside it, a wrong guess increments the attempt counter and then the code raises a validation error.

The catch: raising an error inside a transaction rolls the transaction back, including the counter increment. The counter never moves. An attacker gets unlimited guesses and the limit is decorative.

The fix is to have the transaction return a result and raise the error after it commits. A test that submits five wrong codes and then the right one proves the counter really sticks. It is a good reminder that a security control isn't real until a test tries to break it.

Design decision 4: don't strand the user who did everything right

Some emails are courtesies, not requirements. After a successful change, we alert the old address ("your email was changed to n***@example.com") so a real owner whose session was hijacked finds out.

But by then the change is already saved. If the mail server hiccups, failing the whole request would tell the user "it failed" about something that succeeded. So alert failures are logged and swallowed. The rule we followed: errors that mean the action didn't happen should stop it; errors in follow-up courtesies should not.

The alert also masks the new address. The old owner needs to recognise it, but there's no reason to spell out a full address in an email that could be forwarded.

Design decision 5: throw out every old session

Once the address changes, any login from before that moment is suspect. We handle this with a small trick instead of a blacklist.

Every user carries a number, a token version. Every login token is stamped with the version that was current when it was issued. A bump invalidates every token ever issued in one operation:

  • Change the email → bump the number.
  • Every older token now fails a one-integer comparison on its next request.
  • The device that made the change gets a fresh token with the new number, so it stays signed in.

Compared with blacklisting individual tokens, this needs no list of revoked tokens, adds no extra query (the user is already loaded to authenticate), and is impossible to forget to apply on a new route because it sits in middleware. The trade-off is that it's all-or-nothing: you can't revoke a single device.

The same mechanism handles password changes and account deletion, so one idea covers three features.

Design decision 6: slow down the damage

Even a determined attacker who somehow gets through both inboxes shouldn't be able to ricochet the account across addresses. So only one completed change is allowed every 24 hours. It gives the real owner time to read the alert, and it blunts rapid hopping.

The cost is real: someone who typos their new address and completes the change has to wait a day. We judged that acceptable for an action people take a few times in a lifetime.


The audit trail came free

Each code sent is one row. When a change finishes, the row records both the old and the new address. So the same table that drives the flow doubles as a history of who moved the account where, and when.

That's convenient, and it's also a liability, which brings us to the honest part.


What we got wrong, or left open

A design is only as trustworthy as its authors' willingness to list the holes. Ours:

  1. Personal data outlives deletion. The audit rows hold real addresses. When an account is deleted (soft-deleted and anonymised, so payment records survive), those rows stay. A privacy-conscious system has to clean them up in the same step.
  2. The fixed test code. Outside production, the code is always the same six digits so that tests and manual testing work offline. If a staging server is misconfigured to not call itself production, the first check becomes a formality. That behaviour should hang off an explicit flag, not an environment name.
  3. A different door was left unlocked. The "forgot password" flow did not bump the token version, so sessions survive a reset done that way. Hardening one path while a sibling path stays open is a classic mistake.
  4. Mail is sent synchronously. A slow or failing mail server directly affects the request. Queue it.
  5. The lost-inbox problem. If someone has genuinely lost access to their old inbox, this flow cannot help them, by design. That isn't a bug, but it's a product decision: you need a human support process for identity verification, or those users are stuck.
  6. The alert has no undo. It tells the owner "contact support". A one-click revert link would shorten the recovery.
  7. Some edges are untested: real parallel requests against the row lock, mail failures when sending codes, and email case variants.

What the design does and doesn't promise

Be precise about the claim, because overclaiming is how security features get a bad name.

It stops: an attacker with a stolen or borrowed session but without access to the owner's inbox.

It does not stop: an attacker who already controls the old inbox. That person can reset the password anyway. Two inboxes help exactly when one of them is out of the attacker's hands.

That's the threat model. State it out loud, to your team and to the reviewer who will ask.


Takeaways you can steal

  1. Treat the email address as a credential, not a profile field. Protect its change like a password change, or better.
  2. Prove the old thing before accepting the new thing. The new inbox proves nothing about the person asking.
  3. Keep proof on the server. If a client can say it, a client can lie.
  4. Bind secrets to what they authorise. A code is for one address, not for the user in general.
  5. Layer cheap limits. Expiry, attempt caps, send caps and throttles each fall short alone and are strong together.
  6. Be suspicious of exceptions inside transactions. They can roll back the very evidence you meant to record.
  7. Revoke sessions with a counter, not a list, when "everything before now" is what you mean.
  8. Separate "must succeed" from "nice to send". Don't let a failed courtesy email undo a completed action.
  9. Test the attacks, not just the happy path. The five-wrong-guesses-then-the-right-one test earned its keep.
  10. Write down what you didn't fix. The list of holes is part of the design.

The email address is the smallest field on the settings page and the most powerful. The extra friction, two codes and a minute of a user's time, is a cheap price for making the account hard to steal.

Top comments (2)

Collapse
 
cekuu35 profile image
Cenk KURTOĞLU •

The “prove the old door” flow is right, and the threat model — a valid session held by someone who isn’t the owner — is the one most apps quietly ignore. The complement: your UI now guards the email change, but the database usually still lets that same stolen session change it directly. A textbook Supabase policy is for update using (id = auth.uid()), and that scopes the row, not the columns. A client with the stolen token can run update profiles set email = 'attacker@x' where id = auth.uid() and never touch your four-step flow. A with check on the same row doesn’t help: the row is still the attacker’s own.

Two ways to close it:

  • Move the master-key columns (email, role, owner_id) off the RLS-updatable table, or column-revoke update on them, so only the server path can write them.
  • Rotate the session on a successful change (and treat an email change as a re-auth event), so the “valid session” the attacker held stops being valid.

The test I’d add to step 4: as user A, update your own email/role straight against the API, not the UI, and assert it fails. A two-account probe for that shape: github.com/cekuu35/supabase-rls-le...

Collapse
 
srkdan_writes profile image
SRKDAN •

proving the old inbox first is solid. the painful case is when that old address is already dead (left the company, domain expired). do you have a recovery path for that, or is it support-only? also curious whether you revoke existing sessions and API tokens on a successful change. we missed the token revoke once and a stolen key kept working against the new address for days.