+1 (415) 599-8902

Enterprise SSO in Laravel: OIDC, SAML, JIT Provisioning and Deprovisioning

Once a prospect's procurement team gets involved, one requirement shows up on every checklist: "Does it support SSO?" What they mean is that their staff should log into your Laravel application with their corporate identity provider — Entra ID, Okta, Google Workspace, Keycloak, OneLogin — and that when someone leaves the company, revoking that account should end their access to your app too.

This tutorial builds enterprise SSO into a Laravel 13 application: OpenID Connect via Socialite, SAML 2.0 for the tenants that still require it, just-in-time user provisioning, per-tenant configuration so each customer brings their own IdP, and SCIM-style deprovisioning. It assumes you already have password or passkey login working and are adding SSO alongside it, not replacing it.

Pick your protocol before you write code

  • OpenID Connect (OIDC) is OAuth 2.0 with an identity layer. You redirect the user to the IdP, get back an authorization code, exchange it for an ID token (a signed JWT) containing claims about the user. It is JSON, it is well supported by every modern IdP, and Socialite speaks it with minimal glue.
  • SAML 2.0 is XML, older, and still mandatory in plenty of enterprises, government and healthcare buyers. The IdP POSTs a signed XML assertion to your Assertion Consumer Service (ACS) URL. You validate the signature against the IdP's certificate and read attributes out of the assertion.

If you can only build one, build OIDC. If you sell to large organisations, you will eventually need both. Do not hand-roll either: signature validation, clock skew, audience restriction and replay protection are where custom implementations get breached.

Model tenants and their IdP configuration

SSO is per-customer configuration, not application configuration. Put it in the database:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('sso_connections', function (Blueprint $table) {
            $table->id();
            $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
            $table->string('protocol'); // oidc | saml
            $table->string('email_domain')->unique(); // acme.com
            $table->boolean('enforced')->default(false);

            // OIDC
            $table->string('oidc_issuer')->nullable();
            $table->string('oidc_client_id')->nullable();
            $table->text('oidc_client_secret')->nullable();

            // SAML
            $table->text('saml_entity_id')->nullable();
            $table->text('saml_sso_url')->nullable();
            $table->text('saml_certificate')->nullable();

            $table->json('role_map')->nullable();
            $table->timestamps();
        });
    }
};

Encrypt the secrets at rest with Laravel's encrypted casts so a database dump does not leak a client secret:

protected function casts(): array
{
    return [
        'oidc_client_secret' => 'encrypted',
        'saml_certificate' => 'encrypted',
        'role_map' => 'array',
        'enforced' => 'boolean',
    ];
}

email_domain is how you route a user to the right connection. enforced is the flag that says "for this tenant, password login is off" — large customers ask for it, and you want the switch per tenant rather than global.

Home-realm discovery: one login box, many IdPs

Ask for the email address first, then decide where to send the user.

use App\Models\SsoConnection;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/login/sso', function (Request $request) {
    $validated = $request->validate(['email' => ['required', 'email']]);

    $domain = str($validated['email'])->after('@')->lower()->toString();

    $connection = SsoConnection::where('email_domain', $domain)->first();

    if (! $connection) {
        return back()->withErrors(['email' => 'No single sign-on is configured for that domain.']);
    }

    $request->session()->put('sso_connection_id', $connection->id);

    return $connection->protocol === 'saml'
        ? redirect()->route('saml.redirect', $connection)
        : redirect()->route('oidc.redirect', $connection);
})->middleware('throttle:10,1')->name('login.sso');

Throttle this endpoint. Without a limiter it is a free tenant-enumeration oracle: an attacker learns which companies are your customers by typing domains.

OIDC with Socialite

Socialite's generic OIDC support lets you configure a provider at runtime, which is exactly what per-tenant SSO needs:

composer require laravel/socialite
<?php

namespace App\Http\Controllers\Auth;

use App\Models\SsoConnection;
use Laravel\Socialite\Facades\Socialite;

class OidcController extends Controller
{
    public function redirect(SsoConnection $connection)
    {
        return $this->driver($connection)
            ->scopes(['openid', 'profile', 'email'])
            ->redirect();
    }

    protected function driver(SsoConnection $connection)
    {
        return Socialite::buildProvider(\Laravel\Socialite\Two\OidcProvider::class, [
            'client_id' => $connection->oidc_client_id,
            'client_secret' => $connection->oidc_client_secret,
            'redirect' => route('oidc.callback'),
            'issuer' => $connection->oidc_issuer,
        ]);
    }
}

The discovery document at {issuer}/.well-known/openid-configuration gives you the authorization, token and JWKS endpoints. Cache it — do not fetch it on every login:

$config = Cache::remember(
    "oidc:discovery:{$connection->id}",
    now()->addHours(12),
    fn () => Http::timeout(5)->get("{$connection->oidc_issuer}/.well-known/openid-configuration")->throw()->json(),
);

On callback, validate before you trust:

public function callback(Request $request)
{
    $connection = SsoConnection::findOrFail($request->session()->pull('sso_connection_id'));

    $ssoUser = $this->driver($connection)->user();

    // The provider validates signature, issuer, audience and expiry against JWKS.
    // You still have to check the things only your app knows:
    abort_unless((bool) $ssoUser->getEmail(), 422, 'Identity provider returned no email address.');

    $domain = str($ssoUser->getEmail())->after('@')->lower()->toString();
    abort_unless($domain === $connection->email_domain, 403, 'Email domain does not match this connection.');

    $user = app(ProvisionSsoUser::class)($connection, $ssoUser);

    Auth::login($user, remember: false);
    $request->session()->regenerate();

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

Three details people skip and regret:

  1. Verify email_verified where the IdP supplies it. An IdP that lets users set an arbitrary unverified email turns SSO into account takeover of another tenant.
  2. Re-check the domain against the connection. Otherwise Acme's IdP can assert ceo@bigcustomer.com and log into someone else's tenant.
  3. Regenerate the session after login (session()->regenerate()), which kills session-fixation attacks on the SSO redirect flow.

SAML 2.0 without hand-rolling XML

Use a maintained library — simplesamlphp/saml2 or one of the Laravel wrappers around it — and expose three routes: metadata, redirect, and ACS.

Route::get('/saml/{connection}/metadata', [SamlController::class, 'metadata'])->name('saml.metadata');
Route::get('/saml/{connection}/redirect', [SamlController::class, 'redirect'])->name('saml.redirect');
Route::post('/saml/{connection}/acs', [SamlController::class, 'acs'])
    ->withoutMiddleware([VerifyCsrfToken::class])
    ->name('saml.acs');

The ACS route is an unauthenticated cross-site POST from the IdP, so CSRF verification has to be excluded there — and that means every other protection has to be explicit. Your ACS handler must:

  • verify the assertion signature against the stored certificate;
  • check Destination equals your ACS URL and Audience equals your entity ID;
  • enforce NotBefore / NotOnOrAfter with a small clock-skew allowance (60 seconds is typical);
  • store the assertion ID and reject replays (a cache entry keyed by assertion ID until the assertion expires does the job).
$assertionId = $assertion->getId();

abort_if(
    ! Cache::add("saml:assertion:{$assertionId}", true, now()->addMinutes(10)),
    403,
    'Assertion replay detected.'
);

Attribute names are the other time sink. Entra ID sends claims as long schema URIs, Okta sends short names, and every customer's admin maps things slightly differently. Normalise on the way in and keep the mapping per connection rather than hard-coded.

Just-in-time provisioning

Do not ask enterprise admins to pre-create accounts. Create the user on first successful login, inside a transaction, keyed on the immutable IdP subject rather than the email address:

<?php

namespace App\Actions\Auth;

use App\Models\SsoConnection;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class ProvisionSsoUser
{
    public function __invoke(SsoConnection $connection, $ssoUser): User
    {
        return DB::transaction(function () use ($connection, $ssoUser) {
            $identity = $connection->identities()->lockForUpdate()->firstOrCreate(
                ['subject' => $ssoUser->getId()],
                ['user_id' => $this->resolveUser($connection, $ssoUser)->id],
            );

            $user = $identity->user;

            $user->fill([
                'name' => $ssoUser->getName() ?: $user->name,
                'email' => $ssoUser->getEmail(),
                'email_verified_at' => $user->email_verified_at ?? now(),
            ])->save();

            $user->syncRoles($this->mapRoles($connection, $ssoUser));

            return $user;
        });
    }
}

Keying on subject matters because people get married, change names and change email addresses while remaining the same employee. If you key identities on email, a rename at the IdP silently creates a duplicate account with an empty history.

Role mapping belongs in role_map on the connection: group "acme-admins" from the IdP maps to your admin role. Never let the IdP assert your application roles directly by name.

Enforcement, and the door you leave open

If a tenant has enforced set, block password login for its members:

public function boot(): void
{
    Fortify::authenticateUsing(function (Request $request) {
        $user = User::where('email', $request->email)->first();

        if ($user && $user->tenant?->ssoConnection?->enforced) {
            throw ValidationException::withMessages([
                'email' => 'Your organisation requires single sign-on.',
            ]);
        }

        return $user && Hash::check($request->password, $user->password) ? $user : null;
    });
}

Keep one break-glass path: a platform-admin account outside the tenant, or a signed short-lived recovery link. Every team that enforces SSO with no escape hatch eventually locks itself out when a certificate expires on a Friday evening.

Deprovisioning is the requirement behind the requirement

SSO controls login. It does not end an existing Laravel session. A user removed from Okta at 09:00 can keep browsing your app on a cookie issued yesterday.

Close that gap with three things:

  1. Short session lifetimes for SSO users, plus SESSION_EXPIRE_ON_CLOSE where the customer asks for it.
  2. A middleware that re-checks status on a schedule — store sso_checked_at on the session and, once it is older than, say, 15 minutes, silently re-authorise against the IdP or log the user out.
  3. SCIM or a webhook from the IdP for real deletions. When a deprovision event arrives, delete the user's sessions and revoke their tokens:
DB::table('sessions')->where('user_id', $user->id)->delete();
$user->tokens()->delete();

If you use the database session driver this is trivial; with Redis sessions keep an index of session IDs per user so you can do the same thing.

Test it without a real IdP

Spin up Keycloak in docker-compose.yml for local development and integration tests — it does both OIDC and SAML, and it is the cheapest way to prove your flow before a customer's Okta admin is on the call. For fast unit tests, fake Socialite:

it('provisions a user on first sso login', function () {
    $connection = SsoConnection::factory()->oidc()->create(['email_domain' => 'acme.com']);

    Socialite::shouldReceive('buildProvider->scopes->user')->andReturn(
        (new SocialiteUser)->map([
            'id' => 'idp-subject-123',
            'name' => 'Dana Lane',
            'email' => 'dana@acme.com',
        ])
    );

    $this->withSession(['sso_connection_id' => $connection->id])
        ->get(route('oidc.callback', ['code' => 'x', 'state' => 'y']))
        ->assertRedirect('/dashboard');

    expect(User::where('email', 'dana@acme.com')->exists())->toBeTrue();
});

it('rejects an assertion for a foreign email domain', function () {
    // ... same setup with email => 'ceo@othertenant.com'
    // assert 403 and no user created
});

Write the negative tests first: wrong domain, expired assertion, replayed assertion, missing email, mismatched audience. Those are the paths an attacker uses, and they are the paths nobody exercises by clicking through the happy flow.

A pragmatic rollout order

  1. OIDC with one pilot customer, JIT provisioning, SSO optional.
  2. Home-realm discovery and an admin UI so customers configure their own connection.
  3. Enforcement flag, break-glass access, audit logging of every SSO login.
  4. SAML for the tenants that need it.
  5. SCIM or webhook deprovisioning and session revocation.

Each step is shippable on its own, and the first two are usually enough to unblock a deal.

We implement enterprise SSO, IdP integration and authentication hardening on client Laravel applications. If SSO is blocking a contract, see our Laravel security implementation service and get in touch.