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.

Federation moves the authority for identity outside your system, which changes what you can assert during an incident. You no longer know that a person exists β€” you know that a provider asserted they did, at a moment, with a signature you trusted. Recording the assertion's issuer, its identifier and the exact time alongside every session makes that distinction auditable later, and it is the difference between "this user logged in" and "this provider told us this subject authenticated, and here is the proof".

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.

Connection count is what makes federation an operational discipline rather than a feature. Each enterprise customer brings its own provider, its own certificate with its own expiry, and its own idea of which attributes to send. At fifty connections, certificate rotation stops being an occasional task and becomes a schedule, and a connection whose certificate expires overnight locks an entire customer out of the product before anyone is awake.

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.

What should happen when an assertion arrives for a tenant that has no connection configured? Reject it, and log the issuer. An assertion is only meaningful relative to a connection you deliberately established, so one arriving without a matching connection is either a misconfiguration on the customer's side β€” the most common case, usually a copied endpoint β€” or an attempt to authenticate against a tenant that never enabled federation. Neither should produce a session. Logging the issuer and the target tenant makes the misconfiguration case diagnosable in one query rather than through a support conversation, and it makes the second case visible at all.

How should federated and local accounts for the same person be reconciled? Match on the immutable subject identifier the provider supplies, never on the email address, and keep the existing account rather than creating a second one. A person who signed up with a password before their organisation enabled federation should find their history intact after their first federated login, which means the first successful assertion links the provider subject to the existing account rather than provisioning a new one. Where no local account matches, provisioning a fresh one is correct β€” but the linking case has to come first, because the alternative silently duplicates every user during a federation rollout.

Is it worth supporting more than one connection per tenant? Frequently, yes. Large customers acquire other companies, run separate directories for contractors and staff, or migrate between providers over months rather than in a weekend. Supporting several connections per tenant, each with its own group mapping and its own certificate, costs very little once connections are modelled as rows rather than as configuration β€” and it removes the situation where a customer's migration forces a choice between breaking access for one population or delaying the whole project.

How should a connection be tested before it is enabled for real users? With a dedicated test path that performs a full assertion round trip and shows the administrator exactly what arrived β€” the subject identifier, every attribute, and every group β€” without creating a session or provisioning anyone. That single screen resolves most onboarding problems immediately, because the two frequent failures are a missing attribute and a group value in a different form from what the mapping expects, and both are obvious the moment the raw assertion is visible. Enabling the connection only after a successful test also gives a clean, auditable point at which federation became active for that tenant.