One Login, Many Apps — The Honest Guide to Central-Hub SSO
The Front Door to Everything
You've used this from the outside a hundred times: sign in once, and everything opens. One Google login unlocks Gmail, Calendar, and Drive; one work login puts a dozen internal tools a single click away. You click a tile and you're already inside the app — able to do exactly what your role there allows, no second password, no third. It feels like one system.
Building this is a rite of passage, and it's where a lot of teams quietly make a mess — because the "just log me in everywhere" experience hides four separate decisions, and getting any one of them wrong turns a convenience into a breach waiting to happen.
Let me name the thing you're actually building, because "a hub with tiles" undersells it. You're building a federated-identity application platform: one identity provider as the single anchor for who you are, a launcher that surfaces what you're entitled to, and a set of independently-deployed apps that share identity but each own their data and enforce their own access. The clean way to say it is centralized authentication, decentralized authorization — and that split is the backbone of this entire post.
It forces four decisions, and we'll take them in order:
- The SSO protocol and session model — what "log in once" physically is.
- The onward handoff — how your identity travels from the hub into each app with no second login.
- Where the data lives — one shared database, or a database per app.
- The authorization split — roles assigned centrally, but decided and enforced locally.
One promise up front, and it's the honest core of the whole thing:
"Seamless" means invisible, not absent. Every jump into every app is a full authentication handshake. You're not skipping auth — you're making it happen silently. Once you internalize that, the rest of this stops being magic and starts being architecture.
SSO Fundamentals, Without the Hand-Waving
Three acronyms get blended into mush, and you can't reason about any of this until they're separated. They do different jobs.
| Standard | Job | Answers | Artifact |
|---|---|---|---|
| OAuth 2.0 | Delegated authorization | "What may this app do on my behalf?" | Access token |
| OIDC | Authentication (a layer on OAuth 2.0) | "Who is this user?" | ID token (a JWT) |
| SAML 2.0 | Authentication + SSO (older, XML) | "Who is this user?" | XML assertion |
OAuth 2.0 (RFC 6749) is authorization only — it hands an app a scoped token to call an API for you, and says nothing standard about who you are. Using it as a login mechanism is the classic "pseudo-authentication" anti-pattern. OpenID Connect (OIDC) fixes that by adding a thin identity layer on top of OAuth's machinery: an ID token, a /userinfo endpoint, and standard scopes (openid profile email). SAML is the older XML-based enterprise SSO standard — still everywhere in enterprise and regulated industries, and not going away for another decade, but not what you'd reach for first on a new internal suite.
For a new internal app platform in 2026: OIDC is the default. It's JSON/JWT, works for SPAs, mobile, APIs and server apps alike, has discovery (
/.well-known/openid-configuration) and key rotation built in. Treat SAML as the legacy integration you'll meet when you bolt on an old vendor app — I'll flag those spots.
The two tokens you must not confuse
This trips up everyone, so burn it in:
- The ID token is for your app. Its audience (
aud) is the client. It answers "who logged in." Your app reads it. - The access token is for an API. Its audience is the resource server. It answers "what may the caller do." The API reads it.
Two hard rules follow: an API must never accept an ID token as authorization, and a client must never crack open an access token and depend on its contents (it may be opaque, and its shape isn't your business). This exact split is the hub scenario — the ID token tells the downstream app who arrived; the app then makes its own authorization decision.
The one flow to know: Authorization Code + PKCE
Every modern login uses the authorization code flow with PKCE (RFC 7636). PKCE ("pixy") stops an intercepted authorization code from being redeemed by anyone but the client that started the flow:
1. App generates a random code_verifier, and code_challenge = SHA256(verifier).
2. Browser → IdP /authorize?response_type=code
&scope=openid profile
&redirect_uri=... &state=... &nonce=...
&code_challenge=<hash> &code_challenge_method=S256
3. User authenticates (or is silently recognized — see next section).
4. IdP → redirect back with ?code=...&state=...
5. App → POST /token with the code + the original code_verifier.
6. IdP recomputes the hash, and on match returns id_token + access_token (+ refresh_token).
A note on versions, because the internet gets it wrong: OAuth 2.1 (which makes PKCE mandatory for all clients, removes the insecure implicit and password grants, and requires exact redirect-URI matching) is still an IETF draft in 2026 — it is not an RFC yet. The often-cited RFC 9700 is a different document: the OAuth 2.0 Security Best Current Practice (published early 2025). Cite them separately; don't claim 2.1 "became" 9700.
"Log In Once" — How It Actually Works
Here's the piece that feels like magic and isn't. When you log into the hub, the identity provider sets a session cookie on its own domain. That's it. That cookie is the "one key everywhere."
Now every app that sends your browser to the IdP's /authorize endpoint arrives carrying that cookie. The IdP sees a live session and immediately returns a fresh authorization code — no login screen. The second app, the third, the tenth: each one still runs a complete auth handshake, but because the IdP already knows you, there's no UI. That shared IdP session — not any clever per-app trick — is the entire mechanism.
So what does clicking a tile in the launcher actually do? The portal is just a catalog of the apps you're entitled to. For a modern app, the tile navigates to the app, the app kicks off its own authorization-code flow, and the existing IdP session makes it silent. (For a legacy SAML app, the hub POSTs a signed assertion straight to the app's ACS URL — "IdP-initiated SSO" — which is convenient but weaker; more on why in a moment.)
The Invisible Handshake
This is the heart of "central hub → app without re-authenticating," and there are two families of mechanism. Pick based on who is doing the jumping.
Front-channel: the user navigates into the app
The user clicks through to the app's UI. The app runs a normal SP-initiated code flow but adds prompt=none — OIDC silent authentication:
GET /authorize?...&prompt=none&id_token_hint=<previous id_token>
→ 302 back with ?code=... (there was a live IdP session)
→ 302 back with ?error=login_required (there wasn't — now show a real login)
The app either gets a code with zero UI, or a clean login_required it can handle by doing an interactive redirect. This is the right way to make a tile "just work."
Prefer SP-initiated over IdP-initiated. The tile should deep-link into the app, and let the app bounce to the IdP — not fire an unsolicited assertion at the app. IdP-initiated SSO (the intuitive "POST a SAML assertion into the app" approach) has no request binding, which opens the door to assertion replay and login-CSRF. SP-initiated flows are request-bound and can carry a deep link safely. This is the single most common security mistake in launcher design.
Back-channel: a service calls other services as the user
When a hub backend (say, an orchestrator that answers cross-app queries) needs to call downstream APIs on the user's behalf, there's no browser to redirect. This is OAuth 2.0 Token Exchange (RFC 8693) — or Microsoft's equivalent "On-Behalf-Of" flow. You trade the user's token for a fresh, audience-scoped token per downstream service:
POST /token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<the user's token> # who this is for
subject_token_type=urn:ietf:params:oauth:token-type:access_token
actor_token=<the orchestrator's own token> # who is acting (delegation)
audience=https://reports.internal # exactly one downstream service
scope=reports.read
The minted token preserves the user in sub and records the acting service in a nested act claim:
{ "sub": "user-123", "aud": "https://reports.internal", "act": { "sub": "orchestrator" } }
That's delegation, not impersonation — and the difference matters for audit. Impersonation erases the acting party; delegation keeps both in the record, so your logs show "the orchestrator, acting for user-123." For anything audited and fail-closed, use delegation.
Two constraints that will bite you
The registrable-domain boundary. A cookie can be shared across subdomains of one registrable domain (hub.acme.com ↔ app1.acme.com), but across different domains the browser treats it as third-party and drops it. So apps on separate domains each need their own client registration and must re-federate — silently, riding the shared IdP session, but re-federate nonetheless.
The third-party-cookie apocalypse. Browsers now block third-party cookies by default, which broke the old iframe-based "silent token renewal" and front-channel logout tricks. The current best-practice shape is the Backend-for-Frontend (BFF) pattern: tokens live server-side, the browser holds only an HttpOnly, __Host-prefixed session cookie, and you use refresh-token rotation and back-channel logout. If you skip the BFF and let a SPA hold access and refresh tokens, one XSS bug is total token theft — the IETF's browser-apps guidance explicitly downgrades that pattern.
And the two anti-patterns to tattoo on the back of your hand:
- Never forward the hub's own token downstream. Re-mint a fresh,
aud-scoped token per hop (that's what token exchange is for). Passing one token everywhere is the confused-deputy / token-passthrough vulnerability. - Never trust a forgeable identity header like
X-Forwarded-User: alice. If a caller can set a header, an attacker can too. Carry identity only as a signed, audience-scoped JWT (or bind it to mTLS). A header is a suggestion; a signed token is proof.
Where Does the Data Live? (The Reframe)
Now the question you came for: one shared database for all projects, or a database per project? Almost everyone reaches for the multi-tenancy playbook here. That's the wrong playbook — and seeing why is the most useful thing in this post.
Multi-tenancy is about isolating customers from each other. "Tenant = customer." Its models exist so Customer A can never see Customer B's rows. But a central hub like this is one organization with many apps — there's only one "customer" (the organization itself). In identity terms this is even sharper: to an IdP like Entra, a "tenant" is an entire organization's directory. So an internal app suite is one tenant hosting many app registrations — the exact opposite of SaaS multi-tenancy.
That flip changes everything. Your real question isn't "how do I isolate tenants," it's "should each app own its own datastore?" — which is a microservices question:
| Model | What it is | Trade |
|---|---|---|
| Database per app (usually right) | Each app exclusively owns its store; others reach it only through its API | Autonomy, contained blast radius, independent scaling & schema changes — at the cost of no cross-app JOINs and eventual-consistency work |
| Shared database (usually a trap) | Many apps read/write one database | Easy JOINs and one place to look — at the cost of tight coupling, a shared failure domain, and schema changes that break three teams at once |
For a suite of distinct apps, a database per app is the sensible default — not for tenant isolation, but for the same reasons microservices own their data: independence and a smaller radius when something breaks. SSO federates identity across them regardless of where the bytes sit; the two concerns are orthogonal.
When it is actually multi-tenancy
There's one real exception: a single app inside your suite that itself serves multiple external customers. There, the SaaS tenancy taxonomy applies — from the AWS SaaS Lens: silo (a DB per tenant), pool (one shared DB, rows tagged with tenant_id), or bridge (a mix). The pragmatic default is pool with database-enforced isolation via PostgreSQL Row-Level Security:
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Per request, on the pooled connection, from the *validated* token claim:
SET LOCAL app.current_tenant = 'the-tenant-uuid';
Three gotchas that turn RLS into a false sense of security:
- Use
SET LOCAL, notSET— a plainSETleaks the tenant onto the next request that reuses that pooled connection. That's a cross-tenant data breach in one keyword. - The app must connect as a non-owner, least-privilege role — a table's owner (and any
BYPASSRLSrole) silently ignores RLS policies. - Put the tenant column first in your composite indexes, or every query does more work than it should.
The three identity layers people conflate
One IdP mints a token that says who you are + which org (sub, tid). Then:
- App assignment decides which tiles you see (the hub's entitlement layer).
- Role claims decide what you can do inside an app.
Who you are, which apps you can open, and what you can do in each — three distinct layers. Keep them separate in your head and in your schema.
Central Roles, Local Enforcement
Here's the principle that keeps the whole thing safe, and it's exactly the control-plane / data-plane split: the hub assigns roles; each app enforces them. The hub is a pure control plane — it provisions and mirrors role assignments, and business data never flows through it. Enforcement happens at each app, per request, fail-closed.
The golden rule: the hub says yes, but the app says no → deny. Central role assignment is not central enforcement. If an app trusts "the hub let them in" without independently checking, then one hub bug — or one attacker who hits the app's URL directly — bypasses all your authorization. Every app must validate the token and decide for itself, denying by default.
Getting roles into each app
Three mechanisms, and the honest answer is you use more than one:
- SCIM 2.0 (RFC 7643/7644) — the IdP pushes user and role lifecycle into each app's own store: create, update, and crucially deprovision. Offboarding is a
PATCHthat setsactive: false. Use it so a disabled user loses access everywhere promptly, even from apps they never open. - JIT provisioning — the app creates the user on their first SSO login from the token claims. Zero infrastructure, but it can't deactivate someone who simply never comes back.
- Token claims — roles/groups ride in the token for the app to read at request time. Fast, but tokens bloat and can go stale between refreshes.
Use SCIM for authoritative lifecycle, JIT for onboarding convenience, and claims for the live decision.
Deciding and enforcing
Model access as coarse RBAC + fine ABAC/ReBAC: a role gets you in the door, attributes and relationships decide the specific rows and actions. When you outgrow if (user.role === 'admin') scattered across the codebase, reach for an externalized authorization engine — a policy decision point (PDP) your apps (policy enforcement points, PEPs) call:
- OPA / Rego — general policy engine, big in infra.
- AWS Cedar (and Amazon Verified Permissions) — a purpose-built authz language.
- OpenFGA / SpiceDB — Google Zanzibar-style relationship-based authz ("does user X have relation Y to resource Z").
And mind revocation latency: long-lived tokens mean a fired employee keeps access until expiry. Use short-lived access tokens (minutes), or token introspection on sensitive calls, so a revocation takes effect in seconds, not hours. This is the same fail-closed instinct that once stopped me from running a destructive command against the wrong database — default to no, and make "yes" prove itself.
Securing the Whole Thing
One login that opens everything is, by definition, a skeleton key. Design as if it will be attacked.
- Shrink the blast radius at the front door. Phishing-resistant MFA — passkeys / WebAuthn — plus conditional access and short sessions. If the hub login falls, everything behind it falls, so this is where your strongest control goes. (Plan passkey recovery — device loss — before launch, not after; it's where workforce rollouts stall.)
audrestriction is your anti-confused-deputy control. A token minted forreports.internalmust be rejected bybilling.internal. Validate the audience on every service. This is precisely why you re-mint per hop instead of forwarding.- Validate JWTs properly. Reject
alg: none. Pin the expected algorithm and verify the signature against the IdP's JWKS bykid— don't let an attacker flip RS256 to HS256 and sign with your public key as the HMAC secret. Checkiss,aud,exp,nbfevery time. - Zero-trust between services. Hub-to-service and service-to-service calls should use mTLS / workload identity (SPIFFE-style), not a network assumption. A certificate proves who is calling; the token proves for whom.
- Audit every decision at the point of enforcement — who, acting as whom (
act), on what, allowed or denied. That log is your incident response.
The Forward-Looking Bit: Identity for AI Agents
There's a 2026 twist worth knowing, because it's the same problem one layer up. When an AI orchestrator answers a question that spans several apps — "summarize everything on my plate" — it needs to call each app's API as the user, carrying only that user's access — never more. That's exactly the delegated, audience-scoped token-exchange pattern from earlier, now with a non-human actor in the act claim.
The emerging standard here is Cross-App Access / ID-JAG (an IETF draft, draft-ietf-oauth-identity-assertion-authz-grant), backed by Okta, Auth0, and WorkOS, where a central IdP brokers app-to-app access while each app still controls its own tokens and enforcement — almost a one-to-one map of "hub brokers, app enforces." It's genuinely bleeding-edge (few implementations, still a draft), so treat it as direction-of-travel, not something to depend on yet. If you're wiring AI into your own systems, my guide to building an MCP server covers the connection side; this delegated-identity pattern is how you keep that access scoped to the human behind it.
Reference Architecture, and a Checklist
Put together, the shape is:
┌─────────────────────────────┐
1. log in → │ Identity Provider (IdP) │ ← one session cookie, one identity
└─────────────┬───────────────┘
│ OIDC (code + PKCE)
┌─────────────────────────┼──────────────────────────┐
│ Hub / Launcher (control plane) │ ← shows entitled tiles;
│ assigns + mirrors roles (SCIM / JIT) │ NO business data here
└───┬───────────────┬───────────────┬─────────────────┘
silent SSO silent SSO token exchange (act = orchestrator)
│ │ │
┌─────▼────┐ ┌──────▼───┐ ┌───────▼──────┐
│ App A │ │ App B │ │ App C │ ← each: own DB, validates token,
│ (own DB) │ │ (own DB) │ │ (own DB) │ enforces role, FAIL-CLOSED, audits
└──────────┘ └──────────┘ └──────────────┘
Build-vs-buy, quickly: buy the IdP — Microsoft Entra ID, Okta, Auth0, or self-host Keycloak / Zitadel / Ory if you have data-residency or air-gap constraints; WorkOS if enterprise SSO connections are your product. Rolling your own authentication in 2026 is almost never worth it. But note: you'll usually still own authorization, because role semantics are specific to your apps.
If you build this on Monday, the fail-closed checklist:
- OIDC authorization-code + PKCE for every app; no implicit flow.
- Tiles deep-link into SP-initiated login — never fire unsolicited assertions.
- BFF holds tokens; browser gets an
HttpOnly __Host-cookie; refresh rotation on. - Every hop re-mints an
aud-scoped token; nothing forwards the hub's token. - Every app validates the JWT (sig,
iss,aud,exp) and enforces its own roles, denying by default. - SCIM deprovisioning wired so offboarding revokes access everywhere.
- Short token lifetimes (or introspection) so revocation lands in seconds.
- Passkeys at the front door; mTLS between services; every decision audited.
Wrapping Up
The "one login, everything opens" experience isn't a trick you bolt on — it's the visible tip of a specific architecture: one identity provider, a launcher that reflects entitlements, and apps that share identity but own their data and enforce their own access. The seamless part is a shared IdP session making a real handshake invisible. The safe part is the discipline underneath it: re-mint scoped tokens per hop, let each app be the source of truth for its own data and decisions, and default to no.
Get that split right — centralized authentication, decentralized authorization — and you can add the twentieth app to your hub as easily as the second, without your login becoming a skeleton key or your authorization becoming a single point of failure. That's the whole game.