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
- What We Are Building
- OAuth 2.0 Foundations
- System Architecture
- Authentication Workflows
- Part A — Building the Central Portal
- Part B — Building a Child Site
- Registering a Child Site
- End-to-End Testing
- Gotchas & Security Notes
- Reference Tables
- Appendix A — Extending to Multiple User Types
- 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) │
└─────────┘ └─────────┘ └─────────┘
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:
- Attacker starts an authorization flow with their own account and captures the code
- Attacker tricks the victim into visiting
https://site-a.test/auth/callback?code=ATTACKER_CODE - Site A exchanges the code, gets the attacker's identity, and logs the victim in as the attacker
- 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);
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:
-
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/userinfoshape. - 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)
Passport v13 changed the client primary key from auto-increment integer to UUID. Any
column referencing it must bestring(36)/foreignUuid, notunsignedBigInteger. This
bites people upgrading from v11/v12.
Identity table
users id, name, email (unique), password,
email_verified_at, remember_token, timestamps, softDeletes
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)
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']);
});
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 │ │
│◄───────────────────────┤ │
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) │
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." │
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
Publish the migrations before migrating — v13 does not auto-load them:
php artisan vendor:publish --tag=passport-migrations
php artisan migrate
Generate the RSA keypair used to sign tokens:
php artisan passport:keys
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_KEYenv vars (config/passport.phpreads 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);
}
}
// 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);
}
}
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,
],
],
];
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));
}
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/authorizewould render Passport's consent view and use the default guard on the default domain — and it knows nothing aboutsite_user. -
/oauth/clientsexposes 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', '/'),
APP_SSO_DOMAIN=sso.portal.test
APP_SSO_PREFIX=/
// bootstrap/app.php
->withRouting(
web: __DIR__ . '/../routes/web.php',
commands: __DIR__ . '/../routes/console.php',
then: function () {
require base_path('routes/sso.php');
}
)
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);
}
});
Four things worth pausing on:
/oauth/authorizehas noauthmiddleware. 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./oauth/tokenis 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.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.The
$ssoDomainconditional 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 setsconfig(['session.cookie' => …])based on
$request->getHost(), registered withprependToGroup('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',
]);
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')
);
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');
}
}
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
/logindirectly rather than arriving from an authorize redirect. You could also send them to a small "pick a site" landing page listingAuth::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);
}
}
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,
]);
}
}
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": "alice@example.com",
"site_id": 1
}
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'),
],
# 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
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');
});
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();
}
}
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 isapplication/x-www-form-urlencodedper spec, not JSON. Sending JSON gets you a confusingunsupported_grant_type. -
redirect_uriis 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_descriptionpair, 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
userinfocall; 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
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;
}
}
php artisan sso:register-site "Site A" http://localhost:8001/auth/callback
Key details:
-
grant_typesmust includeauthorization_code. Passport v13 enforces per-client grant allow-lists; omit it and authorize requests fail withunsupported_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_urisis 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.idis a UUID in v13, hencesites.oauth_client_idisstring(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);
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
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
- [ ]
usersrows exist with known passwords - [ ]
sites.oauth_client_idis populated - [ ]
site_userrows 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_URLreachable from inside the container - [ ]
redirect_urimatches the registered value byte for byte - [ ] A local user row exists with the same email as the Portal account
8.3 Manual Walkthrough
- 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.
-
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_DOMAINand the cookie'sSecureflag in devtools. -
Access denied. Delete the
site_userrow and retry. You should be returned to Site A's login page with a permission error, not a 403 on the SSO domain. - 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.
-
Code replay. Capture a
codefrom the callback URL and POST it to/oauth/tokentwice. The second attempt must fail — codes are single-use. -
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;
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
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]);
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));
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)]
);
Two things to be deliberate about before you do:
-
The Portal's grant becomes the only gate. Anyone attached to this site in
site_userbecomes a user here. That is the intent — just make sure the management UI treats attaching a site as the significant action it now is. - 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
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
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
// config/auth.php
'guards' => [
'staff' => ['driver' => 'session', 'provider' => 'staff'],
'partner' => ['driver' => 'session', 'provider' => 'partners'],
'api' => ['driver' => 'passport', 'provider' => 'staff'],
],
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';
}
/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…
}
}
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.');
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
Laravel 12 · PHP 8.2+ · Laravel Passport v13 · league/oauth2-server.
Top comments (0)