Per-tenant enterprise SSO in SaaS: implementing SAML and OIDC
For developers adding enterprise SSO to a multi-tenant SaaS product. This guide shows how to model per-tenant SAML and OIDC config, route login requests correctly, validate assertions/tokens, and avoid the operational traps that break customer onboarding.
TL;DR — Per-tenant enterprise SSO means your app chooses a different SAML or OIDC identity provider configuration for each customer tenant at login time, then maps the returned identity to a local user and session. The key design choice is tenant discovery first: if you cannot reliably determine the tenant before redirecting to the IdP, everything else gets brittle. Reading time: ~8 min
What it is and where it sits
You are adding a federation layer between your SaaS login entrypoint and each customer's corporate identity provider. Instead of one global login method, you store one SSO configuration per tenant: protocol (saml or oidc), issuer/entity ID, endpoints, certificates/JWKS, claim mapping, and policy knobs like required domains or JIT provisioning.
In a typical SaaS stack, this sits in the auth service or monolith login module, not in your core business logic. It replaces password login for some tenants, or coexists with password/social login for others. It also adds admin/onboarding workflows: someone from the customer gives you IdP metadata or issuer/client details, you validate them, and you activate the config for that tenant.
The request flow changes in one important way: login is no longer just POST /login. It becomes:
- discover tenant
- load that tenant's SSO config
- redirect to the right IdP
- validate the response against that tenant's config
- map identity to local account and session
View as text
[User Browser] ---- 1. GET /login?tenant=acme ----> [SaaS App] [SaaS App] ---- 2. lookup tenant SSO config ----> [Tenant DB] [SaaS App] ---- 3. redirect to SAML/OIDC IdP ----> [Customer IdP] [Customer IdP] ---- 4. auth response/assertion ----> [SaaS ACS/Callback] [SaaS ACS/Callback] ---- 5. find tenant config ----> [Tenant DB] [SaaS ACS/Callback] ---- 6. create session/user link ----> [App Session] [User Browser] <---- 7. Set-Cookie + redirect ---- [SaaS App]
What talks to it
- Browser: follows redirects, posts SAMLResponse, carries cookies.
- Your app/auth service: starts auth, validates callback, issues app session.
- Tenant config store: usually Postgres.
- Customer IdP: Entra ID, Okta, Google Workspace, ADFS, Ping, Keycloak, etc.
- Optional SCIM service: separate concern for provisioning; do not couple initial SSO rollout to SCIM unless the customer requires it.
What it replaces
For enterprise tenants, it usually replaces local passwords at login time. It does not replace your internal user table, authorization model, audit log, or session management. You still need a local principal record, even if authentication is delegated.
How it actually works
Walk one realistic example: tenant acme uses OIDC; tenant globex uses SAML. We will follow acme.
Assume your login URL is https://app.example.com/login/acme. That path is your tenant discovery mechanism. This is better than trying to infer tenant from email domain before login, because many customers have multiple domains and guest users.
Step 1: Tenant discovery and config lookup
GET /login/acme hits your app. You load the tenant row and its active SSO config:
- protocol:
oidc - issuer:
https://login.microsoftonline.com/<tenant-id>/v2.0 - client_id:
... - client_secret:
... - authorization_endpoint, token_endpoint from discovery
- jwks_uri from discovery
- claim mapping: subject=
sub, email=emailorpreferred_username, groups=groups
Your app generates:
state: random, stored server-side or signed in a short-lived cookienonce: random, for ID token replay protection- PKCE verifier/challenge if you are treating the app as a public client or just want the extra protection
Then redirect:
curl -i https://app.example.com/login/acme
Typical response shape:
HTTP/2 302
location: https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/oauth2/v2.0/authorize?client_id=...&response_type=code&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Foidc%2Fcallback&scope=openid%20profile%20email&state=YjJm...&nonce=NmQ1...&code_challenge=...&code_challenge_method=S256
set-cookie: sso_state=YjJm...; Path=/auth/oidc/callback; HttpOnly; Secure; SameSite=Lax
If your redirect URI is wrong, the browser never reaches your callback. A common diagnostic:
curl -I "https://login.microsoftonline.com/.../authorize?...&redirect_uri=https%3A%2F%2Fwrong.example.com%2Fauth%2Foidc%2Fcallback"
You usually still get a 302 to the IdP login page; the actual failure appears after user auth at the IdP, often as an IdP-hosted error page like AADSTS50011: The redirect URI ... does not match. This is why callback URL mismatches are annoying to debug from server logs alone.
Step 2: IdP authenticates user and returns code
After the user signs in, the IdP redirects back:
GET /auth/oidc/callback?code=0.AVY...&state=YjJm...
Your callback handler must:
- read and compare
state - exchange
codeat the token endpoint - validate ID token signature using the tenant's
jwks_uri - validate
iss,aud,exp, andnonce - extract stable subject identifier
Do not use email as the primary key for identity linkage if you can avoid it. Use (tenant_id, issuer, subject) or (tenant_id, protocol, external_subject) as the durable login identity. Email changes; subject usually does not.
Step 3: Link or provision local user
Suppose the ID token contains:
{
"iss": "https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/v2.0",
"sub": "AAAA1234",
"aud": "your-client-id",
"email": "jane@acme.com",
"preferred_username": "jane@acme.com",
"name": "Jane Doe",
"groups": ["finance-admins"]
}
Your app checks for an existing identity link in federated_identities:
tenant_id = acmeissuer = .../v2.0subject = AAAA1234
If found, load the linked local user. If not found and JIT provisioning is enabled for this tenant, create a local user and the identity link in one transaction. If JIT is disabled, return a controlled error page: authenticated at IdP, but not authorized in app.
Step 4: Create app session
After local user resolution, issue your normal session cookie or app token. Enterprise SSO changes authentication, not your session layer.
Where SAML differs
The same flow exists, but the callback is usually an Assertion Consumer Service endpoint like POST /auth/saml/acs. Instead of exchanging an authorization code, the browser posts a base64-encoded SAMLResponse. Your app validates:
- XML signature against the tenant's configured X.509 cert
- audience restriction
- recipient / ACS URL
- issuer/entity ID
- time window:
NotBefore,NotOnOrAfter - optional
InResponseToif using SP-initiated login
The biggest implementation difference is config handling. OIDC often supports discovery from /.well-known/openid-configuration; SAML often requires metadata XML parsing and more customer-specific cleanup.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| You sell to mid-market/enterprise customers who require corporate login and disable local passwords | Add per-tenant SSO now |
| Different customers use different IdPs or protocols | Support both SAML and OIDC, store protocol-specific config per tenant |
| Most customers are SMB/self-serve and only a few ask for SSO | Start with one protocol through a broker or only OIDC, then expand |
| You cannot identify tenant before redirecting to login | Fix tenant discovery first; do not bolt on SSO yet |
| You only need Google/Microsoft social login for individual users | You probably do not need enterprise SSO |
| You expect group/role sync on day one | Separate auth from provisioning/authorization; SSO alone is not enough |
| Your app is single-tenant and deployed separately per customer | Simpler: one IdP config per deployment, not per-tenant multiplexing |
You probably don't need this if...
- your product is not sold B2B
- customers are fine with password + MFA in your app
- you have no tenant concept and no near-term plan to add one
- your “enterprise SSO” ask is really just “let users sign in with Google”
Trade-offs
| Benefit | What it costs |
|---|---|
| Meets enterprise procurement/security requirements | Weeks of implementation and ongoing support for customer-specific misconfigurations |
| Centralized customer auth, MFA, lifecycle control | You inherit dependency on external IdP uptime and customer admin competence |
| Reduced password storage/rotation burden | More callback, certificate, clock-skew, and redirect debugging |
| Per-tenant flexibility (SAML or OIDC) | More schema complexity, more branches in auth code, more test matrix |
| OIDC discovery and JWKS rotation can be mostly automatic | SAML metadata/cert rollover is often manual and breaks at inconvenient times |
| JIT provisioning speeds onboarding | Risk of creating unwanted accounts unless domain/claim policy is tight |
| Group claims can drive authorization | Group formats vary wildly; large group sets may be omitted or truncated |
A practical rule: support both protocols at the product level, but prefer OIDC for new customer setups when the customer's IdP supports it. SAML remains necessary because many enterprise environments still expose it more readily to SaaS apps.
In practice
Example 1: Postgres schema for per-tenant SSO config and identity links
create table sso_connections (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
protocol text not null check (protocol in ('oidc', 'saml')),
is_active boolean not null default false,
issuer text,
entity_id text,
sso_url text,
slo_url text,
acs_url text,
client_id text,
client_secret_ciphertext text,
discovery_url text,
jwks_uri text,
x509_cert_pem text,
attribute_mapping jsonb not null default '{}'::jsonb,
allowed_email_domains text[] not null default '{}',
jit_provisioning boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, is_active) deferrable initially immediate
);
create unique index one_active_sso_per_tenant
on sso_connections (tenant_id)
where is_active = true;
create table federated_identities (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
user_id uuid not null references users(id) on delete cascade,
protocol text not null check (protocol in ('oidc', 'saml')),
issuer text not null,
subject text not null,
created_at timestamptz not null default now(),
unique (tenant_id, issuer, subject)
);
This stores one active SSO connection per tenant and a stable external identity link. Gotcha: do not rely on email uniqueness for login linkage; mergers, aliases, and domain changes will hurt you.
Example 2: OIDC callback in Node.js using discovery metadata
import express from "express";
import crypto from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
const app = express();
app.get("/auth/oidc/callback", async (req, res) => {
const { code, state } = req.query;
const expectedState = req.cookies.sso_state;
const tenantSlug = req.cookies.sso_tenant;
if (!code || !state || state !== expectedState) {
return res.status(400).send("invalid_state");
}
const conn = await db.sso_connections.findActiveByTenantSlug(tenantSlug);
if (!conn || conn.protocol !== "oidc") {
return res.status(400).send("sso_connection_not_found");
}
const body = new URLSearchParams({
grant_type: "authorization_code",
code: String(code),
redirect_uri: "https://app.example.com/auth/oidc/callback",
client_id: conn.client_id,
client_secret: decrypt(conn.client_secret_ciphertext)
});
const tokenResp = await fetch(conn.token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body
});
if (!tokenResp.ok) {
const txt = await tokenResp.text();
return res.status(502).send(`token_exchange_failed: ${tokenResp.status} ${txt}`);
}
const tokens = await tokenResp.json();
const JWKS = createRemoteJWKSet(new URL(conn.jwks_uri));
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
issuer: conn.issuer,
audience: conn.client_id
});
const subject = String(payload.sub);
const email = payload.email || payload.preferred_username || null;
const user = await findOrProvisionUser({
tenantId: conn.tenant_id,
issuer: conn.issuer,
subject,
email,
claims: payload,
jit: conn.jit_provisioning
});
if (!user) return res.status(403).send("user_not_provisioned");
await createSession(res, user.id);
res.redirect("/app");
});
This exchanges the auth code, verifies the ID token, and links/provisions the user. Gotcha: if your app runs behind a proxy and computes callback URLs dynamically, a missing X-Forwarded-Proto trust setting will produce http:// redirect URIs and hard-to-diagnose IdP mismatch errors.
Example 3: Diagnosing a broken SAML ACS endpoint behind nginx
⚠️ Changing reverse-proxy auth routes can break login for every SSO tenant immediately. Apply in staging first, then reload nginx with a rollback command ready.
server {
listen 443 ssl http2;
server_name app.example.com;
location /auth/saml/acs {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 10m;
}
}
SAML responses are browser POSTs and can exceed default body limits if assertions include many attributes. Gotcha: if nginx returns 413 Request Entity Too Large, the user sees a generic IdP failure after login.
Diagnostic command:
curl -i https://app.example.com/auth/saml/acs
Typical wrong-method but healthy route:
HTTP/2 405
allow: POST
content-type: text/plain; charset=utf-8
Method Not Allowed
Typical proxy/body issue from logs:
tail -f /var/log/nginx/error.log
client intended to send too large body: 245812 bytes, client: 203.0.113.10, server: app.example.com, request: "POST /auth/saml/acs HTTP/2.0"
Further reading
- OpenID Connect Core 1.0
- OpenID Connect Discovery 1.0
- RFC 7519: JSON Web Token (JWT)
- SAML 2.0 Technical Overview
- OWASP Cheat Sheet Series: SAML Security Cheat Sheet
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI