How a Skipped Push Re-Registration Sent a Stranger's Order Notifications to a Hand-Me-Down Phone
09:14 UTC. A support ticket lands in the on-call queue, and its subject line does the paging for us: "got a shipping notification for an order I never placed." It auto-escalates to security-tagged anytime a ticket body contains the phrase "someone else's order," and today it does.
the setup
The relevant path is small. orders-api flips an order's status, drops a job on a
queue, and notifications-worker picks it up, looks up the customer's registered
device, and calls Expo's push API with a one-line summary. On the client, a React Native app
registers for push once, on first login after install:
useEffect(() => {
async function register() {
const already = await AsyncStorage.getItem('pushRegistered');
if (already === 'true') return; // token doesn't change, no need to redo this
const token = await registerForPushNotificationsAsync();
await api.post('/devices/register', { token });
await AsyncStorage.setItem('pushRegistered', 'true');
}
register();
}, []);
That comment is correct about the token. An Expo push token is tied to the app installation on a physical device, not to whoever's logged in. It survives logout, survives switching accounts, survives everything short of an uninstall. The bug is what the comment doesn't say anything about: which account the backend thinks that token belongs to.
the scramble
First theory: a template mix-up in notifications-worker, two push jobs queued back
to back and swapped mid-batch. Grepping the worker's logs for order #48213, the
order number the customer saw, shows a single send, correct order number, correct summary text,
addressed to the correct account internally. The message was right. Something else was wrong.
Second theory: Expo mis-delivered it, some kind of token collision on their side. Pulling the push receipt for that send shows one Expo push token, one successful delivery, no anomalies. The API did exactly what it was told. That's the part that turns a one-off complaint into a security-tagged ticket: the backend told Expo, correctly, to deliver order #48213's status to a specific token, and that token now sits on a phone belonging to someone who never ordered it.
the hunt
The customer, Iris, has account 8841. Order #48213 belongs to account
5502. Querying device_push_tokens for the token Expo just confirmed
delivering to:
SELECT user_id, updated_at FROM device_push_tokens
WHERE push_token = 'ExponentPushToken[7f2AqX...]';
user_id | updated_at
---------+---------------------
5502 | 2026-08-31 10:02:11
That row says the token belongs to account 5502, last touched five weeks before the ticket. Cross-referencing the login history for that same token's device fingerprint shows account 8841, Iris, authenticating on it eleven times since September 21st. Two accounts, one physical phone, and the database never noticed the handoff. Her own support message fills in the rest: the phone was her partner's, he switched to a new one and left the old one with her, and she'd been signed in and using it normally for over a week before the ticket.
the find
Nobody force-quit the app or reinstalled it between accounts, so pushRegistered
in AsyncStorage was still 'true' from her partner's original setup.
Iris logging out his session and logging into her own never touched that flag, so
usePushRegistration ran its early return every time, and /devices/register
was never called again on that device. The backend's mapping from token to account froze at
whatever it last was: account 5502, her partner, the previous owner of the phone.
Everything downstream worked exactly as designed against a premise that quietly stopped being
true. notifications-worker looked up account 5502's registered token to send him his
own shipment update, got the token correctly, and Expo delivered correctly, to a phone account
5502 hadn't held in over a month.
the fix
The client-side guard was solving a real problem, avoiding a redundant network call and an extra permission-prompt risk, but it was keyed on the wrong condition. A token not changing says nothing about whether the logged-in account changed. The fix drops the AsyncStorage gate and registers on every successful login, which is idempotent on the server either way:
useEffect(() => {
async function register() {
if (!session?.userId) return;
const token = await registerForPushNotificationsAsync();
await api.post('/devices/register', { token }); // always re-run on login
}
register();
}, [session?.userId]);
On the server, the upsert now reassigns ownership by token rather than inserting a second row per account, so a device can only ever map to whoever registered it most recently:
INSERT INTO device_push_tokens (push_token, user_id, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (push_token)
DO UPDATE SET user_id = EXCLUDED.user_id, updated_at = now();
As a safety net for app versions still running the old client, a nightly job flags any push token
whose updated_at in device_push_tokens predates the most recent login
event from a different account on the same device fingerprint, and revokes it, forcing a fresh
registration on next launch instead of leaving a stale mapping to silently keep working.
the aftermath
- A push token's identity and an account's identity are two separate facts. Code that treats "the token hasn't changed" as a stand-in for "the account hasn't changed" will be right until the exact moment a device changes hands, and wrong silently after that.
- Logout is often designed to clear session state and nothing else. Any client-side flag meant to prevent redundant work needs to be re-examined against every place a session can end, not just the ones the person who wrote it was thinking about.
- Idempotent registration on every login costs one extra network call per session. Weighed against a stale mapping that leaks order details to whoever's holding the phone next, it's not a close call.
- The 34 other stale-mapped tokens never generated a single support ticket. Iris's case surfaced because she happened to notice and happened to report it, not because the system detected anything on its own, which is exactly why the audit query now runs nightly instead of never.
No queue misfired, no template mixed up an order number, and Expo delivered precisely what it was asked to deliver. The whole incident lived in one boolean that outlived the assumption it was written for.