Laravel Socialite is the official Laravel package for OAuth social login, and it reduces Google, GitHub, or X sign in to two routes and one controller per provider. Install it with composer require laravel/socialite, drop the client credentials into config/services.php, then handle the redirect and the callback.

That part takes an afternoon. Most tutorials stop right there.

The interesting failures arrive later. A customer who signed up with Google comes back through GitHub and lands in a brand new empty account. X returns a profile with no email address while your users table still has a NOT NULL constraint on that column. One callback in five throws InvalidStateException in production and never once in staging.

You already know OAuth is a solved problem in Laravel. This guide walks through a working implementation for all three providers, then covers the parts that break once real people use them, including account linking, missing emails, session state, token storage, and tests. Every snippet is standard Laravel code for Laravel 11 and 12.

Key Takeaways

What Laravel Socialite does and when to use it

Laravel Socialite is a first-party package that wraps the OAuth 1.0 and OAuth 2.0 handshakes for a fixed set of providers. It builds the authorization URL, manages the state parameter, exchanges the authorization code for an access token, and normalises whatever shape the provider returns into a single user object.

That object gives you a predictable API regardless of which button the person clicked.

  • getId() returns the provider's stable identifier for that account.

  • getNickname() returns the handle, such as a GitHub username.

  • getName() returns the display name.

  • getEmail() returns the email address, which can be null.

  • getAvatar() returns a profile image URL.

  • token, refreshToken, and expiresIn hold the OAuth credentials.

Notice what the package deliberately avoids. It never creates a user, never logs anyone in, and never decides what should happen when two providers hand you the same email address. Those calls belong to your application, which is why Socialite drops into an existing codebase without forcing a rewrite of your auth layer.

These are the drivers that ship in the box.

Provider

Driver key

What to watch for

Google

google

Returns a verified email and avatar by default

GitHub

github

Default scope already includes user:email

X (OAuth 2.0)

twitter-oauth-2

No email address under the default scopes

X (OAuth 1.0a)

twitter

Returns a tokenSecret rather than a refresh token

Facebook, LinkedIn, GitLab, Bitbucket, Slack

facebook, linkedin-openid, gitlab, bitbucket, slack

Same two route pattern

Need Apple, Microsoft, Discord, or Auth0? The community Socialite Providers collection implements the same driver contract, so your controller does not change. For anything version specific, the official Laravel Socialite documentation is the reference.

One boundary is worth stating plainly. Socialite speaks OAuth, not SAML. When an enterprise customer asks for SSO against Okta or Entra ID over SAML, that is a different protocol and a different package.

Otherwise the use case is simple. Developer tools get GitHub login, consumer apps get Google, and a SaaS onboarding flow usually wants both. If you would rather describe the whole flow once and get the routes, controller, migration, and tests back as production-ready Laravel apps, Try StackMivo Now.

Install and configure Laravel Socialite

Pull the package in through Composer.

composer require laravel/socialite

There is no config file to publish. Credentials live in config/services.php, keyed by driver name.

'google' => [
    'client_id' => env('GOOGLE_CLIENT_ID'),
    'client_secret' => env('GOOGLE_CLIENT_SECRET'),
    'redirect' => '/auth/google/callback',
],

'github' => [
    'client_id' => env('GITHUB_CLIENT_ID'),
    'client_secret' => env('GITHUB_CLIENT_SECRET'),
    'redirect' => '/auth/github/callback',
],

'twitter-oauth-2' => [
    'client_id' => env('X_CLIENT_ID'),
    'client_secret' => env('X_CLIENT_SECRET'),
    'redirect' => '/auth/twitter-oauth-2/callback',
],

Relative redirect paths are fine. Socialite resolves them against your APP_URL, which keeps local and production config identical. The value you register in the provider console must match the resolved URL exactly, including the scheme and any trailing slash. Mismatched redirect URIs cause most first-attempt failures.

Routes come next. Because every provider follows the same two step dance, one pair of routes covers all of them.

use App\Http\Controllers\Auth\SocialiteController;

Route::get('/auth/{provider}/redirect', [SocialiteController::class, 'redirect'])
    ->name('socialite.redirect');

Route::get('/auth/{provider}/callback', [SocialiteController::class, 'callback'])
    ->name('socialite.callback');

Route::whereIn('provider', ['google', 'github', 'twitter-oauth-2']);

The whereIn constraint matters more than it looks. Without it, an unknown {provider} value reaches Socialite::driver() and throws an unhandled driver exception instead of returning a clean 404.

Build your Laravel app with AI

Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.

Get started free

Build the Laravel Google login flow end to end

One provider-agnostic controller handles the whole thing.

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\InvalidStateException;

class SocialiteController extends Controller
{
    public function redirect(string $provider)
    {
        return Socialite::driver($provider)->redirect();
    }

    public function callback(string $provider)
    {
        try {
            $socialUser = Socialite::driver($provider)->user();
        } catch (InvalidStateException) {
            return redirect()->route('login')->withErrors([
                'social' => 'That sign in attempt expired. Please try again.',
            ]);
        }

        $user = $this->findOrCreateUser($provider, $socialUser);

        Auth::login($user, remember: true);

        return redirect()->intended('/dashboard');
    }
}

Wrap the callback in that try block from the start. An uncaught InvalidStateException surfaces as a generic 500, which tells you nothing when it happens to a real user at 2am.

Laravel's session guard migrates the session ID inside Auth::login(), so you do not need a separate regenerate() call to close the session fixation window.

Google specific options

Google's driver already requests openid, profile, and email. Two extras are worth adding.

return Socialite::driver('google')
    ->with([
        'prompt' => 'select_account',
        'access_type' => 'offline',
    ])
    ->redirect();

prompt=select_account forces the account chooser, which saves anyone with a work and a personal Google account from silently signing in as the wrong one. access_type=offline asks for a refresh token, and per the Google OAuth 2.0 documentation you generally need prompt=consent alongside it to receive that refresh token again on repeat authorizations. Skip both if you only ever authenticate and never call Google APIs later.

This is the kind of provider-by-provider detail that separates working code from correct code, and it is one of the things Laravel-native intelligence is meant to get right without a documentation round trip.

Add GitHub and X to the same flow

Since the controller takes the driver name as a route parameter, adding providers is configuration rather than code.

GitHub. Register an OAuth App under your GitHub developer settings and set the authorization callback URL. Socialite's GitHub driver requests the user:email scope by default, so getEmail() returns the primary address even when the profile hides it publicly. For a developer tool, getNickname() is usually the better display value than getName(), because plenty of GitHub accounts leave the full name blank.

X. Create an app in the X developer portal, turn on OAuth 2.0, and set the callback. Use twitter-oauth-2 for the modern flow. Its default scopes are users.read and tweet.read, and neither carries an email address. The legacy twitter driver runs OAuth 1.0a and returns a tokenSecret instead of a refresh token, which changes how you store credentials.

$socialUser = Socialite::driver('twitter-oauth-2')->user();

$socialUser->getId();        // stable provider identifier
$socialUser->getNickname();  // handle
$socialUser->getName();      // display name
$socialUser->getEmail();     // frequently null
$socialUser->getAvatar();

Treat that null email as the normal case, not the exception. The section below shows the path that handles it.

Store social accounts without breaking your users table

The tempting shortcut is adding google_id, github_id, and twitter_id columns to users. It survives two providers and collapses on the third, and it gives you nowhere clean to keep tokens.

A join table solves it once.

Schema::create('social_accounts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('provider');
    $table->string('provider_id');
    $table->string('avatar')->nullable();
    $table->text('token')->nullable();
    $table->text('refresh_token')->nullable();
    $table->timestamp('token_expires_at')->nullable();
    $table->timestamps();

    $table->unique(['provider', 'provider_id']);
});

That unique index is your safety net. It makes a duplicate identity a database error rather than a silent second account.

Two more schema changes are required. Make password nullable, because a Google-only user never sets one, and cast the tokens so they never sit in plain text.

// App\Models\SocialAccount
protected function casts(): array
{
    return [
        'token' => 'encrypted',
        'refresh_token' => 'encrypted',
        'token_expires_at' => 'datetime',
    ];
}

Now the lookup. Match on the provider identity first and fall back to the email only when the provider vouches for it.

protected function findOrCreateUser(string $provider, $socialUser): User
{
    $account = SocialAccount::where('provider', $provider)
        ->where('provider_id', $socialUser->getId())
        ->first();

    if ($account) {
        $account->update([
            'token' => $socialUser->token,
            'refresh_token' => $socialUser->refreshToken,
        ]);

        return $account->user;
    }

    return DB::transaction(function () use ($provider, $socialUser) {
        $user = User::firstOrCreate(
            ['email' => $socialUser->getEmail()],
            [
                'name' => $socialUser->getName() ?? $socialUser->getNickname(),
                'email_verified_at' => now(),
            ]
        );

        $user->socialAccounts()->create([
            'provider' => $provider,
            'provider_id' => $socialUser->getId(),
            'avatar' => $socialUser->getAvatar(),
            'token' => $socialUser->token,
            'refresh_token' => $socialUser->refreshToken,
        ]);

        return $user;
    });
}

The transaction matters. Without it, a failure between creating the user and creating the social account leaves an orphan record that can never sign in again.

A freelance Laravel developer shipped Google login for a client's booking app on a Friday and it worked. Six weeks later the client forwarded a support thread from a customer who could not find her reservations. She had registered with Google, then signed in with GitHub on a new laptop, and the app cheerfully built her a second account on the same email. Fixing it meant a data merge script, a round of apology emails, and the social_accounts table that should have existed on day one.

Scaffolding the model, migration, and relationship together is mechanical work. The php artisan make:model cheatsheet covers the flags that generate all three in a single command, and you can Get started free if you would rather describe the schema and review the output.

Handle the edge cases most Socialite tutorials skip

Everything so far works on the happy path. These four cases are what separate a demo from something you leave running.

When the provider returns no email

X under OAuth 2.0 gives you nothing to put in the email column. Rather than generating a fake address, park the payload and ask for one.

if (! $socialUser->getEmail()) {
    session()->put('pending_social', [
        'provider' => $provider,
        'provider_id' => $socialUser->getId(),
        'name' => $socialUser->getName(),
        'avatar' => $socialUser->getAvatar(),
    ]);

    return redirect()->route('social.complete');
}

That route renders a single-field form, verifies the address the normal way, and only then creates the user and the linked social account. Synthetic addresses like [email protected] look harmless until password reset, billing receipts, or a transactional email provider starts bouncing them.

Never link on an unverified email

Matching an incoming social identity to an existing user purely by email string is an account takeover vector. If any provider in your list lets someone register with an address they do not control, that person inherits the matching account on your side.

Google returns an email_verified flag in the raw payload, and Socialite keeps the full response on the user object.

$emailIsVerified = (bool) ($socialUser->user['email_verified'] ?? false);

if (! $emailIsVerified && User::where('email', $socialUser->getEmail())->exists()) {
    return redirect()->route('login')->withErrors([
        'social' => 'An account already uses this email. Sign in, then link the provider from your settings.',
    ]);
}

The safe default is narrow. Auto-link only when the provider reports the email as verified. Otherwise send the person through their existing sign in method and let them connect the provider from account settings, where they have already proven who they are.

InvalidStateException and stateless requests

Socialite writes a state value into the session before redirecting and compares it when the provider sends the browser back. A mismatch throws InvalidStateException. The usual causes are a session that the callback request cannot see, SESSION_DOMAIN set incorrectly, a same_site value of strict blocking the cookie on the cross-site return, or a login page left open until the session expired.

A team lead at a mid-size SaaS watched social login pass every staging test and then fail for roughly one production user in five. Three web nodes sat behind a load balancer running the file session driver, so half the callbacks landed on a node that had never seen the state value. The fix was a one-line change to SESSION_DRIVER. Finding it took two days, because the exception arrived as an anonymous 500 with no context.

Fix the cause. Do not reach for stateless() to make the error go away, because that disables the CSRF protection the state parameter exists to provide. It belongs in API and SPA callbacks only, where there is no session to check.

// API or SPA callback, no session available
$socialUser = Socialite::driver('google')->stateless()->user();

// Mobile client that already holds a provider access token
$socialUser = Socialite::driver('google')->userFromToken($accessToken);

Refreshing tokens you actually use

If you only authenticate people, throw the tokens away and store nothing. If you call the provider later, reading repositories or writing calendar events, keep refreshToken and compute token_expires_at from expiresIn, then exchange the refresh token for a new access token before each call rather than after a failure. Access tokens expire on the provider's schedule, not yours.

Testing the flow with Pest

Socialite resolves through a facade, so it mocks cleanly and your tests never touch the network.

use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;

it('creates one user on first Google login', function () {
    $socialUser = (new SocialiteUser)->map([
        'id' => '1234567890',
        'name' => 'Test User',
        'email' => '[email protected]',
        'avatar' => 'https://example.com/avatar.jpg',
    ]);

    Socialite::shouldReceive('driver->user')->andReturn($socialUser);

    $this->get('/auth/google/callback')->assertRedirect('/dashboard');

    expect(User::where('email', '[email protected]')->count())->toBe(1);
});

Write three tests and the whole feature stops being scary. One for a first-time signup, one for a returning user that asserts the count stays at 1, and one for a second provider on the same verified email that links instead of duplicating. Generating that trio alongside the controller is a good fit for AI code generation, since the assertions follow directly from the schema you already defined.

Ship Laravel social login without the boilerplate

Laravel Socialite handles the OAuth handshake. You own the identity model, and that split is where the real work sits. Two routes and one provider-agnostic controller cover Google, GitHub, and X. A social_accounts table with a unique index keeps multiple providers from splintering a single person into three records. A null-email branch keeps X from breaking signup, and a verified-email check keeps email matching from becoming a takeover path.

Start narrow. Get Google working end to end, including the failure branch, before you add a second provider. Then confirm your session driver is shared across every web node, encrypt any token you keep, and cover the flow with a few Pest tests so a future refactor cannot quietly break sign in.

Social login is a small feature with a long tail of edge cases. Handle them once, in code you own and can read, and every provider you add after that costs a config block and a button.

Build your Laravel app with AI

Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.

Get started free