# How do I do SSO across multiple Laravel apps on different domains?

> 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](https://loginos.io/answers/share-cookies-between-domains.md) 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-client`** has a `GenericProvider` that 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:

1. **A redirect route** creates a random `state` and 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.
2. **A callback route** checks that `state` matches, 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](https://loginos.io/developer) 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:

```php title="config/services.php"
'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:

```php title="Migration"
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:

```php title="routes/web.php"
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:

- **`state` and the verifier live in the session** and are pulled out once, so a callback can’t be replayed. `Str::random` uses PHP’s `random_bytes`.
- **The token request** uses HTTP Basic auth with the app key and secret, which loginos accepts as `client_secret_basic`. The answer includes a `user` object, so one request signs the person in. Codes work once and expire after five minutes.
- **`firstOrCreate` keys the user by `sub`.** The unique index on `email` stays, 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](https://loginos.io/docs/link-accounts.md) instead.
- **Naming the redirect route `login`** makes Laravel’s `auth` middleware send guests to loginos, and `redirect()->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

```php title="routes/web.php"
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:

```php title="league/oauth2-client"
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](https://loginos.io/docs/domains.md#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](https://loginos.io/answers/same-accounts-on-multiple-websites.md) 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](https://loginos.io/docs/roles.md).

---

Updated 2026-10-14. Web page: https://loginos.io/answers/laravel-sso-multiple-domains
