DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

Account Pre-Hijacking via Social Sign-In

Account pre-hijacking inverts the usual order. The attacker does not break into an existing account; they create one first, using the victim’s email address, and wait. When the victim later signs in with Google or GitHub, the system helpfully merges them into the account the attacker already holds — and both parties now have a key.

The attack, in order

The class was named and characterised by Avinash Sudhodanan and Andrew Paverd in “Pre-hijacked Accounts”, presented at USENIX Security 2022, which tested the flows of many popular sites and found the pattern widespread. The paper is worth reading in full if you own a signup flow. The core sequence is five steps and needs no vulnerability in the usual sense — every step is the system working as designed.

  1. The attacker registers an account at the target using [[email protected]](https://multigrid.ai/cdn-cgi/l/email-protection), an address they do not control, and chooses a password. Most signup flows permit this: the confirmation mail goes to the victim, who ignores it, and the account exists regardless.
  2. The account sits unverified and unremarkable. There is nothing in it to steal, so nothing raises an alarm.
  3. Weeks or months later the victim arrives at the same product and presses “Continue with Google”. Google asserts a verified address, which is real proof — the victim does control that mailbox.
  4. The product looks the address up, finds the attacker’s row, and links the identity to it rather than creating a new account. The victim is signed in and sees an empty, normal-looking workspace.
  5. The attacker signs in with the password they set in step one, which was never re-proved on the way in, and reads everything the victim has since put there.

There is a second sting in step four that is easy to miss. In most implementations the provider’s assertion is what sets the account’s verified-address flag. So the victim’s arrival is what promotes the attacker’s squatted row to a fully verified account — and if a verified address is what gates billing, onboarding or an API-key allowance, the attacker’s account becomes privileged at the exact moment its real owner walks in and starts using it.

The five variants

The paper separates the class into five, and the distinction is practical rather than taxonomic: they are closed by different changes, and a fix for one leaves the others standing.

Variant Description
classic-federated merge The one above. The attacker creates a password account on the victim's address; the victim's federated sign-in is merged into it; both hold credentials afterwards. Closed by never merging into an account whose address was not independently proved.
unexpired session The attacker creates the account and keeps a session alive — a scheduled request every few minutes is enough. The victim recovers the account through 'forgot password' and changes the password, but the attacker's session was never invalidated. Closed by dropping every other session on any credential change.
trojan identifier The attacker creates the account and attaches a second identifier they control: their own phone number, a recovery email, a linked social account. The victim resets the password, but the trojan identifier is still attached and still initiates a valid recovery. Closed by clearing unproved identifiers on recovery, and by requiring re-proof to attach one.
unexpired email change The attacker starts a change of the account's address to one of their own. The confirmation link is issued but not yet clicked. The victim takes the account over; the attacker then clicks the pending link and the address moves to them. Closed by expiring pending changes on any credential event, and by re-checking at redemption that the requester still holds the account.
non-verifying identity provider The attacker signs in with an identity provider that does not actually verify addresses, or that returns a user-editable profile field, and claims the victim's address that way. Closed by refusing an assertion whose verified flag is false or absent — and by knowing which field carries it.

The last one deserves a concrete note, because it is where an implementation quietly picks the wrong field. GitHub’s /user profile object has an email property that is user-editable free text with no verification flag at all; the list at /user/emails is the one that carries verified. Reading the first is not a subtle mistake — it means anyone who can spell a customer’s address can sign in as them. Google returns email_verified per address on the userinfo endpoint, and it can be false.

An unverified assertion must be refused for creating an account as well as for claiming one. Allowing it to create a fresh account lets an attacker squat a colleague’s address — blocking the real owner from ever signing up with it — and leaves a row your own records describe as confirmed when nobody confirmed it.

Why an email address is the wrong key

Step four’s lookup is the design decision. Matching a returning federated user on their email address answers the question “who currently holds this mailbox” when the question that matters is “whose account is this”. Those come apart in both directions.

An address changes. A developer whose GitHub primary address was at a former employer moves it to a personal one, presses the same button they always press, and lands in a brand-new account with a zero balance and none of their history. Their API keys keep working, so nothing warns them; support gets a ticket that reads like a data-loss incident.

An address is also reassignable. Corporate mailboxes are handed to the next person in the role. A domain lapses and is re-registered. Some providers have recycled usernames. In each case the address that once proved one person now proves a different one, and every account keyed on it moves with the mailbox.

The provider’s subject identifier has neither property. GitHub’s numeric user id and Google’s OIDC sub are stable for the life of the account and are never reassigned. That is precisely what they exist for, and it is what makes them the right join key.

Keying on the provider’s subject

The schema change is small. One table, one unique index, and the index is the whole point of it: two rows claiming one provider account would mean two of your users can be signed in as the same person.

CREATE TABLE user_identities (
  id            TEXT PRIMARY KEY,
  user_id       TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  provider      TEXT NOT NULL,           -- 'github' | 'google'
  subject       TEXT NOT NULL,           -- 'github:1234', 'google:<sub>'
  email         TEXT,                    -- at link time. Informational; never matched on.
  created_at    INTEGER NOT NULL,
  last_login_at INTEGER
);

CREATE UNIQUE INDEX user_identities_provider_subject_idx
  ON user_identities (provider, subject);
Enter fullscreen mode Exit fullscreen mode

Namespacing the subject with the provider matters more than it looks: github:123 and google:123 are different people, and a subject column without the provider in the key eventually collides. The unique index across the pair is what makes that safe.

The sign-in callback then asks its questions in a fixed order.

  1. Refuse an unverified assertion outright. No verified flag, no address, no sign-in. This has to come first, because every branch below trusts the address.
  2. Look up the identity row by (provider, subject). If it exists, this is a returning user: sign them in, and stop. No address is consulted, so a customer who changed their GitHub email lands in their own account.
  3. Only then fall back to the address, which is the one-time path for an account created with a password that is now connecting a provider for the first time — and write the identity row as you go, so this branch never runs again for that provider account.
  4. Otherwise create a new account and link the identity immediately.

One thing the returning-user branch should not do is repoint the account’s own email address to whatever the provider now reports. That address is the sign-in handle, the password-reset destination and the receipt address; moving it because a third party’s profile changed is not the product’s call to make. Record the new address on the identity row for support, and leave the account with the one its owner chose.

If a product already has live federated accounts keyed on email, introducing this table is a migration rather than an edit: existing users have no identity row, so the address fallback runs once per person and backfills. Doing it before any social account exists costs nothing; doing it a year later means the same change plus merging duplicate accounts holding real balances by hand.

The first link is the dangerous one

Keying on the subject closes the returning-user case completely, and it does not close pre-hijacking. Step three of the attack is a first sign-in — there is no identity row yet, so the address fallback runs, and that is the branch the attacker was waiting for.

The justification usually written next to that fallback is that a provider-verified address is the same proof a password-reset flow accepts. That is the right standard, and the code as usually written does not meet it. A password reset also replaces the password and destroys every session, so the previous holder is evicted. The social fallback typically does only the first half: it lets the new person in and leaves the old credentials working.

Doing the other half is the fix, and it applies only where the existing account’s address was never proved:

if (existing) {
  if (!existing.emailVerifiedAt) {
    // Local credentials on an unproved address are exactly that: unproved.
    // Same eviction a password reset performs, for the same reason.
    await db.update(users)
      .set({ emailVerifiedAt: now, passwordHash: null })
      .where(eq(users.id, existing.id));
    await db.delete(sessions).where(eq(sessions.userId, existing.id));
    await dropPendingLoginsFor(existing.id);   // half-authenticated 2FA tickets
  }
  await linkIdentity(existing.id, provider, identity.subject, email);
  // ...sign in
}
Enter fullscreen mode Exit fullscreen mode

Three details in that block are each doing a job. Clearing the password hash rather than rotating it means the squatter’s credential is gone rather than merely changed. Deleting sessions closes the unexpired-session variant. Dropping pending second-factor tickets closes the half-authenticated state a 2FA flow leaves behind — a ticket that has passed the password step and is waiting for a code is a credential, and it survives a session purge that only looks at the sessions table.

The cost is real and worth stating: a legitimate user who signed up with a password, never confirmed their address, and later connects a provider loses that password. They recover it through the reset flow, which now works, because the provider just verified the address. That is the smaller cost. Accounts whose address is already verified never enter this branch at all, so the ordinary “I signed up months ago and am now connecting Google” case is untouched.

The alternative design, worth knowing because it suits products with a different signup flow, is to refuse the merge entirely and require the person to sign in with the password first and link the provider from inside the account. That is strictly safer and it costs a sign-in the user may not be able to complete — which is why the eviction branch is the more common answer where signup does not prove the address.

Nothing of value before a proved address

The structural version of this defence is not in the auth code at all. Pre-hijacking is only worth an attacker’s time if an account accumulates something before its address is proved. Auditing what an unverified account can hold is a five-minute exercise that changes the value of the whole attack.

The tempting conclusion is to require confirmation at signup, and for many products that is wrong — it costs the developer evaluating your tool on a Saturday, who bounces at “check your inbox”. The more precise rule is to place the requirement at the moment the address stops being a login handle and becomes the receipt destination and the recovery path. In a paid product that moment is the first payment.

Wherever the line is drawn, it has to be drawn in both places. A rule enforced on the form and not on the JSON endpoint that does the same thing is a rule you skip by changing the content type — the same symmetry problem that makes rate limits on one surface and not another worthless.

What to assert

These are cheap tests against a real database and they fail loudly when somebody reorders the callback.

  • The unique index refuses two users claiming one provider account. Insert two identity rows with the same (provider, subject) and different user_id, and assert the second one throws. This is the constraint the whole table exists for.
  • github:123 and google:123 coexist. The complementary case, and the one that catches a subject column keyed without its provider.
  • A changed provider address still lands in the same account. Link an identity, change the address on the provider side, sign in again, assert the same user id and that users.email did not move.
  • The squat is evicted. Create an unverified password account on an address, complete a federated sign-in with that address, then assert three things: the password hash is null, the session count for that user is zero, and no pending second-factor ticket remains.
  • A verified account is not evicted. The false-positive half. Same flow with emailVerifiedAt set, and assert the password still works afterwards. Without this test, a later refactor that drops the if destroys every customer password on their next social sign-in.
  • An unverified assertion is refused for claiming and for creating. Two cases, one flag.
  • Identity rows cascade with the user. Deleting an account must not leave an identity row pointing at nothing, which would otherwise be a subject nobody can ever link again.

The two false-positive cases in that list — the verified account that must survive, and the coexisting subjects — are the ones worth writing first. A test suite that only proves the attack is blocked will happily pass on an implementation that blocks everything.

Related

Top comments (0)