← Back to blog

Why social login creates duplicate accounts, and how a lock table fixes it

Published on
4 mins read
--- views

Social sign-in looks like a solved problem. The provider redirects back with a code, you exchange it for a profile, you find or create a user, you log them in. Four steps, all of them documented.

Then support starts merging duplicate customers by hand.

The failure

An authorisation callback is an ordinary HTTP request, and ordinary HTTP requests happen more than once. A user double-taps the provider's confirm button. A flaky mobile connection makes the browser retry. A link prefetcher warms the URL before the user has finished tapping it. Any of these can deliver the same callback twice, milliseconds apart.

Both requests carry the same authorisation code. Both exchange it. Both look up the user by email. Both find nothing, because neither has finished writing yet. Both create an account.

You now have two customers with the same email address. If your provider returns no email, you get two accounts with near-identical login names and no way to tell which one holds the order history.

Why the obvious fix is not a fix

The instinct is to guard the write:

if (!email_exists($email)) {
    wp_insert_user([...]);
}

This does not help, and understanding why is the whole point. The check and the write are two separate operations against the database, and nothing stops the second request from running its check in the gap between the first request's check and the first request's write. The window is small. It is not zero, and callbacks arrive in exactly the bursts that find it.

A unique index on the email column is a real improvement, because the database will refuse the second insert. But it converts a data problem into an error page: the second request now fails, and it is the request the user's browser is actually watching. They see an error after a successful sign-in.

Claim the code, not the user

The insight is that the thing which is genuinely unique here is not the user. It is the authorisation code. Providers issue a code per sign-in attempt, and it is single-use by design.

So claim it before doing anything else:

public function acquire(string $code): bool
{
    if ($this->checkCodeIsProcessing($code)) {
        return false;          // somebody else already owns this sign-in
    }
    $this->setCodeIsProcessing($code, true);
    return true;
}

The callback handler becomes:

$code = $request->get_param('code');

if (!$this->acquire($code)) {
    return $this->redirectByCode($code);   // follow the winner
}

$user = $service->authClientByCode($code);
$wp_user_id = $user->findOrCreateWpUser();

return $this->redirectByCodeAndUser($code, $wp_user_id);

The first request wins the claim and does the work. The second request loses, and instead of failing it waits for the winner to record the resulting user_id and redirect target, then issues the same redirect. Both browsers end up signed in as the same person. One sign-in, one account, no error page.

The claim row carries a hit counter rather than a boolean, so the row is only deleted once every expected request has been served. That matters when a provider legitimately calls back more than once.

What this does not solve

Two honest limitations.

It is not a distributed lock. checkCodeIsProcessing and setCodeIsProcessing are still two statements. On a single database with requests arriving milliseconds apart it closes the window in practice; under genuine concurrency at scale you want the uniqueness enforced by the database: a unique index on the code column, with the insert itself as the claim, and a caught constraint violation as the "you lost" signal.

The waiting request sleeps. Following the winner means giving it time to finish. That is a fixed pause, which is crude: too short and the follower finds nothing, too long and a user waits for no reason. It is bounded and it is on the losing request only, but it is a compromise.

The general shape

This pattern is not about OAuth. It shows up wherever an external system calls you back and the callback is not guaranteed to be delivered exactly once. Payment notifications, webhook deliveries, queue consumers.

The rule is the same each time: find the identifier the external system considers unique, claim it in one place before doing any work, and make the loser follow the winner instead of failing. Deduplicating on the result (the user, the order, the record) is always a race, because the result does not exist yet at the moment you need to check for it.

Open for contract collaboration

I am available for contract-based collaboration. If you have an interesting project idea, schedule a call via Calendly.

Schedule a 30-min call