SSO Mapping & Identity Federation
Mapping a user from an external identity provider to the right tenant — and only that tenant — is the most failure-prone step in a B2B SaaS login, and it sits squarely inside Auth Isolation & Cross-Tenant Access Control. Every SAML response or OIDC ID token arriving at your callback endpoint carries claims minted by a system you do not control, so federation is fundamentally an exercise in turning untrusted assertions into a trusted, narrowly-scoped tenant context. Get the trust boundary wrong and one customer's admin signs in as another customer's user.
This page is a practical build guide. It covers how to resolve tenant from an assertion, how to translate external groups into internal roles, how to provision users just-in-time, and how to keep the whole pipeline fast under per-tenant JWKS caching. For provider-specific onboarding see the Okta SSO integration walkthrough; for the group-to-role transformation in depth see mapping external IdP groups to tenant roles.
Prerequisites
- [ ] An OIDC client or SAML SP per tenant connection (or one multi-tenant client with per-org metadata) registered with each customer's IdP.
- [ ] A tenant registry table mapping
idp_issuer,idp_entity_id, and verified email domains to atenant_id. - [ ] A signing-key cache layer (Redis or in-process LRU) for JWKS and SAML metadata certificates.
- [ ] An RBAC model already defined per tenant — see role-based access control per tenant.
- [ ] A session/token mint path that stamps
tenant_idinto every credential — see tenant-aware JWT & token management. - [ ] Node.js 18+ (or equivalent),
openid-client≥ 5,samlify≥ 2.8, and PostgreSQL 14+ if you scope at the database with RLS.
Step-by-Step Implementation
1. Resolve the tenant before trusting the assertion
The first decision is which tenant this login belongs to, and it must be made from a signed value, never from a query parameter or an unsigned form field. For OIDC, derive the tenant from the token iss (issuer) — the issuer URL is bound to a specific IdP tenant and is covered by the signature. For SAML, use the Issuer element of the assertion. Only fall back to email-domain matching after the signature has verified.
import { Issuer } from 'openid-client';
// registry: issuer URL -> { tenantId, jwksUri }
export async function resolveTenantFromIssuer(issuerUrl: string) {
const row = await db.oneOrNone(
`SELECT tenant_id, jwks_uri, audience
FROM tenant_idp_connections
WHERE idp_issuer = $1 AND enabled = true`,
[issuerUrl]
);
if (!row) throw new Error(`No tenant for issuer ${issuerUrl}`);
return row;
}
2. Verify the signature against the tenant's keys
Fetch the IdP's JWKS by the issuer's jwks_uri (resolved in step 1), then verify the ID token. Pin the expected aud and iss to the values in the registry so a token minted for tenant A can never satisfy tenant B.
import { Issuer } from 'openid-client';
export async function verifyIdToken(idToken: string, conn: TenantConn) {
const issuer = await Issuer.discover(conn.idpIssuer);
const client = new issuer.Client({
client_id: conn.clientId,
token_endpoint_auth_method: 'private_key_jwt',
});
// openid-client validates signature, exp, iss, aud, nonce
const verified = await client.validateIdToken(
{ id_token: idToken } as any,
conn.expectedNonce,
'authorization_code'
);
if (verified.aud !== conn.audience) throw new Error('aud mismatch');
return verified;
}
3. Normalize external claims into a canonical shape
Every IdP names things differently: Okta sends groups, Azure AD sends roles or group object IDs, a raw SAML response sends an AttributeStatement. Collapse them into one internal structure before any role logic runs, so downstream code never branches on provider.
interface NormalizedIdentity {
externalSubject: string;
email: string;
emailDomain: string;
groups: string[];
tenantId: string;
}
export function normalize(claims: Record<string, unknown>, tenantId: string): NormalizedIdentity {
const email = String(claims.email ?? '').toLowerCase();
const groups = ([] as string[]).concat(
(claims.groups as string[]) ?? (claims.roles as string[]) ?? []
);
return {
externalSubject: String(claims.sub),
email,
emailDomain: email.split('@')[1] ?? '',
groups,
tenantId,
};
}
4. Map external groups to internal roles
Resolve normalized groups into tenant-scoped roles using a per-tenant mapping table, never a global one — Engineering-Admins means different things to different customers. Default to least privilege when no mapping matches. The deeper rules (precedence, conflicts, nested groups) live in the dedicated group-to-role mapping guide.
SELECT internal_role
FROM tenant_group_role_map
WHERE tenant_id = $1
AND external_group = ANY($2::text[])
ORDER BY priority DESC;
export async function mapRoles(id: NormalizedIdentity): Promise<string[]> {
const rows = await db.any(
`SELECT internal_role FROM tenant_group_role_map
WHERE tenant_id = $1 AND external_group = ANY($2::text[])
ORDER BY priority DESC`,
[id.tenantId, id.groups]
);
const roles = rows.map(r => r.internal_role);
return roles.length ? roles : ['tenant_member']; // least privilege fallback
}
5. Provision the user just-in-time
If the verified email domain is on the tenant's allow-list and no account exists, create one inside a transaction so the user and their role grant land atomically. Never auto-provision from an unverified domain.
export async function jitProvision(id: NormalizedIdentity, roles: string[]) {
return db.tx(async t => {
const user = await t.one(
`INSERT INTO users (tenant_id, email, external_subject)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, external_subject)
DO UPDATE SET email = EXCLUDED.email
RETURNING id`,
[id.tenantId, id.email, id.externalSubject]
);
await t.none(`DELETE FROM user_roles WHERE user_id = $1`, [user.id]);
for (const role of roles) {
await t.none(
`INSERT INTO user_roles (user_id, tenant_id, role) VALUES ($1, $2, $3)`,
[user.id, id.tenantId, role]
);
}
return user.id;
});
}
6. Mint a tenant-scoped session
Stamp tenant_id and the resolved roles into the credential. From here the rest of the system relies on that stamp; the assertion is discarded. Use the patterns in tenant-aware JWT & token management so claims and audiences stay consistent across services.
import jwt from 'jsonwebtoken';
export function mintSession(userId: string, id: NormalizedIdentity, roles: string[]) {
return jwt.sign(
{ sub: userId, tenant_id: id.tenantId, roles },
getTenantSigningSecret(id.tenantId),
{ audience: `tenant:${id.tenantId}`, issuer: 'saas-auth', expiresIn: '1h' }
);
}
The figure below shows the full resolve-then-trust flow these six steps assemble.
Choosing a tenant-resolution strategy
The order you try resolution methods determines your security posture. Issuer-based resolution is strongest because the issuer is signed; domain-based is convenient but only safe after verification and against a verified-domain allow-list.
| Strategy | Source | Safe before signature check? | Best for | Risk if misused |
|---|---|---|---|---|
| Issuer / EntityID match | Signed iss or SAML Issuer |
No (verify first) | Enterprise SSO, dedicated connections | Low — bound to signature |
Custom claim (org_id) |
Signed token claim | No | IdPs that emit org identifiers | Low if claim is signed |
| Email-domain match | Verified email claim |
No | JIT onboarding, shared OIDC client | Medium — domain squatting |
| Subdomain / path hint | Request URL | Never trust alone | UI routing only | High — spoofable |
Dynamic Query Scoping & Connection Handling
Once the session carries tenant_id, every downstream query must be scoped by it without trusting application code to remember. The durable approach is to push the tenant identifier into the database session and let Postgres row-level security enforce it, so a forgotten WHERE tenant_id = clause cannot leak rows.
ALTER TABLE tenant_data ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tenant_data
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
Inject the setting from a connection wrapper that reads the resolved tenant out of the session, not out of any request header. With a transaction pooler such as PgBouncer, use SET LOCAL inside a transaction so the setting cannot bleed into the next checkout of a pooled connection.
export async function withTenantScope<T>(tenantId: string, fn: (c: Conn) => Promise<T>) {
return pool.tx(async c => {
await c.none(`SET LOCAL app.current_tenant_id = $1`, [tenantId]);
return fn(c);
});
}
Per-tenant JWKS and metadata caches sit on the hot path of every request that validates a token, so cache misses dominate latency. Partition the cache by tenant:issuer and refresh on a TTL shorter than the IdP's key-rotation window. A webhook from the IdP that invalidates the entry on rotation removes the failure window entirely.
Security Enforcement & Access Control
Federation moves the trust boundary to the moment of signature verification, so the controls cluster there. Reject unsigned or expired assertions outright. Validate nonce (OIDC) and state/InResponseTo (SAML) to defeat replay and CSRF. Constrain the audience to the exact tenant so a token issued for one customer cannot be replayed against another. Strip any inbound X-Tenant-ID header at the edge — that value is set by your code from the session, never accepted from a client.
| Access layer | Enforcement point | Mechanism | Failure mode it prevents |
|---|---|---|---|
| Edge / gateway | Inbound request | Strip client-supplied tenant headers | Header injection / tenant spoofing |
| Assertion verify | Callback handler | Signature + aud + iss + nonce checks |
Forged / replayed assertions |
| Role mapping | After normalization | Per-tenant group map, least-privilege default | Privilege escalation via group names |
| Session | Token mint | tenant_id + roles signed into JWT |
Cross-tenant session reuse |
| Data | DB connection | RLS bound to app.current_tenant_id |
Missing-filter row leakage |
Group claims are attacker-influenceable in some IdP setups, so treat group-to-role mapping as a privilege-escalation surface. Sanitize and bound group lists, cap their size to reject token-bloat attacks, and audit every mapping change. Pair this with auditing RBAC changes across tenants so a quietly added mapping is always visible.
Operational Overhead & Scaling Metrics
Federation overhead is dominated by cryptographic verification and the network cost of fetching keys. The table below gives the metrics worth alerting on and what to do when they cross threshold.
| Metric | Healthy threshold | Mitigation when breached |
|---|---|---|
| P99 assertion verify latency | < 80 ms | Move to per-tenant JWKS cache; pre-warm on connection enable |
| JWKS cache hit rate | > 95% | Lengthen TTL toward rotation window; add webhook invalidation |
| Outbound JWKS fetches / min | < 1 per tenant per TTL | Coalesce concurrent fetches behind a single in-flight promise |
| JIT provision rate | Steady, matches signups | Spike signals domain-allow-list gap or replay; rate-limit per tenant |
| Failed signature rate | Near zero | Investigate stale metadata or an expired IdP cert immediately |
| Metadata refresh failures | Zero | Alert and fall back to last-good cert; block silent expiry |
Lazy-load rarely-used tenant IdP configs so memory scales with active tenants, not total tenants. Automate SAML certificate rotation via metadata-sync webhooks; manual certificate updates are the most common cause of a tenant-wide login outage.
Pitfalls & Anti-Patterns
Resolving tenant from an unsigned value. Reading tenant_id from a query string, a hidden form field, or a subdomain before the signature is checked lets an attacker point a valid-but-foreign assertion at any tenant. Always derive tenant from a signed claim and verify before trusting.
One global group-to-role map. A shared table that maps Admins to tenant_admin for everyone means any customer who names a group Admins mints admins in your system. Mappings must be keyed by tenant_id.
Auto-provisioning from unverified domains. JIT provisioning on an email claim the IdP did not verify lets an attacker who controls a lookalike domain self-onboard. Gate JIT on a verified-domain allow-list per tenant.
Shared, unpartitioned JWKS cache. Caching keys under a global key lets one tenant's rotation or poisoning affect another's validation, and conflates metrics. Partition by tenant:issuer.
Skipping nonce/state validation. Without nonce (OIDC) or InResponseTo/state (SAML) checks, a captured assertion can be replayed. Bind every assertion to a short-lived, single-use value generated at login start.
Frequently Asked Questions
Should I use one OIDC client for all tenants or one per tenant?
One client per tenant connection gives the cleanest isolation: distinct client_id, distinct redirect URIs, and issuer-based tenant resolution with no domain guessing. A single multi-tenant client is acceptable when you resolve tenant from a signed org_id claim and keep a strict verified-domain fallback, but it concentrates blast radius.
How do I handle a user who belongs to two tenants with the same IdP?
Resolve tenant from the connection that initiated the login (the iss/EntityID or the org-specific authorize URL), not from the user identity. Mint a session scoped to that single tenant and require a fresh login or an explicit tenant switch to move between them — never carry both in one session.
What is the safest JWKS caching TTL? Set the TTL below the IdP's key-rotation interval — typically 10 to 60 minutes — and add webhook-driven invalidation so rotations propagate immediately rather than at TTL expiry. Coalesce concurrent misses behind a single fetch to avoid stampeding the IdP.
How do I prevent cross-tenant session reuse after SSO?
Sign tenant_id into the session and set the JWT aud to a per-tenant value such as tenant:<id>, then validate that audience on every request. A token minted for one tenant then fails audience validation everywhere else, even with a valid signature.