Users can change the email address they log in with via
POST /api/v1/users/me/change-email/. Because the email address is
the account identity – it is the username, the login credential, and
the password-reset channel – changing it is the single most dangerous
self-service operation in mibudge. This document explains the flow and
the reasoning behind each protection.
The service layer is
app/users/email_change.py; the
EmailChangeRequest model is in
app/users/models.py.
The design assumes an attacker who has temporarily hijacked an authenticated session (stolen device, XSS, walked-up-to-an-unlocked laptop) but does not control the user’s mailbox. Such an attacker’s goal is to rotate the account’s email to an address they control: once that sticks, they own the password-reset channel and therefore the account, permanently.
The counter-design: every change must be verified by the new address, announced to the old address, and reversible by the old address for long enough that the legitimate owner will plausibly notice – even if they were on vacation when it happened.
request ──► verification email to NEW address (confirm link, 24 h)
└─► notification email to OLD address (revoke link)
confirm ──► User.email/username updated
└─► 7-day revocation window opens
revoke ──► (post-confirm) email reverted, all sessions invalidated,
security alerts to BOTH addresses
create_request()): the authenticated user submits
new_email. Two emails go out immediately: a verification link to
the new address, and a “this change was requested – click here if
this wasn’t you” notification with a revoke link to the old
address.confirm_request()): the new-address recipient clicks
the verification link within EMAIL_CHANGE_TOKEN_EXPIRY_HOURS
(24 h). The user’s email and username are updated and the
revocation window opens.revoke_request()): at any point before confirmation,
or up to EMAIL_CHANGE_REVOCATION_DAYS (7 days) after it, the
revoke link cancels the change. Post-confirmation revocation
reverts email/username to the old address, invalidates every
active session, and sends security alerts to both addresses.The key property: the revoke link stays valid for 7 days after an attacker confirms the change. Rotating the email does not lock the legitimate owner out of undoing it – the old inbox retains veto power for the whole window.
change_email in
app/users/api/v1/views.py refuses if
has_usable_password() is false. Accounts created via the invitation
flows start passwordless (see invitations.md);
requiring a password first ensures there is always a second credential
anchoring the account before its primary identifier can move. The same
gate applies to change-password.
The initiating endpoint carries RequiresInteractiveAuth
(app/users/permissions.py): a leaked
API key can read budgets, but it can never rotate the account’s email.
See authentication.md.
create_request() refuses while a previously confirmed change is
still inside its revocation window (RevocationWindowOpenError).
Without this lockout an attacker could chain changes –
victim@ → attacker1@ → attacker2@ – so that by the time the victim
clicks their revoke link it only undoes the last hop, or the earlier
window has been buried. The lockout guarantees at most one change is
ever in flight, and the old address’s revoke link covers it fully.
EmailChangeRequest.is_revocable
(app/users/models.py): before confirmation
the revoke link works even after the 24-hour verification token has
expired – token expiry should stop the change from being applied,
never stop the owner from cancelling it. After confirmation it works
until revocable_until.
Post-confirmation revocation blacklists every outstanding JWT refresh
token for the user (_invalidate_all_sessions() in
app/users/email_change.py). The
attacker’s hijacked session dies as soon as its short-lived access
token expires (≤ 60 minutes) and cannot be refreshed. Alerts go to
both addresses – the old one so the owner knows the revert
succeeded, the new one because a real person may sit behind it (e.g.
a typo’d address) and should know their address was involved.
The new_email is checked against existing accounts at request time
and again at confirm time (confirm_request()), because another
account could have claimed the address during the 24-hour gap.
EmailChangeRequest rows are never deleted; every request, confirm,
and revoke timestamp is preserved.
All emails in the flow render in the requesting user’s locale, never
a preference the (possibly attacker-controlled) session set on the fly
– otherwise an attacker could switch the account language before
attacking so the security notifications arrive in a language the owner
cannot read (the “language-lock” attack). See the notes on
_send_verification_email() in
app/users/email_change.py.
The links embedded in the emails point at Django GET views
(/users/email-change/{token}/confirm/ and .../revoke/, in
app/users/views.py), which process the
action and redirect the browser to an SPA result page
(/app/email-change/confirmed/, .../revoked/, or .../error/ with a
reason query param).
Native mobile apps can register the production domain as a Universal
Link (iOS) / App Link (Android); the OS then opens the app instead of
a browser, and the app extracts the token and calls the equivalent
REST endpoints. Both paths call the same service functions in
app/users/email_change.py, so the
business logic lives in exactly one place.
| Endpoint | Auth | Action |
|---|---|---|
POST /api/v1/users/me/change-email/ |
JWT (interactive) | Initiate a change |
POST /api/v1/users/me/change-email/{token}/confirm/ |
None (token is the credential) | Confirm |
POST /api/v1/users/me/change-email/{token}/revoke/ |
None (token is the credential) | Revoke |
GET /users/email-change/{token}/confirm/ |
None | Browser confirm link → SPA redirect |
GET /users/email-change/{token}/revoke/ |
None | Browser revoke link → SPA redirect |
Tokens are 64-character URL-safe secrets from
app/common/tokens.py (384 bits of
entropy) – the same token-is-the-credential design as
invitations.
Defined in app/config/settings.py:
| Setting | Default | Meaning |
|---|---|---|
EMAIL_CHANGE_TOKEN_EXPIRY_HOURS |
24 | How long the new-address verification link is valid |
EMAIL_CHANGE_REVOCATION_DAYS |
7 | How long after confirmation the revoke link is valid |
| Piece | Location |
|---|---|
| Service layer (all business logic) | app/users/email_change.py |
EmailChangeRequest model |
app/users/models.py |
| REST endpoints | app/users/api/v1/views.py (UserViewSet.change_email*) |
| Browser link views | app/users/views.py |
| URL registration | app/users/urls.py |
| Old-address notifications | EMAIL_CHANGE_REQUESTED / EMAIL_CHANGE_SECURITY_ALERT in app/users/notification_kinds.py |
| Settings knobs | app/config/settings.py |