How do I do SSO across multiple Laravel apps on different domains?
Short answer
Let each Laravel app keep its own session and move sign-in to one OpenID Connect provider that all of them trust. Every app gets two routes: one sends people to the provider with state and PKCE, and the other exchanges the returned code, finds the local user by the provider’s user ID and logs them in. With loginos, all the apps’ domains join one platform.
You have several Laravel apps on different domains, like app.example.com, shop.example.net and admin.example.org. You want people to sign in once and be signed in on all of them. A Laravel session can’t stretch that far. What works is a central sign-in: one OpenID Connect provider that every app trusts, and two routes in each app.
Why can’t Laravel apps share a session across domains?
A Laravel session lives in a store on the server, and the browser holds its ID in a cookie. Browsers send a cookie only to the domain that set it. The domain option in config/session.php, set with SESSION_DOMAIN, can widen the cookie to the subdomains of one domain, but never to a different domain. So shop.example.net never sees the session of app.example.com, as sharing cookies between domains explains.
Apps on subdomains of one domain can share a session if they also share the session cookie’s name, the APP_KEY that encrypts it, the session store and the users table. That couples the apps tightly, and it still stops at the parent domain. Across domains, every app needs its own session, started after one shared sign-in.
What are your options?
Single sign-on (SSO) needs two pieces: a server that knows who is signed in, and a client in each app.
For the server, you have two choices:
- Run your own with Laravel Passport. Passport turns one Laravel app into a full OAuth2 server, including the authorization code grant with PKCE. Its documentation doesn’t cover OpenID Connect, so the login pages, account recovery and an endpoint that tells apps who signed in are yours to add.
- Use an identity provider that speaks OpenID Connect (OIDC), a standard layer on OAuth 2.0 that tells an app who signed in. It can be hosted, or self-hosted like Keycloak.
For the client in each app:
- Laravel Socialite has built-in drivers for Facebook, X, LinkedIn, Google, GitHub, GitLab, Bitbucket and Slack, but no generic OpenID Connect driver. The community Socialite Providers project adds one,
socialiteproviders/openidconnect, which reads the provider’s discovery document and turns PKCE on by default. league/oauth2-clienthas aGenericProviderthat works with any OAuth 2.0 server and supports PKCE.- Laravel’s own HTTP client is enough. The whole flow is two routes, and writing them yourself keeps every step visible.
What does each app need?
Each app runs the OpenID Connect authorization code flow with two routes:
- A redirect route creates a random
stateand a PKCE verifier, keeps both in the session, and sends the browser to the provider. PKCE (Proof Key for Code Exchange) binds the code to the app that asked for it: the app sends a hash of a random verifier first, and the verifier itself when it redeems the code. - A callback route checks that
statematches, exchanges the one-time code on the server with the app’s secret, finds or creates the local user, and starts the app’s own session.
Each app keeps its own users table, keyed by the provider’s subject identifier: the sub claim, which never changes. Don’t look people up by email.
When someone who signed in on app.example.com opens shop.example.net, the shop redirects them to the provider. The provider recognizes its own session and sends them straight back with a code, so they see a quick redirect instead of a form.
How loginos does it
loginos is a hosted OpenID Connect provider built for one product on many domains. Your product is a platform: create it in the developer console with the first app’s domain, then add every other app’s domain in the Domains tab. All of those domains share one pool of people. A person’s sub is the same in every app, so the apps agree on who is who without sharing a database. Every app uses the same callback path, /api/auth/login/single/callback unless you change it.
Put LOGINOS_APP_KEY and LOGINOS_APP_SECRET in each app’s .env and read them through a config file. Once the config is cached, env() no longer reads .env, so call it only in config files:
'loginos' => [
'url' => 'https://loginos.io',
'key' => env('LOGINOS_APP_KEY'),
'secret' => env('LOGINOS_APP_SECRET'),
],Give the users table a column for the loginos sub, and add loginos_sub to the model’s fillable attributes. People who only sign in with loginos have no local password:
Schema::table('users', function (Blueprint $table) {
$table->string('loginos_sub')->nullable()->unique();
$table->string('email')->nullable()->change();
$table->string('password')->nullable()->change();
});Then add the two routes to every app:
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
Route::get('/login', function (Request $request) {
$state = Str::random(40);
$verifier = Str::random(64);
$request->session()->put('loginos_state', $state);
$request->session()->put('loginos_verifier', $verifier);
return redirect()->away(config('services.loginos.url').'/oauth/authorize?'.http_build_query([
'client_id' => config('services.loginos.key'),
'redirect_uri' => route('loginos.callback'),
'response_type' => 'code',
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
'code_challenge_method' => 'S256',
]));
})->name('login');
Route::get('/api/auth/login/single/callback', function (Request $request) {
$state = $request->session()->pull('loginos_state');
$verifier = $request->session()->pull('loginos_verifier');
abort_if(! $state || ! hash_equals($state, (string) $request->query('state')), 400, 'Sign-in expired, try again.');
if (! $request->filled('code')) {
return redirect('/'); // they cancelled: ?error=access_denied
}
$response = Http::asForm()
->withBasicAuth(config('services.loginos.key'), config('services.loginos.secret'))
->post(config('services.loginos.url').'/oauth/token', [
'grant_type' => 'authorization_code',
'code' => $request->query('code'),
'redirect_uri' => route('loginos.callback'),
'code_verifier' => $verifier,
]);
abort_if($response->failed(), 401, 'Sign-in failed.');
$person = $response->json('user'); // sub, preferred_username, name, email, roles, permissions
$user = User::firstOrCreate(
['loginos_sub' => $person['sub']],
['name' => $person['name'] ?? $person['preferred_username'], 'email' => $person['email'] ?? null],
);
Auth::login($user);
return redirect()->intended('/');
})->name('loginos.callback');What the routes do:
stateand the verifier live in the session and are pulled out once, so a callback can’t be replayed.Str::randomuses PHP’srandom_bytes.- The token request uses HTTP Basic auth with the app key and secret, which loginos accepts as
client_secret_basic. The answer includes auserobject, so one request signs the person in. Codes work once and expire after five minutes. firstOrCreatekeys the user bysub. The unique index onemailstays, so an address that already belongs to a local account makes the insert fail instead of merging two people. If your apps already have users, link their accounts instead.- Naming the redirect route
loginmakes Laravel’sauthmiddleware send guests to loginos, andredirect()->intended()takes them back to the page they asked for.
Behind a load balancer that terminates TLS, configure Laravel’s trusted proxies, so route() builds https addresses. loginos only redirects to https://<one of your domains><callback path>, and plain http works only on localhost and 127.0.0.1.
Sign out
Route::post('/logout', function (Request $request) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
// Optional: end the loginos session too.
return redirect()->away(config('services.loginos.url').'/oauth/logout?'.http_build_query([
'client_id' => config('services.loginos.key'),
'post_logout_redirect_uri' => url('/'),
]));
});The first three lines sign the person out of this app. Sending the browser to /oauth/logout also ends the loginos session, so the next sign-in on any of your apps shows the loginos form again. Sessions your other apps already started last until they expire: by default after 120 idle minutes (SESSION_LIFETIME).
With league/oauth2-client
If you prefer a library, the GenericProvider does the same flow. Set scopeSeparator to a space, because the default is a comma:
use League\OAuth2\Client\Provider\GenericProvider;
$provider = new GenericProvider([
'clientId' => config('services.loginos.key'),
'clientSecret' => config('services.loginos.secret'),
'redirectUri' => route('loginos.callback'),
'urlAuthorize' => 'https://loginos.io/oauth/authorize',
'urlAccessToken' => 'https://loginos.io/oauth/token',
'urlResourceOwnerDetails' => 'https://loginos.io/oauth/userinfo',
'scopes' => ['openid', 'profile', 'email'],
'scopeSeparator' => ' ',
'pkceMethod' => GenericProvider::PKCE_METHOD_S256,
]);
// In the redirect route. getAuthorizationUrl() also creates the state and the PKCE code.
$url = $provider->getAuthorizationUrl();
$request->session()->put('loginos_state', $provider->getState());
$request->session()->put('loginos_pkce', $provider->getPkceCode());
return redirect()->away($url);
// In the callback route, after checking state:
$provider->setPkceCode($request->session()->pull('loginos_pkce'));
$token = $provider->getAccessToken('authorization_code', ['code' => $request->query('code')]);
$person = $token->getValues()['user'];loginos isn’t the right fit if your apps need SAML, social sign-in, or a login page on their own domain: sign-in always happens on loginos.io.
Frequently asked questions
Can one Laravel app serve many domains with this code?
Yes. route() and url() use the host of the current request, so the same code builds the right callback on every domain. Register each domain with loginos, from your server with POST /api/v1/domains, or let loginos ask your server as people sign in, as described under automatic domains.
Do the apps need to share a database?
No. Each app keeps its own users table and its own data, keyed by sub, which is the same in every app. The identity lives at the provider, so the apps never need to read each other’s tables. Using the same accounts on multiple websites compares this with a shared database.
Should I use Laravel Passport instead?
Use Passport if you want to run the identity provider yourself. Passport gives one app an OAuth2 server, and you build the login pages, account recovery and the rest around it. If you would rather not run accounts at all, a hosted OpenID Connect provider leaves each app with just the two routes.
Why is my redirect URI rejected?
The redirect URI must match a registered address exactly. Common causes are http instead of https behind a proxy, a different host, an extra trailing slash or a query string. Log the redirect_uri your app sends and compare it with the registered value, character by character.
How do roles work across the apps?
With loginos, roles and permissions belong to the platform, and every token on every domain carries them in roles and permissions. Check permissions in each app, and keep roles that only one app uses in that app. See roles and permissions.
One account for every domain you run
loginos is an OpenID Connect provider with passkeys, a user pool shared by all of your domains, roles in every token, and account linking for the users you already have. Add your platform and its domains in a few minutes.
Keep reading
- Sign-in across domainsHow do I implement single sign-on across different domains?Use one OpenID Connect provider for all of your domains, and register each domain’s callback URL with it. On every domain, add a route that redirects to the provider with state and PKCE, and a callback that checks state, exchanges the code on your server and starts that domain’s own session. People signed in on one domain then pass through the others without a form.8 min read
- Sign-in across domainsHow do I share a login session across multiple domains?You can’t share a session cookie between two different domains, because browsers only send a cookie to the domain that set it. Instead, each domain keeps its own session and gets it from one central login, an OpenID Connect provider. Someone who is already signed in there is sent straight back with a one-time code, without seeing a form.6 min read
- Sign-in across domainsHow do I set up SSO between WordPress sites on different domains?Install an OpenID Connect client plugin on every WordPress site and point all of them at one identity provider. Each site keeps its own login cookies, but the provider remembers the person, so the other sites log them in after a quick redirect. Multisite domain mapping alone isn’t enough, because WordPress login cookies never cross domains. With loginos, every site’s domain joins one platform.7 min read
- Sign-in across domainsCan I use the same user accounts on multiple websites?Yes. Keep the accounts in one place that every website trusts, usually an identity provider that speaks OpenID Connect, instead of copying users or sharing one database. Each site sends people there to sign in, keeps its own session, and stores its own data under the user’s permanent ID. With loginos, all your domains form one platform with one shared pool of people.7 min read