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.0 | OpenID Connect | |
|---|---|---|
| Message format | XML with XML signatures | JSON and JWTs |
| Library support in modern web stacks | Patchy, often a separate dependency | Built into most auth libraries |
| Mobile and SPA support | Awkward | Designed for it (with PKCE) |
| Token for APIs | Not part of the protocol | Access tokens via OAuth 2.0 |
| Typical use | Enterprise IdP federation | First-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.
- The user opens
app.example.com. There’s no local session, so the app redirects toid.example.com/authorize. - The user signs in with a passkey. The provider sets its own session cookie on
id.example.comand redirects back with a code. - The app exchanges the code for tokens and creates its own session.
- Later the user opens
docs.example.com. No local session there either, so it also redirects toid.example.com/authorize. - 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.
- 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.
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:
- 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.”
- 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. - 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.comfrom 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.