All articles Fraud & ATO

Why Google and GitHub Login Don't Stop Multi-Accounting

Teams often add “Sign in with Google” or “Sign in with GitHub” partly as a fraud control. The reasoning sounds right: Google has already verified the person, GitHub accounts take effort, so social sign-ups must be more real than email-and-password ones.

They are more convenient. They are not more real. A social provider tells you that someone controls an account at that provider. It says nothing about whether that person already has three accounts in your product.

What OAuth actually verifies

An OpenID Connect ID token gives you a provider subject (sub), an email, usually an email_verified flag, and a name. That proves:

  • The user could log in to that provider just now.
  • The provider believes the email belongs to that provider account.

It does not prove the human is new to you. Creating a second Google account is free. GitHub accounts take a minute. Aged accounts with history are bought and sold specifically to pass “this account looks established” checks. Some providers let one person manage several accounts from the same browser profile with a click.

So the multi-accounting problem moves, it doesn’t disappear. Instead of [email protected], the abuser uses a second Google account. Instead of a disposable inbox, a bought GitHub account. The cost per identity goes up a little; the multiplier stays.

The device is the same either way

What doesn’t change between the first and fifth OAuth account is the laptop. A stable device identifier links those accounts the same way it links email sign-ups. Prynt’s visitorId persists across cleared cookies and private windows, and accountsOnDevice on each event lists every account you’ve linked to that device.

The tricky part with OAuth is not the signal. It’s the flow: the user leaves your site, comes back through a callback, and the callback must decide between “new account” and “returning user.”

Carry the requestId across the redirect

Identify on your sign-in page before the redirect, and store the requestId server-side:

<script src="https://api.pryntid.com/cdn/prynt.umd.js"></script>
<script>
  const ready = Prynt.load({ apiKey: 'pk_live_…' })
    .then((a) => a.identify({ tag: { action: 'oauth_start' } }))
    .catch(() => null);

  document.querySelectorAll('[data-oauth]').forEach((btn) =>
    btn.addEventListener('click', async (e) => {
      e.preventDefault();
      const r = await ready;
      const q = new URLSearchParams({ rid: r?.requestId || '' });
      location.href = `/auth/${btn.dataset.oauth}/start?${q}`;
    }));
</script>
// /auth/:provider/start
app.get('/auth/:provider/start', (req, res) => {
  const state = crypto.randomUUID();
  req.session.oauth = { state, pryntRequestId: req.query.rid || null };
  res.redirect(buildAuthorizeUrl(req.params.provider, state));
});

Keeping it in the session (or an HttpOnly cookie bound to the state) means the callback reads it from your own storage, so it can’t be swapped on the return trip from the provider. Whoever controls the browser can still send any requestId to /start, which is why the callback below checks that the event is recent and not already attached to another account. Otherwise one clean identification could be replayed for every sign-up.

Decide only on new accounts

In the callback, look up the provider subject first. That single branch is what protects returning users:

import { PryntServer } from '@prynt/node';
const prynt = new PryntServer({ secretKey: process.env.PRYNT_SECRET_KEY });
const LIMIT = 2;

app.get('/auth/:provider/callback', async (req, res) => {
  const { state, pryntRequestId: rid } = req.session.oauth || {};
  if (!state || req.query.state !== state) return res.status(400).send('bad state');

  const profile = await exchangeCodeForProfile(req.params.provider, req.query.code);
  let user = await db.identities.findUser(req.params.provider, profile.sub);

  if (!user) {
    // New account: this is the only place the device limit applies.
    let action = 'allow';
    if (rid) {
      const ev = await prynt.getEvent(rid).catch(() => null);
      const stale = !ev || Date.now() - Date.parse(ev.createdAt) > 15 * 60 * 1000;
      if (ev?.linkedId) action = 'block';   // requestId already used for another account
      else if (stale) action = 'flag';      // unknown, unreachable or old: treat as missing
      else if (ev.decision === 'block' || ev.accountsOnDevice.count >= LIMIT) action = 'block';
    } else {
      action = 'flag';
    }
    if (action === 'block') return res.redirect('/signup/unavailable');
    user = await db.users.createFromOAuth(req.params.provider, profile, { flagged: action === 'flag' });
  }

  // New or returning: link the device so the next sign-up sees this account.
  if (rid) await prynt.updateEvent(rid, { linkedId: user.id }).catch(() => {});
  startSession(req, user);
  res.redirect('/app');
});

Three properties make this safe for real users:

  • A returning user is never refused for the count. Their own account is already on the device. If you checked the count on every login, a person with a personal and a work account would be locked out of one of them.
  • Linking happens on every login. That keeps accountsOnDevice current, so a farm that logs in to old accounts from a new laptop still gets connected.
  • A missing requestId flags rather than blocks. Script blockers and Global Privacy Control produce empty ids for honest users, and Prynt’s agent honors GPC by default.

If you use Auth0, note that its Pre User Registration trigger doesn’t fire for social connections. That is why the Prynt Auth0 recipe re-checks on the first Post Login and denies there instead. Supabase’s Before User Created hook and the Clerk user.created webhook do see social sign-ups. Whatever your provider, check that your enforcement point actually runs for OAuth.

Linking accounts across providers

One more gap: a user who signs up with Google today and GitHub tomorrow creates two accounts unless you link by verified email. Many apps do link identities that share a verified email, which is good for users. It doesn’t help against abuse, because the abuser uses different emails. The device check covers both cases, because it doesn’t care which provider minted the identity.

Watch the takeover side too

Social login has its own account-takeover risks: a compromised Google account is a compromised account in your app. A device you have never seen, on a login that used to come from one stable device, is worth a step-up. That is covered in OAuth and social login takeover detection and on the account takeover page.

Rolling it out

Run the callback logic in flag-only mode for a week and look at how many new OAuth accounts land on devices that already hold one or two. That number tells you whether a limit of 2 is generous or strict for your audience. The reasoning behind per-device limits, and when to prefer verification over refusal, is in device-based sign-up limits and multi-accounting detection.

Social login is a good feature. Keep it. Just don’t count it as a fraud control. Put the device check in the one branch where a new account is created, and both your users and your trial budget come out ahead.

Try it free

Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.

Keep reading