In this article
October 12, 2026
October 12, 2026

Three OAuth account takeovers, one bug: Auto-linking by email

Pingvin Share X, Better Auth and AshAuthentication each attached a stranger's OAuth sign-in to someone else's account. Here's how each check failed and what a safe linking decision looks like.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

On October 9, 2026, CVE-2026-108157 was published for Pingvin Share X, a self-hosted file sharing app. The bug lets an unauthenticated attacker sign in as any user, administrators included, and skip their TOTP second factor along the way. The cause sits in one line of decision logic: when an OAuth sign-in arrived with an email that matched an existing account, the app linked the two.

It is the third account takeover with the same shape in four months. Better Auth shipped a fix for CVE-2026-53516 in July. AshAuthentication fixed CVE-2026-88952 in September. Each library got a different part of the check wrong, which makes them a useful set to study together.

Why apps auto-link at all

The feature exists for a good reason. A user signs up with a password, forgets about it, and months later clicks "Continue with Google." If the app creates a second account, the user lands in an empty workspace and files a support ticket. So the app sees the same email address, decides it is the same person, and attaches the Google identity to the existing account.

The problem is the word "decides." An email address in an OAuth or OIDC response is a claim made by whoever runs the identity provider, about an address the app has never checked itself. The OpenID Connect spec is direct about this in section 5.7:

Therefore, other Claims such as email, phone_number, preferred_username, and name MUST NOT be used as unique identifiers for the End-User, whether obtained from the ID Token or the UserInfo Endpoint.

The only stable identifier the spec guarantees is the pair of iss (issuer) and sub (subject). Auto-linking by email means using a non-unique, provider-asserted value to decide who owns an account. That can be done safely, but only if several separate conditions all hold. Each of these CVEs dropped a different one.

The three failures

Library CVE (published) What the link check missed Fixed in
Pingvin Share X CVE-2026-108157 (Oct 9, 2026)
CVSS 4.0: 9.2
Never checked email_verified from generic OIDC providers 1.22.0
Better Auth CVE-2026-53516 (Jul 7, 2026)
CVSS 3.1: 8.3
Checked the provider's verification, not the local account's 1.6.11
AshAuthentication CVE-2026-88952 (Sep 17, 2026)
CVSS 4.0: 9.1
Never compared the provider's email with the matched account's email 4.15.0, 5.0.0-rc.14

1. Pingvin Share X: trusting an email nobody verified

According to the VulnCheck advisory, OAuthService.signUp() linked incoming OAuth identities to existing accounts by email, and the generic OIDC provider never checked the email_verified claim. An attacker who could create an account at any enabled OIDC provider with the victim's address, unverified, could sign in to Pingvin as the victim.

OIDC defines email_verified as "True if the End-User's e-mail address has been verified; otherwise false." Ignoring it treats every address a provider passes along as proven. Affected versions run from 0.19.0 up to, but not including, 1.22.0.

The TOTP bypass deserves its own note. The OAuth path created a fully authenticated session without asking for the second factor the victim had set up. Linking a new identity is a change to how an account can be accessed, so it should never be easier than signing in with the account's existing factors.

2. Better Auth: checking one side of the link

Better Auth did check email_verified. The advisory explains that the auto-link gate in handleOAuthUserInfo allowed a link whenever the provider asserted email_verified: true, without also requiring the local user row's emailVerified to be true.

That leaves room for a pre-hijack:

  1. The attacker signs up with the victim's email and a password the attacker chooses. The local row is created with emailVerified: false.
  2. Later, the victim signs in with a real, verified OAuth account at that address.
  3. The provider says the email is verified, so the victim's OAuth identity is linked to the attacker's row.
  4. The attacker now holds a working password login to an account the victim believes is theirs.
Sequence diagram titled "The pre-hijack behind CVE-2026-53516" with four lanes: Attacker, Your app, OAuth provider and Victim. Step 1: the attacker signs up to your app with victim@example.com and a password the attacker picks; the app creates a user row with emailVerified: false. After a "Later" divider, step 2: the victim clicks "Continue with Google" at the OAuth provider. Step 3: the provider sends victim@example.com with email_verified: true to the app. Step 4, highlighted in red: the app decides the emails match and the provider says verified, so it links the identity; it never checked the row's own emailVerified flag. Step 5: the victim is signed in to what looks like their account. Step 6: the attacker signs in with the password and reaches the same account with full access.

The provider's verification only proves the person signing in controls the inbox. It says nothing about who created the local account. Better Auth fixed this in 1.6.11. The advisory compares it to Microsoft's 2023 nOAuth issue, where apps trusted a mutable, unverified email claim from Entra ID.

3. AshAuthentication: matching on the wrong thing

AshAuthentication's bug comes from the opposite direction. Per the advisory, the OAuth2 sign-in path matched an existing account through its identity upsert keys, and the only gate was the provider's email_verified flag. It never compared the provider's email with the email on the account it had matched. The same gap existed in the sign-in path when registration was disabled.

The upsert then overwrote the victim's email with the attacker's. That turns a one-time sign-in into persistent control, because password reset and other recovery flows now go to the attacker's inbox. Fixed versions are 4.15.0 and 5.0.0-rc.14.

Same week, different step of the callback

Two WordPress plugins had CVEs published on October 10 that are not linking bugs, but they fail in the same place: the code that runs when an identity provider sends the user back.

  • WPCOM Member (CVE-2026-104803, CVSS 3.1: 9.8, versions up to 1.7.27): the social login callback lacked nonce validation, OAuth state verification and per-visitor session isolation. An attacker who knows or can discover a user's social provider ID can plant a forged session entry and sign in as that user.
  • WPO365 (CVE-2026-104759, CVSS 3.1: 8.1, versions up to 44.1, fixed in 45.0): a deprecated ID token path verified the OIDC nonce with a function that could never accept the nonce format the plugin generated. The check failed, and authentication continued anyway, so a previously issued ID token could be replayed. It is exploitable only when the use_id_token_parser_v2 option is on.

The WPO365 bug is a good reminder that a verification step which cannot pass is worse than none, if its failure does not stop the flow. Every check in a sign-in callback has to fail closed.

What a safe linking decision looks like

Taken together, the three linking CVEs map out the conditions that have to hold before an incoming identity can join an existing account. In order:

  1. Look up by iss and sub first. If this exact identity is already linked, sign the user in. Email plays no part.
  2. Treat an email match as a hint, not a decision. A matching email only means the two accounts might belong to the same person.
  3. Require proof on both sides. The provider must assert a verified email, and you should only trust that assertion from issuers that are authoritative for the address. The local account's email must also be verified by you. (Better Auth missed the second half.)
  4. Compare the emails you are actually linking. The address in the provider response must match the address on the account you found, after normalization. (AshAuthentication missed this.)
  5. When in doubt, make the user prove they own the existing account. Ask them to sign in with their existing method, including MFA, or enter a one-time code sent to the account's inbox, before you attach the new identity.
  6. Never let linking change account data. Don't overwrite the stored email, roles or recovery settings from provider claims during a link.
  7. Tell the user. Send a notification to the account's existing email whenever a new sign-in method is added.
Flowchart titled "A safe auto-linking decision." It runs top to bottom through seven boxes. First: callback checks pass (state, nonce, signature, audience), and any failure stops the sign-in; a red tag says WPCOM Member and WPO365 failed here. Second: is this (iss, sub) already linked to an account? Yes leads to "Sign in." Third: does an account with this email already exist? No leads to "Create account." Fourth: does the provider assert email_verified, and is it authoritative for this domain? No leads to "Step up"; a red tag says Pingvin Share X skipped this. Fifth: did you verify the existing account's email yourself? No leads to "Step up"; a red tag says Better Auth skipped this. Sixth: does the provider's email match the matched account's email? No leads to "Step up"; a red tag says AshAuthentication skipped this. Seventh: has the user completed the account's MFA? No leads to "Require MFA"; a red tag says Pingvin Share X skipped this too. If every answer is yes, the final green box says: link (iss, sub) to the account, don't change its email, roles or recovery settings, and notify the user. A footnote explains that "Step up" means the user proves they own the existing account by signing in with its existing method, including MFA, or by entering a one-time code sent to its inbox.

Here is the same logic as pseudocode:

  
async function resolveOAuthSignIn(claims, issuer) {
  // 1. Stable identity first: (iss, sub)
  const linked = await findIdentity(issuer, claims.sub);
  if (linked) return signIn(linked.userId);

  const existing = claims.email
    ? await findUserByEmail(normalize(claims.email))
    : null;

  // No account with this email: create a new one
  if (!existing) return createUserWithIdentity(issuer, claims);

  // 2-4. An email match is only a hint
  const canAutoLink =
    claims.email_verified === true &&
    isAuthoritativeFor(issuer, claims.email) &&
    existing.emailVerified === true &&
    normalize(existing.email) === normalize(claims.email);

  if (!canAutoLink) {
    // 5. Make them prove ownership of the existing account first
    return requireStepUp(existing.id, { pendingIdentity: { issuer, sub: claims.sub } });
  }

  // Still require the account's own second factor before linking
  if (existing.mfaEnrolled) {
    return requireMfa(existing.id, { pendingIdentity: { issuer, sub: claims.sub } });
  }

  // 6. Link without touching email, roles or recovery settings
  await linkIdentity(existing.id, issuer, claims.sub);
  await notifyNewSignInMethod(existing.id, issuer); // 7.
  return signIn(existing.id);
}
  

The isAuthoritativeFor check matters most for generic OIDC. Google asserting a verified @gmail.com address is meaningful. A self-hosted or arbitrary OIDC provider asserting it about any domain is not, because whoever runs that provider decides what it says.

Checklist

  • Identities are stored and looked up by (iss, sub), never by email alone.
  • Auto-linking requires a verified email from the provider and a verified email on the local account.
  • The provider's email is compared with the matched account's email before linking.
  • You only trust email_verified from issuers that are authoritative for the domain.
  • Linking can't skip the account's existing MFA.
  • Linking never overwrites the stored email or recovery settings.
  • Users are notified when a new sign-in method is added.
  • Every callback check (state, nonce, signature, audience) fails closed.
  • If you run Pingvin Share X, Better Auth, AshAuthentication or WPO365, you're on a fixed version (see the table above).

How AuthKit handles linking

AuthKit also uses the email address to link credentials, so it has to answer the same questions. Per the identity linking docs, a new credential is attached to an existing user only when WorkOS can verify the user has access to that email inbox. If an identity can't be linked safely, the sign-in doesn't complete.

For enterprise SSO, verified domains change the picture: when an organization has verified its domain, SSO users on that domain are treated as verified. SSO users on unverified domains go through email verification before their SSO credential is linked. When Directory Sync and SSO describe the same person, WorkOS matches on the identity provider's user ID before falling back to verified email, so an email change on one side doesn't create a duplicate user.

For the background on why identity linking is risky in the first place, see Lessons in safe identity linking and Stop using email as a primary key.

The takeaway

Auto-linking by email is a convenience feature that makes an ownership decision. Each of these libraries checked something, and each attacker went through the condition that wasn't checked. If your app links accounts automatically, read the code that makes that decision against the checklist above. It is usually short, and that is exactly why it's easy to get wrong.