Social Login in Laravel: OAuth Without the Footguns
Every client asks for the Google button eventually. The request sounds small, and the happy path genuinely is small: Socialite, two routes, done in an afternoon. The part that bites is everything around the happy path, because a login method is really an account-identity feature, and identity edges are where users end up locked out or duplicated.
The happy path, briefly
Two routes and a redirect:
Route::get('/auth/{provider}/redirect', fn (string $provider) =>
Socialite::driver($provider)->redirect());
Route::get('/auth/{provider}/callback', function (string $provider) {
$oauthUser = Socialite::driver($provider)->stateless()->user();
// find or create the local account
});
The state parameter protects the callback against forged requests, and Socialite handles it behind the scenes. Keep that protection on in production even if you turn it off in local testing to avoid re-registering callback URLs.
Never merge accounts on email alone
This is the decision most teams get wrong, and it is expensive to reverse. A user signs up with email and password in March, comes back in September through Google with the same address, and now you have two accounts and half their data in each.
The safe rule: match on OAuth identity first. Store provider and provider_id (usually the nullable github_id style columns from Socialite), mark the combination unique, and match on it. When an OAuth login finds no identity row, do not silently grab an existing account with the same email. Two options that work:
- Ask. Log the person into the account they just authenticated with and show a short screen: "We found an account with this email. Enter your password to connect it." One extra step, zero surprises.
- Auto-link on a verified email. If the provider says the email is verified and the local account is verified too, linking is defensible. I still prefer asking, because "verified" means different things across providers.
That last point deserves its own trap warning: not every provider email is a real inbox. GitHub happily serves 12345+username@users.noreply.github.com, and some providers let a person register a mailbox they never confirmed. Treat email_verified as data you check per provider, and never dispatch your password-reset or notification mail into an address you have not confirmed can receive it.
What to store, and what not to
$schema->table('users', function (Blueprint $t) {
$t->string('google_id')->nullable()->unique();
$t->timestamp('google_linked_at')->nullable();
});
Store the provider identifier and a timestamp. If the feature actually uses the API after login (Google Calendar, reading repo names), store the access and refresh token encrypted, plus the expiry. If you never call the API, store nothing: tokens you keep "just in case" are a breach-surface item you now have to explain in a security review. One user can have several identities pointing at one account, so keep them in a separate user_identities table unless you are certain one provider is all you will ever need.
The failure states, handled on purpose
The redirect flow fails in boring, predictable ways, and each deserves a message instead of an exception page:
- User cancels on the provider consent screen. The callback arrives without a code. Redirect back to the login page with "Sign-in cancelled" and a normal login form still visible.
- Provider is down or slow. The token exchange throws. Catch, log, and show "Google sign-in is unavailable right now." Never leave a user staring at a 500 from a third party they cannot do anything about.
- Email taken by another identity. Covered above: ask, don't merge.
- Callback URL mismatch. A localhost-versus-production redirect URI mismatch shows up as a raw provider error page. Handle a 4xx from the token exchange with a generic message and log the real reason for yourself.
Every one of these lands back on the login page with a flash message. The login page is also where an email-password option must remain, because OAuth providers go down, and a user with no alternative credential is a support ticket with no good outcome.
Verify the flow the way a new user would
Register a fresh provider app for dev, run the full loop with two different accounts on the same provider, and then the same test with a second provider. The scenario that catches most bugs: sign up with email and password first, then connect Google from the settings page, then try logging in both ways. If all three paths reach the same dashboard with the same data, the identity model is right. If they reach different accounts, you have learned it in staging instead of from an angry email.
Think of social login as a second front door into the same house, and build it so both doors open into the same room.