Okta SSO Integration for Multi-Tenant Apps
Wiring Okta into a multi-tenant SaaS means one login flow must serve every tenant while guaranteeing that a token issued for one organization can never create a session in another. This page sits under SSO Mapping & Identity Federation and shows how to route, validate, and isolate Okta OIDC logins so cross-tenant leakage is structurally impossible.
Problem Framing
The temptation with Okta is to stand up one application per tenant and hardcode its issuer URL. That collapses the moment you have a few hundred tenants: you are managing hundreds of OAuth clients, hundreds of redirect URIs, and a deploy every time a customer signs up. The correct shape is a single Okta application (or a small number of authorization servers) that emits a tenant_id claim, plus server-side logic that resolves the tenant from the request and verifies that the returned token actually belongs to that tenant.
The failure that matters is silent. If you trust a tenant_id from a request header, or skip the equality check between the token's tenant and the tenant the user is trying to enter, a valid token from tenant A will mint a session in tenant B. That is not a crash — it is unauthorized data access that looks like a normal login. Every decision below exists to turn that silent failure into a hard, logged rejection. The same principle governs how you turn directory groups into authorization, covered in mapping external IdP groups to tenant roles.
Step-by-Step Guide
1. Resolve the tenant from the request
Extract the tenant from the Host header or a path prefix, then look it up in your tenant registry before doing anything else. Never default an unknown tenant to a global Okta app — return 404 so an unprovisioned tenant cannot accidentally authenticate against another tenant's configuration.
import type { Request } from "express";
interface TenantConfig {
tenantId: string;
oktaIssuer: string; // e.g. https://acme.okta.com/oauth2/default
oktaClientId: string;
appDomain: string; // e.g. https://app.example.com
}
export async function resolveTenant(req: Request): Promise<TenantConfig> {
const host = (req.headers.host ?? "").split(":")[0];
const tenantId = host.split(".")[0]; // acme.app.example.com -> acme
const cfg = await registry.lookup(tenantId);
if (!cfg) {
throw new HttpError(404, `TENANT_NOT_FOUND:${tenantId}`);
}
return cfg;
}
2. Build the authorization URL and bind state to the tenant
Generate a cryptographic state and nonce, and store the tenant alongside the state so the callback can prove which tenant the flow started for. Fail fast if any config field is missing — a missing issuer must never silently fall back.
import { randomBytes } from "node:crypto";
export async function buildAuthUrl(cfg: TenantConfig): Promise<string> {
if (!cfg.oktaClientId || !cfg.oktaIssuer || !cfg.appDomain) {
throw new Error("MISSING_TENANT_CONFIG");
}
const state = randomBytes(32).toString("hex");
const nonce = randomBytes(32).toString("hex");
await stateStore.set(`state:${state}`, { tenantId: cfg.tenantId, nonce }, 300);
const params = new URLSearchParams({
client_id: cfg.oktaClientId,
redirect_uri: `${cfg.appDomain}/auth/callback`,
response_type: "code",
scope: "openid profile email",
state,
nonce,
});
return `${cfg.oktaIssuer}/v1/authorize?${params.toString()}`;
}
3. Emit the tenant_id claim in Okta
In the Okta Admin Console, add a custom profile attribute under Directory > Profile Editor, then publish it as a token claim under Security > API > Authorization Servers > Claims. Map it with Okta Expression Language so the value flows into every ID token.
Claim name: tenant_id
Include in: ID Token (Always)
Value type: Expression
Value: user.tenant_id
Claim name: roles
Include in: ID Token (Always)
Value type: Groups
Filter: Regex .*
4. Validate the callback and the token, then enforce tenant equality
On callback, confirm the state exists and read back its bound tenant. After exchanging the code, verify the signature against the issuer's JWKS, check iss/aud/exp, and reject unless the token's tenant_id equals the tenant bound to the flow. This single equality check is what stops cross-tenant leakage.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
function jwksFor(issuer: string) {
let set = jwksCache.get(issuer);
if (!set) {
set = createRemoteJWKSet(new URL(`${issuer}/v1/keys`));
jwksCache.set(issuer, set);
}
return set;
}
export async function verifyToken(idToken: string, cfg: TenantConfig, expectedTenant: string) {
const { payload } = await jwtVerify(idToken, jwksFor(cfg.oktaIssuer), {
issuer: cfg.oktaIssuer,
audience: cfg.oktaClientId,
clockTolerance: 30, // seconds
});
if (payload.tenant_id !== expectedTenant) {
throw new HttpError(403, "CROSS_TENANT_TOKEN");
}
return payload;
}
5. Create a tenant-namespaced session
Prefix every session key with the tenant ID so storage isolation is structural, not conventional. Regenerate the session identifier after authentication and set Secure + SameSite=Strict on the cookie.
export async function createSession(res: Response, tenantId: string, sub: string) {
const sid = randomBytes(32).toString("hex");
await redis.set(`sess:${tenantId}:${sid}`, JSON.stringify({ sub, tenantId }), "EX", 3600);
res.cookie("sid", `${tenantId}:${sid}`, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 3_600_000,
});
}
6. Scope lifecycle (SCIM) events to the resolved tenant
If you sync users from Okta via SCIM, derive the tenant from the provisioning context and never from an unauthenticated body field. Upsert into a tenant-scoped table so a misrouted event cannot write into the wrong organization.
app.post("/webhooks/okta/scim/:tenantId", verifyScimSignature, async (req, res) => {
const { tenantId } = req.params;
const { id, email } = req.body;
if (!id || !email) return res.status(400).json({ error: "missing_fields" });
await db.users.upsert({ tenantId, externalId: id, email });
res.status(200).json({ status: "ok" });
});
Verification
Assert the two behaviors that prove isolation: a matching tenant produces a namespaced session, and a token for another tenant is rejected with 403. Run this against a staging Okta org.
import { describe, it, expect } from "vitest";
describe("okta multi-tenant SSO", () => {
it("accepts a token whose tenant_id matches the flow", async () => {
const payload = await verifyToken(acmeIdToken, acmeConfig, "acme");
expect(payload.tenant_id).toBe("acme");
});
it("rejects a token issued for a different tenant", async () => {
await expect(verifyToken(acmeIdToken, globexConfig, "globex"))
.rejects.toThrow("CROSS_TENANT_TOKEN");
});
});
Confirm the session key shape in Redis after a successful login — every key must carry its tenant prefix:
redis-cli --scan --pattern 'sess:*' | head
# sess:acme:9f3c... <- tenant-namespaced, never a bare sess:<sid>
In the Okta System Log, trace claim evaluation by filtering eventType eq "user.authentication.sso" and inspecting outcome.result for mapping failures before they reach production.
Failure Modes & Gotchas
- Hardcoded issuer URL. Symptom: callback works for one tenant, fails for all others. Cause: a single static issuer cannot serve multiple authorization servers. Fix: resolve
oktaIssuerfrom the tenant registry per request. - Trusting
tenant_idfrom a header. Symptom: a user reaches another tenant's data after a normal login. Cause: the header is attacker-controlled. Fix: readtenant_idonly from the verified token and compare it to the state-bound tenant. - Skipping
audiencevalidation. Symptom: tokens from an unrelated client are accepted. Cause:audnot checked. Fix: passaudience: cfg.oktaClientIdtojwtVerifyand reject on mismatch. - Stale JWKS after key rotation. Symptom: valid logins fail with signature errors right after Okta rotates keys. Cause: keys cached indefinitely. Fix: use
createRemoteJWKSet, which refetches on unknownkid, instead of a hand-rolled cache with a long TTL.
FAQ
How do I handle Okta for thousands of tenants without an app per tenant?
Use one Okta application that emits a tenant_id claim and resolve the tenant server-side. Reserve a dedicated authorization server per tenant only for compliance cases that demand a fully isolated issuer.
Can I use SAML instead of OIDC?
Yes, but OIDC is simpler for SaaS because claims are JSON and JWKS verification is standard. With SAML you must parse the NameID and AttributeStatement, then apply the same tenant-equality check before creating a session.
Where should the tenant equality check live?
In shared verification middleware that runs before any handler touches tenant data, so no route can accidentally bypass it. The check compares the token's tenant_id against the tenant bound to the OAuth state.