DEV Community

Cover image for "Sign in with Google" Account Linking: Seven Rules That Stop Pre-Hijacking, as Tests
NET_DARK_BOI
NET_DARK_BOI

Posted on Fully Autonomous

"Sign in with Google" Account Linking: Seven Rules That Stop Pre-Hijacking, as Tests

Adding "Sign in with Google" to a service that already has e-mail and password accounts looks like a small feature. It creates a question that did not exist before: when Google says "this person is a@example.test" and an account with that address already exists, what happens to the account?

How a service answers that question is the subject of a 2022 USENIX Security paper, "Pre-hijacked accounts: An Empirical Study of Security Failures in User Account Creation on the Web" (Avinash Sudhodanan and Andrew Paverd). The authors analysed 75 popular services and found at least 35 vulnerable to one or more account pre-hijacking attacks, assuming an attacker who knows nothing but the victim's e-mail address.

What pre-hijacking is

In Microsoft's summary (MSRC, May 2022), pre-hijacking is an attack class in which someone registers a victim's e-mail address at a service before the victim does, then regains access after the victim starts using the account. The root cause MSRC names is that services often let users act on an account before confirming they own the e-mail address or phone number.

MSRC lists five variants:

  • Classic-Federated Merge: an account created with a password and one created through federated sign-in are combined insecurely by e-mail address, leaving two parties with access.
  • Unexpired Session Identifier: a session opened by the attacker survives the victim's password reset.
  • Trojan Identifier: the attacker links their own identifier (a federated identity, an e-mail address, a phone number) to the account and uses it to return later.
  • Unexpired Email Change: a pending e-mail change survives a password reset and is completed later.
  • Non-Verifying IdP: an identity provider that does not verify e-mail ownership lets an attacker create a matching account that the service then merges with the victim's.

The mitigation for merging is stated plainly: before combining accounts, the service should confirm that the user controls both. Where an account already exists for an address, the user proves control of it; where an identity provider is used, the service checks whether the provider verifies identifiers or does its own verification.

The decision as a function

The rules are easier to review as one function than as scattered conditions in a callback. This is a standalone model, small enough to test completely. claims is what the identity provider returned after the signature, audience, expiry and nonce checks; bySub and byEmail are the accounts found by the provider's stable subject id and by the e-mail address.

export function decideSignIn({ claims, bySub = null, byEmail = null }) {
  if (!claims.sub) return { action: 'reject', why: 'no subject' };
  if (claims.email_verified !== true) return { action: 'reject', why: 'the provider did not verify the address' };
  if (bySub) return gate(bySub, { action: 'sign-in' });                    // identity = subject, not the e-mail text
  if (!byEmail) return { action: 'create', passwordUsable: false, emailVerified: true };
  // MSRC: before combining accounts, confirm the user controls both. A verified provider address proves the mailbox,
  // not control of the existing account, so the link waits for a sign-in to that account (or a confirmation link).
  if (byEmail.emailVerified) return gate(byEmail, { action: 'confirm-link', proveExistingAccount: true, keepPassword: true, notifyOwner: true });
  // An unverified account was created by someone who never proved the address:
  return gate(byEmail, { action: 'adopt', discardPassword: true, markVerified: true });
}

function gate(account, result) {
  if (account.role === 'admin') return { action: 'reject', why: 'administrators use password and a second factor' };
  if (account.locked) return { action: 'reject', why: 'account is locked' };
  if (account.totp) return { ...result, requireSecondFactorBeforeSession: true };
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Seven rules, each with a test

Twelve table-driven tests ran against the function (12 of 12 passed). They cover the seven rules below.

# Rule Test case Result
1 Trust the address only if the provider verified it Provider returns email_verified: false for an address that has an account Rejected
2 Identity is the stable subject, not the e-mail text Known subject, while a different account has the same address Signs in as the subject's account
3 Link a verified account only after the user proves control of it Verified account, same address Link waits for a sign-in to that account (or a confirmation link); password kept; owner notified
4 Adopt an unverified account, and discard what its creator set Unverified account, same address Adopted; password discarded; address marked verified
5 A new address creates an account that has no usable password New address Created with an unusable password
6 Some accounts never sign in this way Administrator, locked account Rejected
7 A second factor still comes before the session Account with TOTP, including an unverified one Second factor required before a session starts

Rule 3 follows MSRC directly. Before combining accounts, the service confirms that the user controls both. A provider that verified the address proves control of the mailbox today, not of the account that already exists, so the link is completed only after the person signs in to that account with its own credentials (and second factor), or follows a confirmation link sent to the address. The existing owner is notified either way.

Rule 4 is the pre-hijacking defence. An account that was created but never verified belongs to nobody yet. Whoever registered it chose its password, and may be someone other than the address's owner. When the provider proves the address belongs to the person now signing in, the account keeps the history but loses the password chosen by its creator. Linking it with the password intact would be the Classic-Federated Merge. The password set by the first registrant is exactly what the attacker keeps if the service merges silently.

Rule 2 comes straight from the OpenID Connect specification. sub is defined as "a locally unique and never reassigned identifier within the Issuer for the End-User", and section 5.7 says sub and iss are "the only Claims that an RP can rely upon as a stable identifier for the End-User". An e-mail address can change, and an issuer may reuse one over time. A service that selects the account by the address text alone can attach a new person to an old account.

Rule 1 closes the Non-Verifying IdP variant: the standard email_verified claim is defined as "True if the End-User's e-mail address has been verified; otherwise false", so a provider's assertion about an address counts only when the provider says it verified it, and the check is on the claim itself, not on the provider's name.

Rule 5 is a quiet consequence. An account created through the provider has no password, so a later "Forgot password" flow is the only way to set one, and that flow proves control of the mailbox. Operations that normally need the current password (deleting the account, changing the password) need an alternative proof for such accounts; an e-mailed code is one.

What the other variants need around the function

The decision function covers merging. The other variants live in the neighbouring flows, and each has a matching check:

Variant Check around it
Unexpired Session Identifier Password reset ends other sessions. An unverified account cannot hold a session at all, because login requires a verified address.
Unexpired Email Change Pending e-mail changes are cancelled on password reset, and are short-lived.
Trojan Identifier The list of linked sign-in methods is visible to the owner, and linking a new one notifies them.
General Unverified accounts are deleted after a short period (48 hours in one implementation) and registration is rate limited.

MSRC's defence-in-depth list adds: end other sessions on reset, cancel pending e-mail changes, show users their linked identifiers, keep e-mail-change confirmations short-lived, prune unverified accounts quickly, use bot detection against automated sign-ups, and invalidate sessions created before multi-factor authentication was enabled.

A test list for your own service

Each row is something an authorised tester arranges in an environment they own, with the expected result.

Arrange Expected
Register an account with an address you control and never verify it. Then sign in through the provider with that address The registration password no longer works; the account is verified
Sign in through the provider with an address the provider reports as unverified No account is created or linked
Sign in through the provider with an address that has a verified password account Nothing is linked until you sign in to that account (or confirm by e-mail); then the two are linked, the owner receives a notice and the password still works
Change the address at the provider, keep the same subject Still the same account
Sign in through the provider as an administrator or a locked account Refused
Sign in through the provider as an account with a second factor No session before the code
After linking, reset the password Other sessions end; no sign-in method the owner does not recognise remains

Practise the review

Breachloom has a hands-on exercise on the way account weaknesses chain together, Multi-step account takeover chain, with a matching chapter, Three weak links become one account takeover. For the recovery flow that rule 5 depends on, see Password Reset Security: Review the Account-Recovery Flow; for the step after sign-in, Passkey Login Security: Demo and Test Checklist. The chapters and the free IDOR case need no account; the full set of challenges is part of a paid plan.

Does your service have an account that exists but was never verified? Look at what a provider sign-in does with it. That one branch is where the paper found most of the problems.

Sources

Top comments (0)