# How 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.

You have apps on different domains, like `app.example.com`, `example.net` and `shop.example.org`, and you want one account and one sign-in for all of them. You need one identity provider, two routes on every domain, and a local session on each. Below is the build, step by step, with code for Next.js and Python (Flask).

For the background, [How do I share a login session across multiple domains?](https://loginos.io/answers/share-login-session-across-domains.md) explains why cookies can’t do this and how the redirect flow works.

## Step 1: Choose a protocol and a provider

### OpenID Connect or SAML?

For websites and apps you build, use OpenID Connect (OIDC), a sign-in layer on top of OAuth 2.0. Your app gets a one-time code, swaps it on the server, and learns who signed in. SAML 2.0 is an older XML standard, common in company sign-in systems for staff.

| | OpenID Connect | SAML 2.0 |
| --- | --- | --- |
| Built on | OAuth 2.0 | Its own XML protocol, an OASIS standard since 2005 |
| Your app receives | JSON, with a signed ID token (a JWT) | A signed XML assertion |
| Pick it when | You build the apps and the sign-in | A customer’s identity system requires it |

Many providers speak both, so you can add SAML later for customers who ask for it.

### Self-hosted or hosted?

- **Self-hosted:** open-source servers such as Keycloak and authentik. You get full control, including a sign-in page on your own domain, and you take on updates, backups and uptime.
- **Hosted:** services such as Auth0, Amazon Cognito and loginos. Check how each handles many domains: how many callback URLs a plan allows, whether an API can add them, and whether the sign-in page can use your domain. Costs depend on the plan, so check each vendor’s pricing page.

All of them speak OpenID Connect and publish their endpoints at `/.well-known/openid-configuration`, so the code below changes little between them.

## Step 2: Register your domains and redirect URIs

A redirect URI, or callback URL, is the address on your site where the provider sends people back with a code. Providers must compare it with your list exactly: [RFC 9700](https://www.rfc-editor.org/rfc/rfc9700), the OAuth security best practice from January 2025, requires exact string matching. So register one callback per domain:

```text title="Redirect URIs"
https://app.example.com/api/auth/login/single/callback
https://example.net/api/auth/login/single/callback
https://shop.example.org/api/auth/login/single/callback
http://localhost:3001/api/auth/login/single/callback
```

- **Use the same path on every domain.** Your code can then build the callback from the request’s own host, and one codebase serves every domain.
- **Allow plain `http` only on `localhost`**, for development.
- **Automate it if domains come and go.** Hand-edited lists don’t scale, and wildcards are risky: see [Can I use wildcard redirect URIs with OAuth?](https://loginos.io/answers/wildcard-redirect-uri-oauth.md)
- **Behind a proxy, check the host your app sees.** It must be the public host with `https`, or the callback won’t match. In Flask, Werkzeug’s `ProxyFix` middleware fixes this.

## Step 3: Add the sign-in route and the callback

Every domain needs two routes: one sends the browser to the provider, and one takes it back. Two values protect the trip:

- **`state`** is a random value you store in the browser and send along. The callback accepts a code only if `state` comes back unchanged, so nobody can slip their sign-in into your visitor’s browser.
- **PKCE** (Proof Key for Code Exchange, [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)) is a random secret called the verifier. Its SHA-256 hash goes out with the redirect, and the code exchange must include the verifier itself, so a stolen code is useless.

Keep both in an HttpOnly cookie for ten minutes, with `SameSite=Lax`, not `Strict`: the way back from the provider starts on another site, and browsers don’t send `Strict` cookies on it. The callback checks `state`, handles errors, and exchanges the code on your server, where the client secret lives.

```ts title="Next.js"
// app/api/auth/login/single/route.ts: send the browser to loginos
import { createHash, randomBytes } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";

const LOGINOS = "https://loginos.io";
const CALLBACK = "/api/auth/login/single/callback";
const b64url = (b: Buffer) => b.toString("base64url");

export function GET(req: NextRequest) {
  const state = b64url(randomBytes(16));
  const verifier = b64url(randomBytes(32)); // PKCE
  const url = new URL("/oauth/authorize", LOGINOS);
  url.search = new URLSearchParams({
    client_id: process.env.LOGINOS_APP_KEY!,
    redirect_uri: req.nextUrl.origin + CALLBACK, // this domain's own callback
    response_type: "code",
    scope: "openid profile email",
    state,
    code_challenge: b64url(createHash("sha256").update(verifier).digest()),
    code_challenge_method: "S256",
  }).toString();
  const res = NextResponse.redirect(url);
  // Lax, not Strict: the way back from loginos starts on another site.
  res.cookies.set("lo_flow", `${state}.${verifier}`, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600 });
  return res;
}

// app/api/auth/login/single/callback/route.ts: check, exchange, start the session
export async function GET(req: NextRequest) {
  const q = req.nextUrl.searchParams;
  const [state, verifier] = (req.cookies.get("lo_flow")?.value ?? "").split(".");
  if (!state || q.get("state") !== state) return new Response("Sign-in expired, try again.", { status: 400 });
  if (q.get("error")) return NextResponse.redirect(new URL("/?signin=cancelled", req.url)); // e.g. access_denied

  const res = await fetch(`${LOGINOS}/oauth/token`, {
    method: "POST",
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: q.get("code") ?? "",
      redirect_uri: req.nextUrl.origin + CALLBACK,
      code_verifier: verifier,
      client_id: process.env.LOGINOS_APP_KEY!,
      client_secret: process.env.LOGINOS_APP_SECRET!,
    }),
  });
  if (!res.ok) return new Response("Sign-in failed, try again.", { status: 401 }); // e.g. invalid_grant
  const { user } = await res.json(); // sub, preferred_username, name, email, roles, permissions

  const accountId = await findOrCreateUserBySub(user.sub, user); // your own: match on sub, never on email
  const sessionId = await createSession(accountId); // your own: a new random id, stored on your server
  const done = NextResponse.redirect(new URL("/", req.url));
  done.cookies.delete("lo_flow");
  done.cookies.set("session", sessionId, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 7 });
  return done;
}
```

```python title="Flask"
import base64, hashlib, os, secrets
from urllib.parse import urlencode

import requests
from flask import Flask, abort, redirect, request, session

LOGINOS = "https://loginos.io"
CALLBACK = "/api/auth/login/single/callback"

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE="Lax",  # not Strict: the way back from loginos starts on another site
    PERMANENT_SESSION_LIFETIME=7 * 24 * 3600,
)

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

@app.get("/api/auth/login/single")
def sign_in():
    session["lo_state"] = secrets.token_urlsafe(16)
    session["lo_verifier"] = secrets.token_urlsafe(32)  # PKCE
    params = {
        "client_id": os.environ["LOGINOS_APP_KEY"],
        "redirect_uri": request.host_url.rstrip("/") + CALLBACK,  # this domain's own callback
        "response_type": "code",
        "scope": "openid profile email",
        "state": session["lo_state"],
        "code_challenge": b64url(hashlib.sha256(session["lo_verifier"].encode()).digest()),
        "code_challenge_method": "S256",
    }
    return redirect(f"{LOGINOS}/oauth/authorize?{urlencode(params)}")

@app.get(CALLBACK)
def callback():
    state = session.pop("lo_state", None)
    verifier = session.pop("lo_verifier", None)
    if not state or request.args.get("state") != state:
        abort(400, "Sign-in expired, try again.")
    if "error" in request.args:  # e.g. access_denied
        return redirect("/?signin=cancelled")

    r = requests.post(f"{LOGINOS}/oauth/token", timeout=10, data={
        "grant_type": "authorization_code",
        "code": request.args.get("code", ""),
        "redirect_uri": request.host_url.rstrip("/") + CALLBACK,
        "code_verifier": verifier,
        "client_id": os.environ["LOGINOS_APP_KEY"],
        "client_secret": os.environ["LOGINOS_APP_SECRET"],
    })
    if not r.ok:  # e.g. invalid_grant
        abort(401, "Sign-in failed, try again.")
    user = r.json()["user"]  # sub, preferred_username, name, email, roles, permissions

    account_id = find_or_create_user_by_sub(user["sub"], user)  # your own: match on sub, never on email
    session.clear()  # start a fresh session for the signed-in person
    session["user_id"] = account_id
    session.permanent = True
    return redirect("/")
```

The code targets loginos. With another provider, take the authorize and token endpoints from its discovery document.

## Step 4: Create the local session

After the exchange you know who signed in. Now make them a user of this domain:

1. **Find or create the account by `sub`.** `sub` is the provider’s stable id for the person, the same on all of your domains. Never match accounts by email: an address the provider hasn’t verified proves nothing.
2. **Start a fresh session.** Create a new session id at sign-in instead of keeping the old one. OWASP calls this mandatory, because it stops session fixation, where an attacker plants a session id before the victim signs in.
3. **Set the cookie carefully.** `HttpOnly` hides it from JavaScript, `Secure` keeps it on HTTPS, and `SameSite=Lax` keeps it off cross-site form posts. Leave out `Domain`, so it stays on this host. In production, the `__Host-` prefix makes the browser enforce that.
4. **Choose the session length yourself.** It doesn’t have to follow the ID token’s expiry. Store sessions on the server if you want to end them from there. [How can users log in once and stay logged in on all my websites?](https://loginos.io/answers/stay-logged-in-across-websites.md) covers lifetimes and silent renewal.

## Step 5: Handle errors

| What happens | Usually means | Do this |
| --- | --- | --- |
| `state` is missing or different | An old tab, an expired cookie, or a forged request | Don’t exchange the code. Say “Sign-in expired” and offer a new start. |
| `?error=access_denied` | The person cancelled, or isn’t allowed in | Go back with a short message |
| `?error=login_required` or `consent_required` | Only after `prompt=none`: nobody is signed in, or the person hasn’t approved your app | Show the signed-out page |
| `invalid_grant` from the token endpoint | The code expired, was used twice, or the verifier or redirect URI differs | Start the sign-in again |
| `invalid_client` | Wrong client id or secret | Fix your configuration |

These are standard OAuth 2.0 and OpenID Connect error codes. Log the details, and show people a plain message with a way to retry.

## Step 6: Test with two domains

Test locally first: run two apps on `localhost:3001` and `localhost:3002`, and register both. Cookies ignore the port ([RFC 6265, section 8.5](https://www.rfc-editor.org/rfc/rfc6265#section-8.5)), so the two apps see each other’s cookies. Give each app its own cookie names, or one app’s cookie can hide a bug in the other.

Then test on two real HTTPS domains, such as two staging sites. Subdomains of `vercel.app` or `netlify.app` work too: both are on the public suffix list, so browsers treat each subdomain as a separate site.

On both setups, check that:

1. After signing in on domain A, opening domain B signs you in after a short redirect, without a form.
2. Domain B in a private window shows the sign-in form.
3. A changed `state` in the callback URL is refused.
4. Reloading the callback URL after a sign-in fails, because codes work once.
5. Safari works too. It blocks all third-party cookies, and nothing above needs them.

## How loginos does it

loginos is a hosted OpenID Connect provider for one product on many domains, so steps 1 and 2 are short:

1. In the [developer console](https://loginos.io/developer), create a platform with its first domain. You get an app key, which is your `client_id`, and an app secret that stays on your server.
2. Add your other domains in the console, [from your server](https://loginos.io/docs/domains.md#from-your-server) up to 1,000 at a time, or with a verified wildcard like `*.example.com`. There is no limit on domains, and they all share one pool of people.
3. loginos sends people back only to `https://<one of your domains><callback path>`, with the same path everywhere: `/api/auth/login/single/callback` unless you change it. You register domains, not a list of URLs.

The code in step 3 is complete for loginos. The token response carries a `user` object with `sub`, `roles` and `permissions`, so you don’t have to decode the ID token. The [quickstart](https://loginos.io/docs/quickstart.md) has the same flow for Express and PHP, and the [error reference](https://loginos.io/docs/oidc.md#errors) lists every error. [Build with AI](https://loginos.io/docs/ai.md) has a prompt that lets a coding agent add it for you.

loginos isn’t the right fit everywhere. It speaks OpenID Connect only, without SAML or social sign-in, its sign-in page always runs on loginos.io, and it can’t be self-hosted. If you need any of those, choose another provider from step 1.

## Frequently asked questions

### Do I need a separate client ID for each domain?

Usually not. One client with a callback URL for each domain works when the provider accepts several redirect URIs. Every domain then shares the same accounts and central session. Some teams still create one client per app, to give each its own secret.

### Can I build single sign-on without an identity provider?

You can hand a short-lived, one-time token from one domain to the next. Then you write the hard parts of a provider yourself: single use, expiry, replay protection and strict redirect checks. A self-hosted open-source provider gives you the same control with less to get wrong.

### Is single sign-on across subdomains different?

Subdomains of one registrable domain, like `app.example.com` and `shop.example.com`, can share a cookie set with `Domain=example.com`, so they don’t strictly need a central login. You need one as soon as you add a second domain. [How do I share authentication cookies between two domains?](https://loginos.io/answers/share-cookies-between-domains.md) explains the cookie rules.

### Should I use a library or write the routes myself?

Either works. A maintained OpenID Connect library, such as openid-client or Auth.js in Node.js and Authlib in Python, handles discovery, PKCE, `state` and ID token checks. The two routes above are short enough to own, and knowing each step makes sign-in problems easier to debug.

---

Updated 2026-10-14. Web page: https://loginos.io/answers/single-sign-on-across-domains
