Self-Hosted SSO from Scratch with Laravel Passport

작성자

카테고리:

← 피드로
DEV Community · Hoang Manh Cam · 2026-08-03 개발(SW)

A hands-on guide to being your own Identity Provider — with Laravel 12 and Passport v13.

You will build a Central Portal that acts as an OAuth 2.0 Authorization Server, then wire up
child sites (Site A, Site B, Site C) so a user logs in once and gets access to all
of them.

No Google. No Auth0. No Keycloak. No “Sign in with…” anything. You own the users table,
you issue the codes and tokens, you hold the signing keys. The only dependency is
laravel/passport, which implements the OAuth 2.0 protocol machinery — every identity decision
is yours to make, and this guide walks through each one.

The scope is deliberately narrow: authentication only. How a user proves who they are at a
central server, and how a child site learns that identity. Everything else (admin CRUD screens,
audit logging, UI theming) is left out.

Everything here is buildable on a fresh Laravel install. No prior context needed.

Table of Contents

  1. What We Are Building
  2. OAuth 2.0 Foundations
  3. System Architecture
  4. Authentication Workflows
  5. Part A — Building the Central Portal
  6. Part B — Building a Child Site
  7. Registering a Child Site
  8. End-to-End Testing
  9. Gotchas & Security Notes
  10. Reference Tables
  11. Appendix A — Extending to Multiple User Types
  12. Appendix B — Mental Model in One Page

1. What We Are Building

The Problem

You operate several web applications. Each has its own users table, its own login form, its
own password reset flow. When a staff member joins, someone creates four accounts. When they
leave, someone must remember to disable four accounts. Passwords drift out of sync. There is
no single place to answer “who has access to what?”

The Solution

One central server owns identity. Every child site delegates login to it.

                  ┌──────────────────────────────────┐
                  │        Central Portal            │
                  │        sso.portal.test           │
                  │   OAuth 2.0 Authorization Server │
                  │                                  │
                  │   — the one login form           │
                  │   — owns the `users` table       │
                  │   — issues codes and tokens      │
                  │   — decides who may enter        │
                  │     which site                   │
                  └──────────────┬───────────────────┘
                                 │
             ┌───────────────────┼───────────────────┐
             │                   │                   │
        ┌────▼────┐         ┌────▼────┐         ┌────▼────┐
        │ Site A  │         │ Site B  │         │ Site C  │
        │(client) │         │(client) │         │(client) │
        └─────────┘         └─────────┘         └─────────┘

Enter fullscreen mode Exit fullscreen mode

The Portal is a single-purpose app: it does nothing but authenticate. Creating users and
granting site access happen via an artisan command and tinker throughout this guide. Wrapping
those in a management UI is ordinary CRUD over two tables and would teach you nothing about
SSO — so it is left out entirely.

Design Decisions Made Up Front

These shape everything that follows. Decide them before writing code.

Decision Choice Rationale Grant type Authorization Code The only flow safe for server-rendered web apps. The access token never touches the browser. Consent screen None (auto-approve) All child sites are first-party. Asking “do you allow Site A to access your data?” is noise when you own Site A. Hybrid login Not supported A site is either fully SSO or fully local. Hybrid means two password stores and two truths. Permission model Binary per site The Portal answers “may this person enter Site A?” Fine-grained permissions (“may they delete invoices?”) stay inside Site A. User provisioning Manual on both sides The Portal does not create users in child sites. Email is the join key; the local record must already exist.

That last one is worth dwelling on. Auto-provisioning (create a local user on first SSO
login) is tempting and often correct — but it means any Portal user granted your site
silently becomes a user of your site, with whatever default role you pick. Starting manual
keeps the blast radius small; see §9.10 for how to
add it deliberately.

2. OAuth 2.0 Foundations

You cannot debug this system without understanding the protocol. Read this section even if
you have used OAuth before, because the vocabulary matters when things break.

2.1 The Four Roles

Role In our system Meaning Resource Owner The user logging in The human who owns the identity Client Site A / Site B / Site C The application that wants to know who is knocking Authorization Server sso.portal.test Authenticates the user, issues tokens Resource Server sso.portal.test (same host) Serves protected data (/oauth/userinfo)

In larger systems the Authorization Server and Resource Server are separate machines. Here
they are the same Laravel app — which simplifies things considerably, because the Resource
Server can read the token straight from the same database.

2.2 Why Authorization Code Grant

OAuth 2.0 defines several grant types. Only one is correct for a server-rendered web app:

Grant Verdict Why Authorization Code ✅ Use this The access token is exchanged server-to-server. The browser only ever sees a short-lived, single-use code that is useless without the client secret. Implicit ❌ Deprecated Returns the access token in the URL fragment. Ends up in browser history, logs, Referer headers. Password (ROPC) ❌ Deprecated Requires the child site to collect the user’s password — which defeats the entire purpose of central auth. Client Credentials ❌ Wrong tool Authenticates a machine, not a user. There is no user identity to return.

2.3 The Two Credentials — Keep Them Straight

This is the single most important concept in the flow.

authorization_code access_token Travels through Browser URL (redirect query string) HTTPS POST, server → server only Lifetime ~10 minutes Configurable (see §9.7) Reusable No — single use, revoked on exchange Yes, until expiry Bound to client_id + redirect_uri + user client_id + user If leaked Nearly harmless — cannot be exchanged without client_secret Serious — grants API access

The whole architecture exists to keep the access token out of the browser. The code is a
“claim ticket”: visible to anyone watching the URL, but worthless unless you also hold the
client secret, which lives only in the child site’s .env.

2.4 The state Parameter — CSRF Protection

Without state, this attack works:

  1. Attacker starts an authorization flow with their own account and captures the code
  2. Attacker tricks the victim into visiting https://site-a.test/auth/callback?code=ATTACKER_CODE
  3. Site A exchanges the code, gets the attacker’s identity, and logs the victim in as the attacker
  4. The victim now operates inside the attacker’s account — anything they type is visible to the attacker

The fix: the child site generates a random state before redirecting, stores it in its own
session, sends it along, and refuses the callback if it does not come back identical.

// Before redirect
$state = Str::random(40);
$request->session()->put('oauth_state', $state);

// On callback
$state = $request->session()->pull('oauth_state');
abort_unless(hash_equals((string) $state, (string) $request->query('state')), 403);

Enter fullscreen mode Exit fullscreen mode

Use hash_equals, not === — it is timing-attack safe. And pull rather than get, so the
state is consumed and cannot be replayed.

PKCE note: PKCE (code_challenge / code_verifier) protects clients that cannot keep
a secret — mobile apps, SPAs. Our clients are Laravel servers holding a real
client_secret, so PKCE is optional. Passport supports it if you later add a public client.

2.5 Where Sessions Fit

OAuth says nothing about how the Authorization Server authenticates the user — that is
explicitly out of scope for the spec. We use an ordinary Laravel session cookie on the SSO
domain.

That session cookie is the “single” in Single Sign-On:

  • First site → no portal session → show login form → create session → issue code
  • Second site → portal session already exists → issue code immediately, no form

The user typed their password once. Every subsequent site gets a silent redirect.

2.6 Is This “Real” SSO?

Worth settling, because the term gets used loosely.

Yes — and the session cookie from §2.5 is precisely why. The industry distinction is:

Pattern Definition Is it SSO? Same sign-on The same credentials work on every site, but the user types them at each one (e.g. three apps all querying one LDAP server) ❌ No Single sign-on One authentication ceremony; subsequent apps do not prompt at all ✅ Yes

Our system is the second. Site B does not ask for a password because the Portal already holds a
session for that browser. Remove the portal session cookie and make every /oauth/authorize
re-prompt, and this would degrade into same sign-on — the credentials would still be
centralized, but the “single” would be gone.

So the accurate one-line description is: web SSO via the OAuth 2.0 authorization code flow,
with a central Identity Provider session.
This is the same architecture as “Sign in with
Google” or “Sign in with GitHub” — the only difference is that we own all the clients.

The caveat: OAuth 2.0 is not an authentication protocol

Strictly, OAuth 2.0 is an authorization framework. It defines how a client obtains a token to
access a resource — it says nothing about how to learn who the user is. OpenID Connect
(OIDC)
is the standard that layers authentication on top of OAuth 2.0, and it is worth
recognising how close we came to reinventing it:

OIDC feature What we built Gap UserInfo endpoint /oauth/userinfo — same idea, same name Shape is ours, not the OIDC-registered claim set (sub, preferred_username, …) ID token (signed JWT carrying identity) ✗ none Child sites must make a server-to-server call instead of verifying a signed assertion locally openid scope ✗ we send scope='' No standard signal that this is an authentication request Discovery (/.well-known/openid-configuration) ✗ none Clients cannot self-configure; every URL is hand-set in .env JWKS endpoint ✗ none No public key rotation mechanism Single Logout ✗ none See §9.3

So the honest label is: an OIDC-shaped custom implementation, not an OIDC-compliant one. It
is real SSO, and it is standards-based (the OAuth 2.0 half is fully spec-compliant); the
identity layer on top is bespoke.

Does that matter?

For first-party clients you control — no. A bespoke userinfo contract between your own
Laravel apps is simpler than OIDC, has fewer moving parts, and every child site is four files.

It starts to matter when either of these becomes true:

  1. You need to onboard software you did not write. Grafana, GitLab, Nextcloud, Jenkins, AWS IAM Identity Center — they speak OIDC or SAML and expect discovery plus ID tokens. They cannot consume a custom /oauth/userinfo shape.
  2. You need offline verification. With an ID token, a child site verifies identity from a signature using a cached public key — no network call. That matters at high volume or when the Portal’s availability becomes a bottleneck for every login across every site.

If either is on the roadmap, add OIDC rather than growing the bespoke layer. Passport does not
ship OIDC, so that means a package on top of league/oauth2-server, or a dedicated IdP
(Keycloak, Zitadel, Authentik, Auth0). The migration is not painful if you keep the
authorization-code half clean, which is most of this guide.

Terminology cross-reference

The same roles have different names in each standard. Useful when reading other docs:

This guide OAuth 2.0 OpenID Connect SAML 2.0 Central Portal Authorization Server OpenID Provider (OP) / IdP Identity Provider (IdP) Child site Client Relying Party (RP) Service Provider (SP) User Resource Owner End-User Principal / Subject /oauth/userinfo Resource Server UserInfo Endpoint — (claims ride in the assertion)

3. System Architecture

3.1 Host Layout

Host Role Route file sso.portal.test The Portal — OAuth 2.0 Authorization Server routes/sso.php site-a.test Child site (OAuth client) its own routes/web.php site-b.test Child site (OAuth client) its own routes/web.php

These are separate Laravel applications. Each child site has its own database, its own
users table, and its own session cookie. They share nothing but the HTTP calls in §4 — which
is exactly why this scales to sites written in other frameworks entirely.

3.2 Database Schema

Passport tables (published, do not hand-write)

oauth_clients         id (uuid PK), owner_type, owner_id, name, secret,
                      provider, redirect_uris, grant_types, revoked

oauth_auth_codes      id (char 80 PK), user_id, client_id (uuid), scopes, revoked, expires_at
oauth_access_tokens   id (char 80 PK), user_id, client_id (uuid), name, scopes, revoked, expires_at
oauth_refresh_tokens  id (char 80 PK), access_token_id, revoked, expires_at
oauth_device_codes    (unused — device flow not enabled)

Enter fullscreen mode Exit fullscreen mode

Passport v13 changed the client primary key from auto-increment integer to UUID. Any
column referencing it must be string(36) / foreignUuid, not unsignedBigInteger. This
bites people upgrading from v11/v12.

Identity table

users        id, name, email (unique), password,
             email_verified_at, remember_token, timestamps, softDeletes

Enter fullscreen mode Exit fullscreen mode

Site registry and access grants

sites        id, oauth_client_id (string 36, → oauth_clients.id),
             name, url, timestamps, softDeletes

site_user    site_id, user_id     unique(site_id, user_id)

Enter fullscreen mode Exit fullscreen mode

sites is the bridge between “an OAuth client” (a protocol concept) and “an application a
human can be granted access to” (a business concept). Everything the Portal knows about
authorization lives in site_user.

// database/migrations/xxxx_create_sites_table.php
Schema::create('sites', function (Blueprint $table) {
    $table->id();
    $table->string('oauth_client_id', 36)->nullable()->index();
    $table->string('name');
    $table->string('url')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('site_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('site_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->timestamps();
    $table->unique(['site_id', 'user_id']);
});

Enter fullscreen mode Exit fullscreen mode

oauth_client_id is nullable so you can create a site row first and attach its OAuth client
afterwards.

3.3 Guards on the Portal

Guard Driver Provider Used for web session users The portal session — created by the login form api passport users Bearer token on /oauth/userinfo

Two guards, one provider, one model. One user type means one provider, which keeps this
refreshingly boring — and note that Laravel gives you both of these out of the box. If you need several
distinct user types (staff vs. partners, each with their own table), read
Appendix A — there is a real trap in that
direction.

3.4 The Three Preconditions for SSO Login

A login succeeds only if all three hold. Knowing which one failed is 90% of debugging.

# Owned by Condition Failure behaviour ① Portal The user account exists (email + password) Login form rejects credentials. No code issued. ② Portal That user is granted the target site in site_user Portal redirects back with ?error=access_denied. No code issued. ③ Child site The site has a local user row with the same email Code and token issued fine; the site’s own lookup fails and it shows “account not registered”.

Note the split of responsibility. ① and ② are enforced at the Portal. ③ is not the Portal’s
problem
— it has no idea what users exist inside Site A. A successful token exchange proves
“this person is a valid user with access to your site”, not “you have a row for them”.

4. Authentication Workflows

4.1 First Login (cold — no portal session yet)

Browser                Site A (server)              Portal (sso.portal.test)
   │                        │                              │
   │  GET /dashboard        │                              │
   ├───────────────────────►│                              │
   │  302 → /login          │  (auth middleware)           │
   │◄───────────────────────┤                              │
   │                        │                              │
   │  click "Login with SSO"│                              │
   │  GET /auth/redirect    │                              │
   ├───────────────────────►│                              │
   │                        │ generate state, store in own │
   │                        │ session                      │
   │  302 → sso.portal.test/oauth/authorize                │
   │      ?client_id=…&redirect_uri=…&response_type=code   │
   │      &state=RANDOM40                                  │
   │◄───────────────────────┤                              │
   │                                                       │
   │  GET /oauth/authorize?client_id=…                     │
   ├──────────────────────────────────────────────────────►│
   │                        validateAuthorizationRequest() │
   │                        → client_id + redirect_uri OK  │
   │                        → Auth::guest() == true        │
   │                        → stash full URL in session    │
   │  302 → sso.portal.test/login                          │
   │◄──────────────────────────────────────────────────────┤
   │                                                       │
   │  GET /login  (renders form)                           │
   ├──────────────────────────────────────────────────────►│
   │  200 login form                                       │
   │◄──────────────────────────────────────────────────────┤
   │                                                       │
   │  POST /login {email, password, _token}                │
   ├──────────────────────────────────────────────────────►│
   │                        attempt() → ✅ ① satisfied     │
   │                        session()->regenerate()        │
   │                        ── PORTAL SESSION CREATED ──   │
   │                        pull stashed authorize URL     │
   │  302 → the original /oauth/authorize?…                │
   │◄──────────────────────────────────────────────────────┤
   │                                                       │
   │  GET /oauth/authorize?client_id=… (replay)            │
   ├──────────────────────────────────────────────────────►│
   │                        session exists ✅               │
   │                        sites.oauth_client_id → site   │
   │                        site_user has row? ✅ ②        │
   │                        setAuthorizationApproved(true) │
   │                        INSERT oauth_auth_codes        │
   │  302 → site-a.test/auth/callback?code=XYZ&state=…     │
   │◄──────────────────────────────────────────────────────┤
   │                        │                              │
   │  GET /auth/callback?code=XYZ&state=RANDOM40           │
   ├───────────────────────►│                              │
   │                        │ hash_equals(state) ✅        │
   │                        │                              │
   │                        │  POST /oauth/token           │
   │                        │  grant_type=authorization_code│
   │                        │  code, client_id,            │
   │                        │  client_secret, redirect_uri │
   │                        ├─────────────────────────────►│
   │                        │  revoke code, mint token     │
   │                        │  {access_token, refresh_token,│
   │                        │   expires_in, token_type}    │
   │                        │◄─────────────────────────────┤
   │                        │                              │
   │                        │  GET /oauth/userinfo         │
   │                        │  Authorization: Bearer …     │
   │                        ├─────────────────────────────►│
   │                        │  validate token → user       │
   │                        │  {id, name, email, site_id}  │
   │                        │◄─────────────────────────────┤
   │                        │                              │
   │                        │ User::where('email', …) ③    │
   │                        │ Auth::login($user)           │
   │                        │ session()->regenerate()      │
   │  302 → /dashboard      │                              │
   │◄───────────────────────┤                              │

Enter fullscreen mode Exit fullscreen mode

Note the replay: /oauth/authorize is hit twice. The first hit stashes the URL and
bounces to the login form; the second hit — after the session exists — actually issues the
code. This is what makes one login form serve every child site without the form needing to
know anything about OAuth.

4.2 Second Site, Same Browser (warm — SSO in action)

Browser                Site B (server)              Portal
   │  GET /auth/redirect    │                             │
   ├───────────────────────►│                             │
   │  302 → sso.portal.test/oauth/authorize?client_id=B&… │
   │◄───────────────────────┤                             │
   │                                                      │
   ├─────────────────────────────────────────────────────►│
   │                       portal session EXISTS ✅        │
   │                       site_user check ✅              │
   │                       issue code immediately          │
   │  302 → site-b.test/auth/callback?code=ABC            │
   │◄─────────────────────────────────────────────────────┤
   │  (token exchange + userinfo, identical to 4.1)       │

Enter fullscreen mode Exit fullscreen mode

No login form. No password. No user interaction at all. From the user’s point of view they
clicked a button and were inside.

4.3 Access Denied (precondition ② fails)

Browser                                              Portal
   │  GET /oauth/authorize?client_id=C&…                  │
   ├─────────────────────────────────────────────────────►│
   │                       session exists ✅               │
   │                       Site::where('oauth_client_id')  │
   │                       → found, but no site_user row   │
   │                       → NO code issued                │
   │  302 → site-c.test/auth/callback                     │
   │        ?error=access_denied                          │
   │        &error_description=…&state=…                  │
   │◄─────────────────────────────────────────────────────┤
   │                        │                             │
   │  Site C sees ?error → back to its login page with    │
   │  "You do not have permission to access this site."   │

Enter fullscreen mode Exit fullscreen mode

The error travels back through the redirect URI, not as a raw 403 on the SSO domain. The user
stays on the site they were trying to reach, which is where an error message is useful.

4.4 Local User Missing (precondition ③ fails)

The Portal issues code and token normally — from its perspective everything is correct. The
child site’s User::where('email', $userInfo['email'])->first() returns null, and the site
shows “this account is not registered.”

This is the correct division of labour: the Portal vouches for identity, the site decides
whether it has a seat for that identity.

Part A — Building the Central Portal

Working directory: the Portal project (a fresh Laravel 12 app is fine).

A1. Install Passport

composer require laravel/passport:^13

Enter fullscreen mode Exit fullscreen mode

Publish the migrations before migrating — v13 does not auto-load them:

php artisan vendor:publish --tag=passport-migrations
php artisan migrate

Enter fullscreen mode Exit fullscreen mode

Generate the RSA keypair used to sign tokens:

php artisan passport:keys

Enter fullscreen mode Exit fullscreen mode

This writes storage/oauth-private.key and storage/oauth-public.key.

Deployment: these files are gitignored, and regenerating them invalidates every existing
token. On multi-server deployments, generate once and inject via the
PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY env vars (config/passport.php reads them)
so every node signs with the same key.

A2. Prepare the Models

User needs HasApiTokens (so Passport can issue tokens for it) and a sites() relation (so
the Portal can check precondition ②).

// app/Models/User.php
namespace App\Models;

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, SoftDeletes;

    protected $fillable = ['name', 'email', 'password'];

    protected $hidden = ['password', 'remember_token'];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password'          => 'hashed',   // auto-hash on assignment
    ];

    public function sites(): BelongsToMany
    {
        return $this->belongsToMany(Site::class);
    }
}

Enter fullscreen mode Exit fullscreen mode

// app/Models/Site.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class Site extends Model
{
    use SoftDeletes;

    protected $fillable = ['oauth_client_id', 'name', 'url'];

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class);
    }
}

Enter fullscreen mode Exit fullscreen mode

A3. Configure Guards

// config/auth.php
return [
    'defaults' => [
        'guard'     => 'web',
        'passwords' => 'users',
    ],

    'guards' => [
        // Session guard for the SSO login form
        'web' => [
            'driver'   => 'session',
            'provider' => 'users',
        ],

        // Bearer token guard for /oauth/userinfo
        'api' => [
            'driver'   => 'passport',
            'provider' => 'users',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model'  => App\Models\User::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table'    => 'password_reset_tokens',
            'expire'   => 60,
            'throttle' => 60,
        ],
    ],
];

Enter fullscreen mode Exit fullscreen mode

Passport registers the passport driver itself, so api works with no further setup.

A4. Configure Passport in a Service Provider

// app/Providers/AppServiceProvider.php
use Laravel\Passport\Passport;

public function boot(): void
{
    // Suppress Passport's auto-registered /oauth/* routes.
    // We register only what we want, ourselves, on the SSO domain.
    Passport::ignoreRoutes();

    Passport::tokensExpireIn(now()->addMinutes(10));
    Passport::refreshTokensExpireIn(now()->addDays(30));
}

Enter fullscreen mode Exit fullscreen mode

Why ignoreRoutes() matters. By default Passport registers a dozen routes on your default
domain: /oauth/authorize, /oauth/token, /oauth/clients,
/oauth/personal-access-tokens, and more. Every one of them is a surface you did not design:

  • /oauth/authorize would render Passport’s consent view and use the default guard on the default domain — and it knows nothing about site_user.
  • /oauth/clients exposes client management (including secret regeneration) to whoever satisfies the default guard.
  • None of them are scoped to sso.portal.test.

Turn them all off, then register only the endpoints you actually want.

On the token lifetime: 10 minutes looks aggressive but is correct for this design — the token
is used for exactly one userinfo call, seconds after issuance. See
§9.7.

A5. Bind the SSO Domain and Register Routes

// config/app.php
'sso_domain' => env('APP_SSO_DOMAIN', null),
'sso_prefix' => env('APP_SSO_PREFIX', '/'),

Enter fullscreen mode Exit fullscreen mode

APP_SSO_DOMAIN=sso.portal.test
APP_SSO_PREFIX=/

Enter fullscreen mode Exit fullscreen mode

// bootstrap/app.php
->withRouting(
    web:      __DIR__ . '/../routes/web.php',
    commands: __DIR__ . '/../routes/console.php',
    then: function () {
        require base_path('routes/sso.php');
    }
)

Enter fullscreen mode Exit fullscreen mode

routes/sso.php owns its own domain and prefix binding, which keeps it self-contained — and
means the Portal’s web.php can stay empty, or serve a landing page, without interference.

Now routes/sso.php:

<?php

use App\Http\Controllers\Sso\Auth\ForgotPasswordController;
use App\Http\Controllers\Sso\Auth\LoginController;
use App\Http\Controllers\Sso\Auth\ResetPasswordController;
use App\Http\Controllers\Sso\OAuth\AuthorizationController;
use App\Http\Controllers\Sso\OAuth\UserInfoController;
use Illuminate\Support\Facades\Route;

$ssoPrefix = trim((string) config('app.sso_prefix'), '/');
$ssoDomain = config('app.sso_domain');

$ssoRoutes = function () {
    /* ---------------- Login form (browser) ---------------- */
    Route::middleware('guest')->group(function () {
        Route::get('login',  [LoginController::class, 'showLoginForm'])->name('sso.login');
        Route::post('login', [LoginController::class, 'login'])
            ->middleware('throttle:5,1')
            ->name('sso.login.post');
    });

    Route::post('logout', [LoginController::class, 'logout'])
        ->middleware('auth')
        ->name('sso.logout');

    /* ---------------- OAuth authorization (browser) ---------------- */
    // NOTE: no auth middleware — the controller decides login-vs-issue
    Route::get('oauth/authorize', [AuthorizationController::class, 'handle'])
        ->name('sso.oauth.authorize');

    Route::delete('oauth/authorize', [AuthorizationController::class, 'deny'])
        ->middleware('auth')
        ->name('sso.oauth.deny');

    /* ---------------- Password reset (browser) ---------------- */
    Route::middleware('guest')->group(function () {
        Route::get('password/reset',          [ForgotPasswordController::class, 'showLinkRequestForm'])->name('sso.password.request');
        Route::post('password/email',         [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('sso.password.email');
        Route::get('password/reset/{token}',  [ResetPasswordController::class, 'showResetForm'])->name('sso.password.reset');
        Route::post('password/reset',         [ResetPasswordController::class, 'reset'])->name('sso.password.update');
    });

    /* ---------------- Server-to-server ---------------- */
    Route::post('oauth/token', [\Laravel\Passport\Http\Controllers\AccessTokenController::class, 'issueToken'])
        ->middleware('throttle')
        ->name('sso.oauth.token');

    Route::get('oauth/userinfo', [UserInfoController::class, 'show'])
        ->middleware('auth:api')
        ->name('sso.oauth.userinfo');
};

Route::middleware('web')->group(function () use ($ssoPrefix, $ssoDomain, $ssoRoutes) {
    if ($ssoDomain) {
        Route::domain($ssoDomain)->prefix($ssoPrefix)->group($ssoRoutes);
    } else {
        Route::prefix($ssoPrefix)->group($ssoRoutes);
    }
});

Enter fullscreen mode Exit fullscreen mode

Four things worth pausing on:

  1. /oauth/authorize has no auth middleware. If it did, the framework would redirect the
    guest to the login form and the authorize URL would be lost. The controller must see the
    guest request so it can stash the URL first.

  2. /oauth/token is Passport’s stock controller. The token endpoint does not need any
    custom logic — it validates the code, the client secret, and the redirect URI, then mints a
    token. There is no reason to write your own.

  3. The login form is throttled. It is a password oracle on a public domain.
    throttle:5,1 (5 attempts per minute) is a floor, not a ceiling.

  4. The $ssoDomain conditional lets you run without a dedicated domain in local dev
    (path-prefix mode, e.g. localhost:8000/sso/login) and with a real domain in
    staging/production, from the same code.

If you later add a second domain to this app — a management UI on admin.portal.test,
say — you must give each domain its own session cookie name, or guard state leaks across the
boundary. Do it in a middleware that sets config(['session.cookie' => …]) based on
$request->getHost(), registered with prependToGroup('web', …) so it runs before
StartSession. Renaming the cookie after the session has started silently does nothing. Not
needed for a single-domain Portal, which is why it is a footnote rather than a step.

A6. Middleware Adjustments in bootstrap/app.php

Two small changes, both inside ->withMiddleware().

Exempt the token endpoint from CSRF. /oauth/token is called by a server, which has no
session and no CSRF token. Without this, every token exchange returns 419.

$middleware->validateCsrfTokens(except: [
    'oauth/token',
    '*/oauth/token',
]);

Enter fullscreen mode Exit fullscreen mode

Both patterns are listed so it works in path-prefix mode and domain mode. This is safe because
the endpoint’s authentication is client_secret, not a cookie — and CSRF attacks exploit
ambient cookie credentials, of which there are none here.

Point guests at the SSO login form. Our login route is named sso.login, not login, so
the framework needs telling where to send unauthenticated users.

$middleware->redirectGuestsTo(fn (Request $request) =>
    $request->expectsJson() ? null : route('sso.login')
);

Enter fullscreen mode Exit fullscreen mode

Returning null for JSON requests lets them 401 instead of redirecting.

A7. The Login Controller

// app/Http/Controllers/Sso/Auth/LoginController.php
namespace App\Http\Controllers\Sso\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;

class LoginController extends Controller
{
    public function showLoginForm(Request $request)
    {
        return view('sso.auth.login');
    }

    public function login(Request $request): RedirectResponse
    {
        $request->validate([
            'email'    => ['required', 'email'],
            'password' => ['required', 'string'],
        ]);

        // Precondition ① — does this account exist with this password?
        if (! Auth::attempt(
            $request->only('email', 'password'),
            $request->boolean('remember')
        )) {
            throw ValidationException::withMessages([
                'email' => __('auth.failed'),
            ]);
        }

        // Fresh session ID — defeats session fixation
        $request->session()->regenerate();

        // Resume the OAuth flow that sent us here
        $redirectTo = $request->session()->pull('sso_authorize_url', route('sso.login'));

        return redirect()->to($redirectTo);
    }

    public function logout(Request $request): RedirectResponse
    {
        Auth::logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        return redirect()->route('sso.login');
    }
}

Enter fullscreen mode Exit fullscreen mode

The critical lines:

  • session()->regenerate() after a successful attempt. Without it, an attacker who planted a known session ID before login continues to hold a valid session after login — session fixation.
  • pull('sso_authorize_url') — read and remove. Consuming it prevents a stale authorize URL from hijacking an unrelated later login.
  • Fallback to the login route if there is no stashed URL, i.e. someone hit /login directly rather than arriving from an authorize redirect. You could also send them to a small “pick a site” landing page listing Auth::user()->sites.

A8. The AuthorizationController — the Heart of the System

This replaces Passport’s stock controller. It does two things the stock one cannot: bounce
guests to your login form while preserving the flow, and enforce precondition ②.

// app/Http/Controllers/Sso/OAuth/AuthorizationController.php
namespace App\Http\Controllers\Sso\OAuth;

use App\Http\Controllers\Controller;
use App\Models\Site;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Passport\Bridge\User as PassportUser;
use Laravel\Passport\Http\Controllers\ConvertsPsrResponses;
use Laravel\Passport\Http\Controllers\HandlesOAuthErrors;
use League\OAuth2\Server\AuthorizationServer;
use Psr\Http\Message\ResponseInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\HttpFoundation\Response;

class AuthorizationController extends Controller
{
    use ConvertsPsrResponses, HandlesOAuthErrors;

    public function __construct(
        protected AuthorizationServer $server,
    ) {}

    public function handle(Request $request, ResponseInterface $psrResponse): Response|RedirectResponse
    {
        $psrRequest = (new PsrHttpFactory())->createRequest($request);

        // STEP 1 — validate the request itself, before touching auth.
        // Verifies client_id exists, is not revoked, supports authorization_code,
        // and that redirect_uri exactly matches a registered URI.
        $authRequest = $this->withErrorHandling(
            fn () => $this->server->validateAuthorizationRequest($psrRequest)
        );

        // STEP 2 — no portal session? Stash the flow and bounce to login.
        if (Auth::guest()) {
            $request->session()->put('sso_authorize_url', $request->fullUrl());

            return redirect()->route('sso.login');
        }

        $user = Auth::user();

        // STEP 3 — precondition ②: is this user granted this site?
        $clientId = $authRequest->getClient()->getIdentifier();
        $site     = Site::where('oauth_client_id', $clientId)->first();

        if (! $site || ! $user->sites()->whereKey($site->id)->exists()) {
            return $this->denyWithError(
                $authRequest->getRedirectUri() ?? $request->query('redirect_uri', ''),
                $request->query('state')
            );
        }

        // STEP 4 — auto-approve. First-party sites get no consent screen.
        $authRequest->setUser(new PassportUser($user->getAuthIdentifier()));
        $authRequest->setAuthorizationApproved(true);

        // STEP 5 — persist the auth code and emit the 302 with ?code=…&state=…
        return $this->withErrorHandling(
            fn () => $this->convertResponse(
                $this->server->completeAuthorizationRequest($authRequest, $psrResponse)
            )
        );
    }

    public function deny(Request $request): RedirectResponse
    {
        return $this->denyWithError(
            $request->query('redirect_uri', ''),
            $request->query('state')
        );
    }

    private function denyWithError(string $redirectUri, ?string $state): RedirectResponse
    {
        $url = $redirectUri . '?' . http_build_query(array_filter([
            'error'             => 'access_denied',
            'error_description' => 'The user does not have access to this application.',
            'state'             => $state,
        ]));

        return redirect()->to($url);
    }
}

Enter fullscreen mode Exit fullscreen mode

Why each piece exists

validateAuthorizationRequest() runs first — before the auth check. Fail fast on a bad
client, before you send the user through a login form that cannot help them. It also gives you
$authRequest, which is the only trustworthy source for the redirect URI: it has been matched
against oauth_clients.redirect_uris, whereas $request->query('redirect_uri') is raw
attacker-controlled input. Redirecting to an unvalidated URI is the classic open
redirector
vulnerability.

PsrHttpFactory converts Laravel’s Request into a PSR-7 ServerRequestInterface.
league/oauth2-server — the library under Passport — speaks PSR-7 only.

HandlesOAuthErrors catches OAuthServerException and turns it into a spec-compliant
error response instead of a 500 stack trace.

ConvertsPsrResponses converts the League library’s PSR-7 response back into a Symfony
response Laravel can return.

new PassportUser($id) is a thin bridge value object carrying just the identifier. The
League library never needs your Eloquent model, only its ID — which is what gets written to
oauth_auth_codes.user_id.

setAuthorizationApproved(true) is where a consent screen would go. In a third-party
system you would instead render a view here, and only set this after the user clicks “Allow”.

Access denial reuses the OAuth error redirect, not abort(403). The user came from Site C
and their error belongs on Site C, in Site C’s language and layout.

A9. The UserInfoController

The token endpoint returns an opaque access_token. This endpoint turns it into identity.

// app/Http/Controllers/Sso/OAuth/UserInfoController.php
namespace App\Http\Controllers\Sso\OAuth;

use App\Http\Controllers\Controller;
use App\Models\Site;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class UserInfoController extends Controller
{
    public function show(Request $request): JsonResponse
    {
        $user  = $request->user();                      // resolved by auth:api
        $token = $user->token();                        // the access token in use
        $site  = Site::where('oauth_client_id', $token->client_id)->first();

        // Re-check precondition ② — the grant may have been revoked
        // between token issuance and this call.
        if (! $site || ! $user->sites()->whereKey($site->id)->exists()) {
            return response()->json(['error' => 'access_denied'], 403);
        }

        return response()->json([
            'id'      => $user->id,
            'name'    => $user->name,
            'email'   => $user->email,
            'site_id' => $site->id,
        ]);
    }
}

Enter fullscreen mode Exit fullscreen mode

The auth:api middleware does all the cryptographic work: signature verification, expiry, and
revocation checks. $request->user() then gives you the User model, and $user->token()
(from HasApiTokens) gives you the token record — which is how we learn which site is
asking.

Re-checking the grant here is cheap insurance. Without it, a token issued before an
administrator revoked access stays usable for the remainder of its lifetime.

The response shape is the contract with every child site. Keep it stable; version it if you
must change it.

{
  "id": 42,
  "name": "Alice Nguyen",
  "email": "[email protected]",
  "site_id": 1
}

Enter fullscreen mode Exit fullscreen mode

Part B — Building a Child Site

Working directory: the child site project (Site A).

No packages required. This is two HTTP calls and a session write.

B1. Configuration

// config/services.php
'portal' => [
    'sso_url'          => env('PORTAL_SSO_URL'),
    'sso_internal_url' => env('PORTAL_SSO_INTERNAL_URL', env('PORTAL_SSO_URL')),
    'client_id'        => env('PORTAL_CLIENT_ID'),
    'client_secret'    => env('PORTAL_CLIENT_SECRET'),
    'redirect_uri'     => env('PORTAL_REDIRECT_URI'),
],

Enter fullscreen mode Exit fullscreen mode

# Browser-facing URL — used for redirects the user's browser follows
PORTAL_SSO_URL=http://localhost:8000

# Server-to-server URL — used for token exchange and userinfo
PORTAL_SSO_INTERNAL_URL=http://host.docker.internal:8000

PORTAL_CLIENT_ID=019e14fa-191a-72f0-9f57-cb2fe04eae8d
PORTAL_CLIENT_SECRET=puUsbzP2HKXeSSVplTVH2Or4y5ALEeUgD0rDCIDv

PORTAL_REDIRECT_URI=http://localhost:8001/auth/callback

Enter fullscreen mode Exit fullscreen mode

The two-URL split is not optional in Docker. localhost inside the app container means the
container itself, not the host. The browser needs localhost:8000; the PHP process needs
host.docker.internal:8000 (or a shared Docker network alias). In production both are usually
the same public HTTPS URL, and the fallback in config/services.php handles that
automatically.

B2. Routes

// routes/web.php
use App\Http\Controllers\Auth\SsoController;

Route::middleware('guest')->group(function () {
    Route::get('login', [LoginController::class, 'showLoginForm'])->name('login');

    Route::get('auth/redirect', [SsoController::class, 'redirect'])->name('sso.redirect');
    Route::get('auth/callback', [SsoController::class, 'callback'])->name('sso.callback');
});

Route::middleware('auth')->group(function () {
    Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
});

Enter fullscreen mode Exit fullscreen mode

guest on both SSO routes means an already-logged-in user who re-enters the flow is bounced to
their dashboard rather than logging in twice.

The callback path must match PORTAL_REDIRECT_URI exactly, and that URI must be
registered in oauth_clients.redirect_uris on the Portal. OAuth compares by exact string — a
trailing-slash difference is a rejected request.

B3. The SsoController

// app/Http/Controllers/Auth/SsoController.php
namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class SsoController extends Controller
{
    private string $ssoUrl;
    private string $ssoInternalUrl;
    private string $clientId;
    private string $clientSecret;
    private string $redirectUri;

    public function __construct()
    {
        $config = config('services.portal');

        $this->ssoUrl         = rtrim($config['sso_url'], '/');
        $this->ssoInternalUrl = rtrim($config['sso_internal_url'], '/');
        $this->clientId       = $config['client_id'];
        $this->clientSecret   = $config['client_secret'];
        $this->redirectUri    = $config['redirect_uri'];
    }

    /** Step 1 — send the browser to the Portal. */
    public function redirect(Request $request): RedirectResponse
    {
        $state = Str::random(40);
        $request->session()->put('oauth_state', $state);

        $query = http_build_query([
            'client_id'     => $this->clientId,
            'redirect_uri'  => $this->redirectUri,
            'response_type' => 'code',
            'scope'         => '',
            'state'         => $state,
        ]);

        return redirect("{$this->ssoUrl}/oauth/authorize?{$query}");
    }

    /** Step 2 — the Portal sends the browser back with ?code= or ?error= */
    public function callback(Request $request): RedirectResponse
    {
        $this->validateState($request);

        if ($request->has('error')) {
            return redirect()->route('login')
                ->withErrors(['email' => 'You do not have permission to access this site.']);
        }

        // Server-to-server: code → access token
        $token = $this->exchangeCode($request->query('code'));

        // Server-to-server: access token → identity
        $userInfo = $this->fetchUserInfo($token['access_token']);

        // Precondition ③ — do we have a local seat for this identity?
        $user = User::where('email', $userInfo['email'])->first();

        if (! $user) {
            return redirect()->route('login')
                ->withErrors(['email' => 'This account is not registered on this site.']);
        }

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

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

    private function validateState(Request $request): void
    {
        $state = $request->session()->pull('oauth_state');

        abort_unless(
            hash_equals((string) $state, (string) $request->query('state')),
            403,
            'Invalid OAuth state.'
        );
    }

    private function exchangeCode(string $code): array
    {
        $response = Http::asForm()->post("{$this->ssoInternalUrl}/oauth/token", [
            'grant_type'    => 'authorization_code',
            'client_id'     => $this->clientId,
            'client_secret' => $this->clientSecret,
            'redirect_uri'  => $this->redirectUri,
            'code'          => $code,
        ]);

        if (! $response->successful()) {
            Log::error('SSO token exchange failed', [
                'status' => $response->status(),
                'body'   => $response->body(),
            ]);
            abort(502, 'Token exchange failed.');
        }

        return $response->json();
    }

    private function fetchUserInfo(string $accessToken): array
    {
        $response = Http::withToken($accessToken)
            ->get("{$this->ssoInternalUrl}/oauth/userinfo");

        if (! $response->successful()) {
            Log::error('SSO userinfo fetch failed', [
                'status' => $response->status(),
                'body'   => $response->body(),
            ]);
            abort(502, 'UserInfo fetch failed.');
        }

        return $response->json();
    }
}

Enter fullscreen mode Exit fullscreen mode

Points worth internalising:

  • State is validated before anything else — including before checking for ?error=. An unsolicited callback is rejected regardless of what it carries.
  • Http::asForm() — the token endpoint is application/x-www-form-urlencoded per spec, not JSON. Sending JSON gets you a confusing unsupported_grant_type.
  • redirect_uri is sent again during exchange. The spec requires it to match the one used at authorize time; it binds the code to this specific client and callback.
  • Log the failure body. OAuth errors carry a useful error/error_description pair, and it is the difference between five minutes and an afternoon of debugging.
  • session()->regenerate() after login — session fixation, same as on the Portal side.
  • The access token is deliberately not stored. It was needed for one userinfo call; the local Laravel session is now the source of truth. Store it only if the site needs to keep calling Portal APIs later — and if it does, revisit the token lifetime in §9.7.

B4. Add the SSO Button

{{-- resources/views/auth/login.blade.php --}}
@if (Route::has('sso.redirect'))
    <div class="text-center mt-3">
        <a href="{{ route('sso.redirect') }}" class="btn btn-outline-secondary w-100">
            Login with SSO
        </a>
    </div>
@endif

Enter fullscreen mode Exit fullscreen mode

The Route::has() guard means the button appears only where the SSO routes are registered, so
the same view works in an environment where SSO is not configured.

B5. Repeat per Site

Site B and Site C are the same three files with different env values. Each gets its own OAuth
client on the Portal (own client_id, own client_secret, own redirect_uri) and its own row
in sites.

7. Registering a Child Site

Every child site needs a row in oauth_clients plus a linked row in sites. An artisan
command is the most convenient way to do it.

// app/Console/Commands/RegisterSite.php
namespace App\Console\Commands;

use App\Models\Site;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Laravel\Passport\Client;

class RegisterSite extends Command
{
    protected $signature = 'sso:register-site
                            {name : Display name, e.g. "Site A"}
                            {redirect : Full callback URL, e.g. http://localhost:8001/auth/callback}';

    protected $description = 'Register a child site as an OAuth client';

    public function handle(): int
    {
        $plainSecret = Str::random(40);

        $client = new Client();
        $client->name          = $this->argument('name');
        $client->secret        = $plainSecret;   // hashed by the model cast on save
        $client->redirect_uris = [$this->argument('redirect')];
        $client->grant_types   = ['authorization_code', 'refresh_token'];
        $client->revoked       = false;
        $client->save();

        Site::create([
            'oauth_client_id' => $client->id,
            'name'            => $this->argument('name'),
            'url'             => parse_url($this->argument('redirect'), PHP_URL_SCHEME)
                                 . '://' . parse_url($this->argument('redirect'), PHP_URL_HOST),
        ]);

        $this->info('Site registered. Add these to the child site .env:');
        $this->line("  PORTAL_CLIENT_ID={$client->id}");
        $this->line("  PORTAL_CLIENT_SECRET={$plainSecret}");
        $this->line("  PORTAL_REDIRECT_URI={$this->argument('redirect')}");

        return self::SUCCESS;
    }
}

Enter fullscreen mode Exit fullscreen mode

php artisan sso:register-site "Site A" http://localhost:8001/auth/callback

Enter fullscreen mode Exit fullscreen mode

Key details:

  • grant_types must include authorization_code. Passport v13 enforces per-client grant allow-lists; omit it and authorize requests fail with unsupported_grant_type.
  • The secret is displayed once. It is hashed on save and unrecoverable afterwards. Copy it immediately; to rotate, generate a new one and update the child site’s .env.
  • redirect_uris is an array. A site with several callback paths (different user areas, for instance) can register them all against one client. See Appendix A.
  • oauth_clients.id is a UUID in v13, hence sites.oauth_client_id is string(36).

Finally, grant users access — insert into site_user (normally through the management UI).
Without that row, precondition ② fails and no code is issued.

$user->sites()->attach($site->id);

Enter fullscreen mode Exit fullscreen mode

8. End-to-End Testing

8.1 Local Hosts

# /etc/hosts
127.0.0.1 sso.portal.test
127.0.0.1 site-a.test
127.0.0.1 site-b.test

Enter fullscreen mode Exit fullscreen mode

Or skip domains entirely in dev by leaving APP_SSO_DOMAIN empty and using the path prefix
(localhost:8000/sso/...) — routes/sso.php supports both.

8.2 Setup Checklist

On the Portal:

composer require laravel/passport:^13
php artisan vendor:publish --tag=passport-migrations
php artisan migrate
php artisan passport:keys
php artisan sso:register-site "Site A" http://localhost:8001/auth/callback

Enter fullscreen mode Exit fullscreen mode

  • [ ] users rows exist with known passwords
  • [ ] sites.oauth_client_id is populated
  • [ ] site_user rows exist for the test user
  • [ ] APP_SSO_DOMAIN (or prefix) set

On the child site:

  • [ ] PORTAL_* env vars filled in, including the printed client id/secret
  • [ ] PORTAL_SSO_INTERNAL_URL reachable from inside the container
  • [ ] redirect_uri matches the registered value byte for byte
  • [ ] A local user row exists with the same email as the Portal account

8.3 Manual Walkthrough

  1. Cold flow. Open Site A’s login page → click “Login with SSO” → you should land on the Portal login form → submit credentials → you should end up on Site A’s dashboard.
  2. Warm flow (the actual SSO test). In the same browser, go to Site B and click its SSO button. No login form should appear. If one does, the portal session cookie is not persisting — check SESSION_DOMAIN and the cookie’s Secure flag in devtools.
  3. Access denied. Delete the site_user row and retry. You should be returned to Site A’s login page with a permission error, not a 403 on the SSO domain.
  4. Missing local user. Restore the grant, delete the local user row, retry. Expect “not registered on this site” — and note that a token was issued: precondition ③ is enforced client-side.
  5. Code replay. Capture a code from the callback URL and POST it to /oauth/token twice. The second attempt must fail — codes are single-use.
  6. State tampering. Hit the callback with a modified state. Expect 403.

8.4 Reading the Database While Debugging

-- Was a code issued at all? (① and ② passed)
SELECT id, user_id, client_id, revoked, expires_at
FROM oauth_auth_codes ORDER BY expires_at DESC LIMIT 5;

-- Did the exchange succeed? (revoked=1 on the code above, plus a new token)
SELECT id, user_id, client_id, revoked, expires_at
FROM oauth_access_tokens ORDER BY created_at DESC LIMIT 5;

-- Is the client registered as expected?
SELECT id, name, redirect_uris, grant_types, revoked FROM oauth_clients;

-- Is the grant in place?
SELECT u.email, s.name
FROM site_user su
JOIN users u ON u.id = su.user_id
JOIN sites s ON s.id = su.site_id;

Enter fullscreen mode Exit fullscreen mode

Reading them in order tells you exactly which step failed:

Symptom Failed at No oauth_auth_codes row ① or ② — login failed or grant missing Code exists, revoked = 0, no access token Token exchange failed (secret, redirect_uri, CSRF, internal URL) Access token exists, child site still errors ③ — no local user with that email

8.5 Common Errors

Error Cause Fix invalid_client Wrong client_id/client_secret, or client revoked Re-register; verify .env on both sides unsupported_grant_type grant_types missing authorization_code, or JSON body sent instead of form Fix oauth_clients.grant_types; use Http::asForm() invalid_grant Code expired, already used, or redirect_uri mismatch Restart the flow; compare URIs byte for byte 419 on /oauth/token CSRF not excluded Add both patterns in A6 Redirect loop on /oauth/authorize Portal session not persisting Check SESSION_DOMAIN, and the cookie Secure flag if serving over plain HTTP cURL “connection refused” during exchange Child container cannot reach the Portal Set PORTAL_SSO_INTERNAL_URL to host.docker.internal or a shared Docker network alias Login form shown on every site Portal cookie scoped too narrowly, or third-party cookie blocking Verify cookie domain; ensure all traffic is same-scheme 401 on /oauth/userinfo Token expired between exchange and call, or keys differ across nodes Check tokensExpireIn; pin PASSPORT_*_KEY across servers

9. Gotchas & Security Notes

9.1 Never Redirect to an Unvalidated redirect_uri

$request->query('redirect_uri') is attacker-controlled. Prefer
$authRequest->getRedirectUri(), which the League library has already matched against the
registered list. Using the raw query parameter turns your Portal into an open redirector — and
worse, into a credential-forwarding service.

In denyWithError() the code falls back to the raw query value when $authRequest has none.
That path is only reached after validateAuthorizationRequest() has run, so it is bounded —
but if you extend that method, keep the validated value first.

9.2 Passport::ignoreRoutes() Is a Security Control

Without it you are silently exposing /oauth/clients, /oauth/tokens,
/oauth/personal-access-tokens, and a consent-screen /oauth/authorize bound to your default
guard. Verify:

php artisan route:list | grep oauth

Enter fullscreen mode Exit fullscreen mode

You should see exactly your own SSO routes and nothing else.

9.3 The Portal Owns Its Session; Child Sites Own Theirs

Logging out of Site A does not end the portal session. The next SSO click logs straight back in
with no prompt. This is usually what users expect from SSO, but it surprises people who assume
“log out” is global.

Single Logout (SLO) requires either a Portal-initiated logout endpoint plus back-channel
notification to every site, or short-lived site sessions that re-validate against the Portal.
Neither is implemented here. If you add one, start with a Portal /logout that accepts a
post_logout_redirect_uri — validated against the registered list, per
§9.1.

9.4 Soft Deletes Do Not Revoke Tokens

Soft-deleting a user makes Auth::attempt() fail (Laravel’s EloquentUserProvider calls
newQuery(), so the SoftDeletingScope applies) — so they cannot start a new flow. But
tokens already issued stay valid until expiry. If you need immediate cutoff:

// On deletion / access revocation
$user->tokens()->update(['revoked' => true]);

Enter fullscreen mode Exit fullscreen mode

The same applies to detaching a site_user grant: it blocks future authorize requests but does
not invalidate existing tokens. The userinfo grant re-check in
A9 closes most of this gap.

9.5 Rate-Limit the Login Form and the Token Endpoint

Both are covered in A5
throttle:5,1 on login, throttle on the token endpoint. Worth restating because the SSO
login form is the single most attacked surface in the whole system: it is a password oracle,
public, and it guards every child site at once.

Consider keying the limiter on email + IP rather than IP alone, so one office NAT does not
lock out a floor of users.

9.6 Everything Hinges on Email

User::where('email', $userInfo['email'])->first() is the entire user-matching mechanism.
Consequences:

  • Changing an email on the Portal silently detaches the child-site account
  • Two people cannot share an email
  • Email must be unique and non-nullable on both sides

If you need durable linkage, add a portal_user_id column on the child site and match on
$userInfo['id'] first, falling back to email. That survives email changes.

9.7 Token Lifetimes Are a Deliberate Trade-Off

Passport::tokensExpireIn(now()->addMinutes(10));
Passport::refreshTokensExpireIn(now()->addDays(30));

Enter fullscreen mode Exit fullscreen mode

In this design the access token is used exactly once, seconds after issuance, for one
userinfo call. A long-lived token would be pure risk with no benefit — so keep it short.

Lengthen it only if child sites genuinely make ongoing API calls to the Portal. If they do,
also store the refresh token and implement refresh, rather than issuing a 30-day access token.

9.8 HTTPS in Production, Without Exception

Authorization codes travel through the browser URL. Over plain HTTP, anyone on the network
path reads them. Also set SESSION_SECURE_COOKIE=true — otherwise the portal session cookie is
transmitted in the clear and SSO collapses into session hijacking.

9.9 Scopes Are Available, and Currently Unused

We send scope='' and never check scopes. That is defensible when every client is first-party
and gets identical data. The moment one child site should see less than another (say, no email
address), scopes are the right mechanism — define them with Passport::tokensCan(), request
them from the client, and check with $user->tokenCan('...') in UserInfoController.

Do not fake it with a column on sites. Scopes are already in the token and already validated.

9.10 Auto-Provisioning, If You Want It

Precondition ③ requires a pre-existing local user. To auto-create instead:

$user = User::firstOrCreate(
    ['email' => $userInfo['email']],
    ['name' => $userInfo['name'], 'password' => Str::random(40)]
);

Enter fullscreen mode Exit fullscreen mode

Two things to be deliberate about before you do:

  1. The Portal’s grant becomes the only gate. Anyone attached to this site in site_user becomes a user here. That is the intent — just make sure the management UI treats attaching a site as the significant action it now is.
  2. Give the row an unusable password, not a known default. A random string that is never shown means the account cannot be used via local login, only via SSO — which is what you want, since the whole point is that the Portal owns credentials.

10. Reference Tables

10.1 Portal Endpoints (sso.portal.test)

Method URI Auth Purpose GET /login guest Show login form POST /login guest Authenticate → create portal session → resume authorize POST /logout auth Destroy portal session GET /oauth/authorize none (controller decides) Issue code, or bounce to login DELETE /oauth/authorize auth Explicit deny GET /password/reset guest Forgot-password form POST /password/email guest Send reset link GET /password/reset/{token} guest Reset form POST /password/reset guest Save new password POST /oauth/token client credentials Exchange code → access token (server-to-server) GET /oauth/userinfo Bearer (auth:api) Return identity JSON (server-to-server)

10.2 Child Site Endpoints

Method URI Purpose GET /auth/redirect Generate state, redirect to Portal authorize GET /auth/callback Validate state, exchange code, fetch userinfo, log in locally

10.3 Environment Variables

Portal:

Variable Example Notes APP_SSO_DOMAIN sso.portal.test Empty → path-prefix mode APP_SSO_PREFIX / Path prefix when no domain PASSPORT_PRIVATE_KEY -----BEGIN RSA… Optional; for multi-node deploys PASSPORT_PUBLIC_KEY -----BEGIN PUBLIC… Optional; must pair with the above SESSION_SECURE_COOKIE true Required in production

Child site:

Variable Example Notes PORTAL_SSO_URL http://localhost:8000 Browser-facing PORTAL_SSO_INTERNAL_URL http://host.docker.internal:8000 Server-to-server PORTAL_CLIENT_ID 019e14fa-… (UUID) From oauth_clients.id PORTAL_CLIENT_SECRET puUsbzP2… Shown once at registration PORTAL_REDIRECT_URI http://localhost:8001/auth/callback Must match registration exactly

10.4 Files Touched

Portal:

config/auth.php                                       web + api guards
config/app.php                                        sso_domain / sso_prefix
config/passport.php                                   (published) keys, connection
bootstrap/app.php                                     route loading, CSRF exempt, guest redirect
app/Providers/AppServiceProvider.php                  ignoreRoutes(), token lifetimes
routes/sso.php                                        all SSO endpoints
app/Http/Controllers/Sso/Auth/LoginController.php     ① + session + resume
app/Http/Controllers/Sso/OAuth/AuthorizationController.php  ② + code issuance
app/Http/Controllers/Sso/OAuth/UserInfoController.php       Bearer → identity JSON
app/Models/User.php                                   HasApiTokens + sites()
app/Models/Site.php                                   oauth_client_id ↔ users pivot
app/Console/Commands/RegisterSite.php                 client registration
database/migrations/*_create_sites_table.php          sites + site_user
resources/views/sso/auth/login.blade.php              the one login form

Enter fullscreen mode Exit fullscreen mode

Nine files, two of them config one-liners. That is the whole Authorization Server.

Child site:

config/services.php                                   portal block
routes/web.php                                        redirect + callback routes
app/Http/Controllers/Auth/SsoController.php           the whole client side
resources/views/auth/login.blade.php                  SSO button

Enter fullscreen mode Exit fullscreen mode

Appendix A — Extending to Multiple User Types

Skip this unless you need it. The main guide assumes one users table, which is the right
default. But some systems have genuinely distinct populations — internal staff and external
partners, say — living in separate tables with separate login forms.

The pattern: one route prefix per user type, each with its own guard.

/staff/login              /partner/login
/staff/oauth/authorize    /partner/oauth/authorize
/staff/oauth/userinfo     /partner/oauth/userinfo
/oauth/token              ← shared, un-prefixed

Enter fullscreen mode Exit fullscreen mode

// config/auth.php
'guards' => [
    'staff'   => ['driver' => 'session', 'provider' => 'staff'],
    'partner' => ['driver' => 'session', 'provider' => 'partners'],
    'api'     => ['driver' => 'passport', 'provider' => 'staff'],
],

Enter fullscreen mode Exit fullscreen mode

Each user type gets its own pivot table (site_staff, site_partner) and its own login
controller — a copy with the guard name swapped.

The controllers resolve their guard from the route:

private function resolveGuard(): string
{
    return Route::is('sso.staff.*') ? 'staff' : 'partner';
}

Enter fullscreen mode Exit fullscreen mode

/oauth/token stays shared and un-prefixed — the token endpoint does not care about user
types, only about client_id and code validity.

The trap: auth:api cannot serve two models

A guard maps to exactly one provider, which maps to exactly one model class. The api
guard above is bound to staff. A Bearer token belonging to a Partner would be resolved
against the Staff model, silently returning the wrong person or null.

So UserInfoController can no longer use auth:api. Drop the middleware and validate the
token by hand, then resolve the model yourself:

use League\OAuth2\Server\ResourceServer;
use Laravel\Passport\Http\Controllers\HandlesOAuthErrors;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;

class UserInfoController extends Controller
{
    use HandlesOAuthErrors;

    public function __construct(protected ResourceServer $resourceServer) {}

    public function show(Request $request): JsonResponse
    {
        $psrRequest = $this->withErrorHandling(
            fn () => $this->resourceServer->validateAuthenticatedRequest(
                (new PsrHttpFactory())->createRequest($request)
            )
        );

        $userId   = (int) $psrRequest->getAttribute('oauth_user_id');
        $clientId = $psrRequest->getAttribute('oauth_client_id');

        $user = Route::is('sso.staff.*')
            ? Staff::find($userId)
            : Partner::find($userId);

        // …build the response as before…
    }
}

Enter fullscreen mode Exit fullscreen mode

validateAuthenticatedRequest() performs the same cryptographic checks the middleware does —
signature, expiry, revocation — but hands back raw claims instead of a model, leaving
resolution to you.

The trap behind the trap

Resolving the model from the route rather than from the token creates a subtle hole. A
client holding a valid staff token can call /partner/oauth/userinfo and receive the
Partner row whose id happens to equal that staff member’s id — a different human.

The impact is bounded (a client can only do this with tokens it already holds, and it leaks
names and emails rather than granting access), but close it anyway. Two ways:

Option A — record the owner type on the token. Add owner_type / owner_id to
oauth_access_tokens, populate them at issuance, and reject a mismatch:

abort_unless($token->owner_type === $expectedModelClass, 403, 'Token type mismatch.');

Enter fullscreen mode Exit fullscreen mode

Option B — one endpoint, type from the token. Drop the per-type prefix on userinfo and
serve a single /oauth/userinfo that reports the type recorded on the token. Simpler and more
correct, though it changes the contract with child sites.

Option B is generally the better choice if you are still early enough to change the contract.

Appendix B — Mental Model in One Page

     ┌──────────────────────────────────────────────────────────────┐
     │  The Portal's job: prove identity, and answer one question — │
     │  "may this person enter that site?"                          │
     │                                                              │
     │  It never creates users in child sites.                      │
     │  It never decides what they may do once inside.              │
     └──────────────────────────────────────────────────────────────┘

     ┌──────────────────────────────────────────────────────────────┐
     │  The child site's job: ask the Portal who is knocking,       │
     │  then find its own local seat for that person.               │
     │                                                              │
     │  It never sees a password.                                   │
     │  It never lets the access token reach the browser.           │
     └──────────────────────────────────────────────────────────────┘

     THE FIVE STEPS, EVERY TIME:

        1. Site → Portal     "authorize me, here is who I am (client_id)
                              and where to send the answer (redirect_uri)"

        2. Portal            validate the client, then:
                             session? → check the grant → issue a code
                             no session? → login form → then the above

        3. Portal → Site     browser redirect carrying ?code=…&state=…

        4. Site → Portal     POST /oauth/token with code + client_secret
                             (server-to-server; the browser is not involved)

        5. Site → Portal     GET /oauth/userinfo with Bearer token
                             → {id, name, email, site_id}
                             → find local user by email → log them in

Enter fullscreen mode Exit fullscreen mode

Laravel 12 · PHP 8.2+ · Laravel Passport v13 · league/oauth2-server.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다