How do I implement single sign-on across different domains?
Short answer
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? 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, the OAuth security best practice from January 2025, requires exact string matching. So register one callback per domain:
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
httponly onlocalhost, 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?
- 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’sProxyFixmiddleware 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:
stateis a random value you store in the browser and send along. The callback accepts a code only ifstatecomes back unchanged, so nobody can slip their sign-in into your visitor’s browser.- PKCE (Proof Key for Code Exchange, RFC 7636) 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.
// 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;
}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:
- Find or create the account by
sub.subis 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. - 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.
- Set the cookie carefully.
HttpOnlyhides it from JavaScript,Securekeeps it on HTTPS, andSameSite=Laxkeeps it off cross-site form posts. Leave outDomain, so it stays on this host. In production, the__Host-prefix makes the browser enforce that. - 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? 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), 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:
- After signing in on domain A, opening domain B signs you in after a short redirect, without a form.
- Domain B in a private window shows the sign-in form.
- A changed
statein the callback URL is refused. - Reloading the callback URL after a sign-in fails, because codes work once.
- 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:
- In the developer console, 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. - Add your other domains in the console, 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. - loginos sends people back only to
https://<one of your domains><callback path>, with the same path everywhere:/api/auth/login/single/callbackunless 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 has the same flow for Express and PHP, and the error reference lists every error. Build with AI 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? 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.
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 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 can users log in once and stay logged in on all my websites?Keep one long-lived session at a central login, an OpenID Connect provider, and a separate session on each of your websites. When a site has no session, it asks the provider silently with prompt=none, and people who are signed in there come straight back without a form. Each site renews its own session this way, so people stay logged in everywhere until the central session ends.8 min read
- Existing usersHow do I add SSO to an app that already has users?Add SSO as a second way into the accounts you already have, not as a new user base. Let signed-in users connect the new login from their settings, so your app stores the provider’s user id against their account. Keep passwords working while people link, then decide whether to retire them. Never match accounts by an unverified email. With loginos, every token then carries your own user id.7 min read
- Customer domainsCan I use wildcard redirect URIs with OAuth?Usually not, and you shouldn’t rely on them. RFC 9700, the OAuth 2.0 Security Best Current Practice, requires authorization servers to match redirect URIs exactly, because loose patterns have let attackers steal authorization codes. Google rejects wildcards, while Auth0 and Okta allow narrow ones with warnings. Register exact URIs through an API instead, or use a provider that verifies wildcard ownership or asks your server.7 min read