Blog · OAuth & OIDC

Single sign-on across your own apps, without SAML

Set up single sign-on for multiple apps you own with OpenID Connect instead of SAML: one identity provider session, silent sign-in with prompt=none, and logout.

To get single sign-on across multiple apps you own, point every app at the same OpenID Connect identity provider. The user signs in once at the provider, which sets a session cookie on its own domain. When the user opens a second app, that app redirects to the provider as usual, the provider sees the existing session, and it sends them straight back with an authorization code, with no prompt. Each app keeps its own local session and its own tokens. No SAML, no shared cookies between your apps, and no custom token-passing code.

This is the pattern behind “sign in once, use the dashboard, the docs, the billing portal and the mobile app.” Here’s how it works and how to build it.

Why not SAML?

SAML is the established protocol for enterprise SSO, and if you’re selling to companies that want to connect their own identity provider, you’ll likely need it eventually. But for SSO across your own apps, it’s heavier than you need:

SAML 2.0OpenID Connect
Message formatXML with XML signaturesJSON and JWTs
Library support in modern web stacksPatchy, often a separate dependencyBuilt into most auth libraries
Mobile and SPA supportAwkwardDesigned for it (with PKCE)
Token for APIsNot part of the protocolAccess tokens via OAuth 2.0
Typical useEnterprise IdP federationFirst-party and consumer apps

XML signature validation has also been a recurring source of security bugs. OIDC gives you the same “sign in once” behavior with simpler moving parts.

How OIDC single sign-on works

Say you have two apps, app.example.com and docs.example.com, both registered with the same identity provider at id.example.com.

  1. The user opens app.example.com. There’s no local session, so the app redirects to id.example.com/authorize.
  2. The user signs in with a passkey. The provider sets its own session cookie on id.example.com and redirects back with a code.
  3. The app exchanges the code for tokens and creates its own session.
  4. Later the user opens docs.example.com. No local session there either, so it also redirects to id.example.com/authorize.
  5. The browser sends the provider’s session cookie. The provider recognizes the user and immediately redirects back with a code, with no sign-in screen.
  6. The docs app exchanges the code and creates its own session.

The user saw one sign-in and two quick redirects. Each app got its own ID token, with its own aud claim, and the same sub for the user.

Use the sub claim as your user key in every app. It’s the same for one user across all apps registered with the same provider, which is what lets you join data between them.

Silent sign-in with prompt=none

Sometimes you want to check for a session without ever showing a sign-in page, for example on a marketing site that shows “Go to dashboard” for signed-in visitors. OIDC handles this with prompt=none:

GET /authorize?response_type=code
  &client_id=marketing-site
  &redirect_uri=https://www.example.com/auth/callback
  &scope=openid%20email
  &code_challenge=...&code_challenge_method=S256
  &state=...&nonce=...
  &prompt=none

The provider responds one of two ways:

  • Signed in: a normal redirect with a code.
  • Not signed in: a redirect with error=login_required. Show your own sign-in button.

It never displays UI, so it’s safe to run as a top-level redirect on page load. Run it once per visit, not on every request.

Forcing re-authentication with prompt=login

The opposite case: before a sensitive action such as changing an email address or deleting an account, you want proof the user is present now, not 29 days into a session. Send prompt=login and the provider asks for a passkey even if a session exists. Check the iat claim in the returned ID token to confirm the sign-in was recent.

Logout across apps

Logout is where SSO needs a deliberate decision. There are three levels:

  1. Local logout. Clear the app’s own session. The provider session survives, so visiting the app again signs the user straight back in. Fine for “switch tabs” but surprising for “sign out.”
  2. Provider logout. Clear the local session, then redirect to the provider’s end_session_endpoint. The next app that redirects to the provider will prompt for sign-in.
  3. Everywhere, immediately. Every app also drops its local session. That needs extra plumbing, because each app has to learn about the logout. Short-lived access tokens limit the window in the meantime: a stale local session can’t keep calling your APIs for long.

For most products, option 2 matches what users expect when they click “Sign out.”

// After clearing your own session cookie:
const logout = new URL("https://id.example.com/logout");
logout.searchParams.set("post_logout_redirect_uri", "https://app.example.com/");
return Response.redirect(logout, 302);

Architecture tips

  • Register each app separately. Separate client IDs mean separate audiences, separate redirect URIs and separate audit trails.
  • Don’t share cookies across subdomains. Let each app hold its own session. The provider is the only shared state.
  • Keep tokens short and refresh. Short access tokens with refresh token rotation limit how long a revoked session lingers. See refresh tokens.
  • Run the provider on your domain. With passkeys, the provider’s domain is the RP ID. Running it at id.yourcompany.com from the start avoids re-enrolling users later.

Gatekeep and single sign-on

Gatekeep provides this out of the box. One passkey sign-in covers every app you register, with silent SSO between them. Session cookies are HttpOnly, Secure and SameSite=Lax, and slide for 30 days. prompt=none and prompt=login are supported, and /logout ends the Gatekeep session and returns users to a registered app.

Each app integrates over standard OIDC with PKCE, so a Next.js dashboard, a Python API and an Express admin tool can share one sign-in. Read SSO and logout, see the Next.js passkey guide or PKCE explained, then request early access.

Delete the password field.

Passkey sign-in for every app you ship. Free up to 5,000 monthly active users.