Back to blog
October 7, 2026Sergei Solod12 min read

How to Build Cross-Domain SSO Across Independent Root Domains

A practical architecture for unified authentication across independent root domains using host-only sessions, a known authentication issuer, and short-lived one-time handoff tokens without forcing every visitor through an authentication redirect.

Cross-Domain SSOAuthenticationSession SecurityWeb SecurityWeb Architecture

I needed multiple independent websites to behave like one account system without weakening normal browser isolation. A user could sign in on one domain, later open another domain directly, click Log In, and continue there without entering credentials again.

The important constraint was that these were independent root domains. A session cookie created by one root domain cannot simply be scoped to an unrelated root domain. The design became much easier to reason about once I separated two concepts that are often mixed together: global identity and local browser session.

The resulting model is straightforward: the backend recognizes one shared account identity, each domain owns its own host-bound session, and a short-lived one-time handoff lets an authenticated trusted origin authorize creation of a new session on another trusted origin.

Global identity does not require a shared cookie

The account can be global even when browser sessions are local.

Each site keeps a host-only session cookie. A cookie issued by one root domain cannot be extended to an unrelated root domain through the Domain attribute. That attribute can broaden a cookie only within the domain hierarchy that the issuing host is allowed to use.

For a session that should remain bound to one host, a __Host--prefixed cookie is a useful fit. Conceptually:

Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Lax

The important properties are the boundaries:

  • Site A cannot read Site B's host-only session cookie.
  • Site B cannot read Site A's host-only session cookie.
  • Both sessions can still resolve to the same backend identity.

Cross-domain SSO therefore should not attempt to move a cookie from one site to another. It should transfer a temporary proof that a trusted source has already authenticated the user.

The one-time handoff pattern

The authenticated source exposes a small handoff operation. The browser calls it only when a cross-site authentication transition is needed.

The backend generates a cryptographically random ticket, returns the raw value to the browser once, and stores only a hash together with the minimum information needed to validate the transfer:

{
  tokenHash,
  userId,
  sourceOrigin,
  targetSite,
  returnPath,
  expiresAt
}

The ticket should be short-lived and valid for one target only. The source-side issuance request should already be authenticated and protected against CSRF. The requested target and return destination should be validated before anything is issued.

The browser then performs a top-level POST to the target. The target consumes the ticket, resolves the shared user identity, creates its own local session, and redirects the browser to the intended path.

Conceptually:

record = consumeOnce({
  tokenHash: hash(ticket),
  targetSite: currentSite,
  sourceOrigin: requestOrigin,
  notExpired: true
})

if (!record) deny()

user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)

The ticket is not the target session. It is one-time authorization to create that target session.

Atomic consumption closes the replay window

A weaker implementation would load the ticket, validate it, create a session, and delete the ticket afterward. That creates a window in which two requests can observe the same still-valid record.

The safer design is to combine validation and invalidation in one atomic consume operation. If no record matches, authentication fails. If a record is returned, that ticket has already been removed from the usable set before the session is created.

This also lets the target bind several conditions to the same operation: expected target, expected source origin, token hash, and expiry.

Origin validation should fail closed

Origin binding adds another useful property: a ticket issued through one trusted source should not be consumable from an unrelated origin.

The origin check should participate in the consume condition itself. That means a request from the wrong origin does not consume the valid ticket, while the legitimate source can still complete the handoff afterward.

There is one important caveat: an Origin header is not guaranteed to contain a normal origin string in every browser context. Some requests can arrive without a usable value, and opaque contexts can produce Origin: null.

My rule is to treat a missing or opaque Origin as a failed origin check unless the application has an explicit alternative validation mechanism. The literal value null should not become a trusted origin merely for compatibility.

This is a fail-closed choice. If a legitimate flow needs to support a context without a usable origin, that exception should be designed explicitly rather than silently weakening the general check.

Keep the handoff credential out of the URL

I prefer sending the temporary ticket in a top-level form POST instead of putting it into a query string.

A generalized client-side version is:

const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';

const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;

form.append(input);
document.body.append(form);
form.submit();

The target accepts the ticket from the expected POST body and rejects equivalent attempts to provide it through the URL.

This does not make the credential harmless, but it keeps it out of ordinary navigation URLs and reduces accidental exposure through URL history, copied links, referrers, and URL-oriented logging.

Authenticated cross-site links can upgrade themselves

If the current site already knows that the user is authenticated, an ordinary link to another trusted site can opportunistically perform the handoff.

The link should still contain a real href. SSO is an enhancement to normal navigation, not a replacement for it.

A reduced client flow looks like this:

if (session.status !== 'authenticated') {
  return; // ordinary browser navigation
}

if (!isTrustedTarget(destination)) {
  return;
}

event.preventDefault();

const ticket = await requestHandoff({
  targetSite,
  returnPath: destinationPath
});

postTicketToTarget(ticket);

The production version should also protect against duplicate submissions, validate the target against a trusted registry, and fall back to normal navigation if the handoff cannot be created.

Native browser behavior should remain native. Middle-click, modifier-key clicks, downloads, and links intended for another browsing context should not be silently converted into authentication requests.

The Log In button on the second site needs a known issuer

The harder case starts when the user opens Site B directly. Site B has no local session, so it correctly treats the visitor as anonymous even if the browser still has a valid session on Site A.

Site B cannot inspect Site A's host-only cookie. It also cannot redirect to an arbitrary Site C and expect that site to discover sessions belonging to the rest of the system. Each unrelated root domain can observe only the cookies that belong to it.

That means an anonymous Log In flow needs a known issuer: either a canonical authentication origin or a specifically selected trusted site that is expected to hold the reusable login session.

For the simple Site A → Site B case, the sequence is:

  1. The user signs in on Site A.
  2. Later, the user opens Site B directly.
  3. Site B has no session, so it shows Log In.
  4. The user clicks Log In.
  5. The browser performs a top-level navigation to Site A, which is the selected issuer.
  6. Site A can read its own existing host-only session cookie.
  7. Because that session is still valid, Site A immediately issues a one-time handoff for Site B.
  8. The browser POSTs the ticket to Site B.
  9. Site B creates its own local session and returns the user to the requested page.

From the user's perspective, this is a quick redirect to Site A and back. No password is required because Site A already has the session it is allowed to observe.

The limitation is equally important: if the user is logged in only on Site C, redirecting Site B to Site A does not help unless Site A is a true central authentication broker with its own independent knowledge of the login state. A shared user database does not make browser sessions shared.

Why a canonical issuer simplifies the system

As the number of independent sites grows, a canonical issuer removes the need for session discovery across arbitrary domains.

Without one, an anonymous Site B would somehow need to decide which other site might currently have a useful cookie. Site A cannot inspect Site C's cookie, Site C cannot inspect Site A's cookie, and Site B cannot inspect either.

A canonical issuer gives every anonymous login attempt one predictable path:

Site B
  → authentication issuer
  → issuer session exists?
      yes → create one-time handoff
      no  → show normal login
  → Site B consumes handoff
  → Site B creates local session

The issuer does not need to serve the rest of the application. Its relevant responsibility is to own the reusable authentication session and issue tightly scoped handoffs to trusted sites.

For a smaller system, an existing site can play that role. For a broader architecture, a dedicated authentication origin can make the trust model easier to understand.

Do not bounce every visitor through the issuer

The issuer flow should not run automatically for every anonymous page view.

Redirecting every visitor to another domain just to check for an existing session creates needless navigation, extra latency, more failure modes, noisier analytics, and more complicated caching behavior.

It is also a poor fit for public, SEO-sensitive pages. A crawler or ordinary visitor requesting a public URL should receive that public page directly rather than being forced through an authentication detour first.

The rule I use is:

Do not probe the issuer on page load. Start cross-domain authentication only after an explicit Log In action or an intentional authenticated cross-site navigation.

This keeps public traffic simple while still making authentication fast for a user who already has a valid session on the selected issuer.

Return-path validation deserves more care than a prefix check

The handoff usually needs to remember where the browser should land afterward. That value can become an open redirect if it is treated as an arbitrary client-controlled URL.

Where possible, the safest design is not to accept a complete destination URL at all. A short identifier or server-side mapping for known destinations reduces the amount of URL parsing and validation required.

If a relative path must be accepted, validate it carefully and keep it constrained to the target site.

A deliberately reduced example—not a complete redirect-validation routine—is:

function isSafeReturnPath(value) {
  if (!value.startsWith('/')) return false;
  if (value.startsWith('//')) return false;
  if (value.includes('\\')) return false;

  const base = 'https://example.invalid';
  const parsed = new URL(value, base);

  return parsed.origin === base;
}

A real implementation also needs to consider decoding behavior, malformed input, control characters, normalization, application routing rules, and any proxy or framework transformations that occur before the redirect is produced.

The security invariant is simpler than the parser details: the handoff may select an allowed location on the target application, never an arbitrary external destination.

The failure cases matter more than the happy path

A successful login proves only that the basic path works. The more useful tests exercise the trust boundaries.

I verified behavior for cases such as:

  • an unauthenticated source attempting to issue a handoff;
  • missing CSRF protection;
  • a missing, opaque, or unexpected origin;
  • an untrusted target;
  • an unsafe return destination;
  • a ticket presented to the wrong target;
  • a wrong-origin consume attempt;
  • ticket replay;
  • ticket expiry;
  • a user that no longer exists;
  • a ticket supplied through the URL instead of the expected POST body.

Two invariants are especially useful to test directly: a rejected request must not accidentally destroy a ticket that is still valid for the legitimate flow, and a successful consume must make that ticket unusable immediately.

The protocol scales without pairwise integrations

The handoff design does not fundamentally change when more sites are added.

Each participating site needs the same small contract:

  • a stable logical identifier;
  • a trusted public origin;
  • access to the shared account identity;
  • a handoff-issuance operation for authenticated sources;
  • a handoff-consume operation;
  • a trusted-site registry;
  • a host-bound local session;
  • a safe return-destination policy;
  • and a known issuer for anonymous Log In flows.

The important scaling choice is to avoid custom pairwise authentication logic. An already-authenticated site can issue the same kind of handoff to another trusted site. An anonymous site can send the browser to the known issuer.

That keeps the system based on one protocol rather than a growing matrix of special cases.

Login portability is separate from logout propagation

Independent host-bound sessions create another useful distinction: making login portable does not automatically define global logout.

If the user signs out of Site A, another site's local session may remain valid unless the backend intentionally revokes it. That is a session-management policy, not a failure of the SSO handoff.

A system can choose among local logout, global logout, or explicit session management. Keeping that decision separate from the handoff makes both mechanisms easier to reason about.

The model I use

The architecture became simpler once I stopped describing the problem as “sharing login cookies across domains.”

There is one global identity, each domain owns its own browser session, and a short-lived one-time handoff allows an authenticated trusted origin to authorize creation of another local session.

For direct entry into an anonymous site, one additional rule matters: the browser must be sent to an issuer that can actually observe the user's existing session. If the user previously signed in on that issuer, the round trip can be nearly invisible. If the issuer has no valid session, it must fall back to normal authentication rather than pretending another unrelated domain's session is visible.

That model preserves the browser's domain boundaries, avoids unnecessary authentication redirects for public visitors, and still provides the intended experience: sign in on the issuer, open another trusted site later, click Log In, and return with a new local session without entering the password again.