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

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.