SAML single sign-on with Okta
Connect one organization's Okta to its Better IAM tenant with a tenant-managed SAML connection, verified domain, just-in-time accounts, default access, and enforced SSO.
This guide connects one customer organization, Acme, to its Okta tenant with , so Acme's people sign in to your product with their Okta account and Okta's password and MFA rules. The connection is tenant-managed: Acme's administrator creates it at runtime by uploading Okta's metadata, with no deployment change per customer. You will build:
- a SAML service provider mounted in a Next.js app, with one key pair shared by every organization's connection;
- a verified
acme.comdomain, so sign-in can route Acme's people by email address; - an SSO settings page where Acme's administrator registers the app in Okta and uploads its metadata;
- just-in-time (JIT) accounts on first sign-in, with default access granted by a birthright rule;
- a one-time link for members who already have a password account;
- an enforced policy that allows only federated sign-in for Acme.
The Okta steps below name the fields of Okta's SAML app integration settings; Okta's console labels can change between versions, so match them by meaning. Other SAML identity providers (Entra ID, Google Workspace, ADFS) ask for the same values; see SAML.
Prerequisites
- The app from Build a multi-tenant B2B SaaS, or any Next.js app using
@better-iam/nextwith an organization (acme) and its owner. - A public HTTPS origin for the app, such as
https://app.example.com. The SAML service provider refuses a non-HTTPSbaseUrl, and its request cookie isSecurewithSameSite=None. For local testing, run the app behind an HTTPS tunnel and setBETTER_IAM_BASE_URLto the tunnel's origin. - Admin access to an Okta organization (a free developer org works), and DNS access for the domain you verify.
openssl, to create the service-provider key pair.
Create the service-provider key pair
Better IAM signs its SAML requests and metadata with one key pair, shared by every tenant-managed connection. Create it once per deployment and store both files like any other secret:
openssl req -x509 -newkey rsa:2048 -nodes -days 730 \
-keyout saml-sp-key.pem -out saml-sp-cert.pem -subj "/CN=app.example.com"The certificate is published in your SP metadata. Okta does not need it for this setup, because Better IAM does not
ask for encrypted assertions unless a connection sets requireEncryptedAssertions.
Mount the SAML service
npm i @better-iam/samlCreate the service with the host callbacks from iam.protocolHost and mount it. basePath puts the SAML routes
under the IAM route that Next.js already serves, app/api/iam/[...path]. afterSignIn, mfaUrl, and errorUrl
are the pages the browser lands on after the assertion (next step).
import { readFileSync } from 'node:fs';
import { betterIam } from '@better-iam/server';
import { createSamlService } from '@better-iam/saml';
import config from '../better-iam.config.mjs';
export const iam = betterIam(config);
export const saml = createSamlService({
...iam.protocolHost,
basePath: '/api/iam/saml',
serviceProvider: {
baseUrl: process.env.BETTER_IAM_BASE_URL!, // https://app.example.com
privateKey: readFileSync(process.env.SAML_SP_KEY_FILE!, 'utf8'),
publicCertificate: readFileSync(process.env.SAML_SP_CERT_FILE!, 'utf8'),
},
afterSignIn: '/',
mfaUrl: '/login/mfa',
errorUrl: '/login',
});
iam.useProtocol(saml);Add the package to serverExternalPackages in next.config.mjs next to @better-iam/server, so it loads as a
plain Node module.
Each connection now has three fixed URLs under the base URL:
| URL | Use |
|---|---|
https://app.example.com/api/iam/saml/{id}/metadata | Signed SP metadata. It is also the SP entity ID (the audience). |
https://app.example.com/api/iam/saml/{id}/acs | The assertion consumer service (ACS), HTTP-POST binding. |
https://app.example.com/api/iam/saml/{id}/login | Where SP-initiated sign-in starts. |
Send people into the app after the assertion
When the browser posts Okta's response to the ACS, the IAM handler validates it and, because afterSignIn is set,
answers 303 to that page with the session cookie (__Host-better-iam.session over HTTPS) already set. The IAM
route needs nothing beyond the usual export const { GET, POST, OPTIONS } = iamNext.handlers();.
- Where people return. Sign-in started at
/api/iam/saml/{id}/login?returnTo=/invoicescomes back to/invoicesinstead ofafterSignIn.returnTomust be a path on your site; anything else is refused with400. IdP-initiated sign-in (a tile in Okta) always lands onafterSignIn. - MFA. When the organization requires your product's own MFA (
requireMfain its policy, orrequireMfaForOwnersfor an owner, which the B2B guide'stenantDefaultssets for every new organization), no session is issued yet: the browser goes tomfaUrlwith the challenge in the URL fragment,#tenantId=…&mfaRequired=true&challenge=…&enrollmentRequired=…, plusreturnTowhen there was one. Fragments never reach a server or a log. The page readslocation.hashand finishes withauth.verifyMfa(orauth.beginMfaandauth.confirmMfawhenenrollmentRequiredistrue). Okta already enforces MFA for Okta sign-ins, so the usual choice for an SSO-only organization is to leave both settings off. - Failures. A refused assertion goes to
errorUrlwith?error=CODEappended, for example/login?error=ACCOUNT_LINK_REQUIREDor/login?error=SAML_INVALID. WithouterrorUrlthe ACS answers401with{ "error": "SAML_INVALID" }.
afterSignIn, mfaUrl, and errorUrl are paths, or absolute URLs on one of the deployment's trustedOrigins. The
redirect happens where the service is mounted with iam.useProtocol and served by the IAM handler; calling
saml.handler yourself returns the JSON sign-in result instead.
Verify the email domain
A verified domain proves that @acme.com addresses belong to Acme. It lets your sign-in page find Acme from an
email address alone, and stops any other organization from claiming acme.com. Acme's administrator claims it,
and whoever runs Acme's DNS publishes the record:
'use server';
import { iam } from '@/lib/iam';
import { iamNext } from '@/lib/iam-next';
export async function claimDomain(domain: string) {
const session = await iamNext.requireSession();
const claimed = await iam.api.domains.add(await iamNext.credential(), {
tenantId: session.session.tenantId,
domain,
});
return { domainId: claimed.id, record: claimed.dnsRecord }; // { type: 'TXT', name, value }
}
export async function verifyDomain(domainId: string) {
const session = await iamNext.requireSession();
const result = await iam.api.domains.verify(await iamNext.credential(), {
tenantId: session.session.tenantId,
domainId,
});
return result.verified;
}The record is a TXT record named _better-iam-challenge.acme.com with the value
better-iam-verification=<token>. domains.verify looks it up and returns verified: true once DNS has it.
A domain verified by one organization cannot be claimed by another (DOMAIN_TAKEN), and shared mailbox providers
such as gmail.com are refused (DOMAIN_NOT_ALLOWED).
Route: POST /api/iam/domains/add, then POST /api/iam/domains/verify
Register the app in Okta
Pick the connection ID first, because it is part of every URL Okta needs. Connection IDs are 2 to 63 lowercase
letters, digits, and hyphens; this guide uses acme-okta. Give Acme's Okta administrator these values, then have
them create a SAML 2.0 app integration:
| Okta field | Value |
|---|---|
| Single sign-on URL | https://app.example.com/api/iam/saml/acme-okta/acs (also used as Recipient and Destination) |
| Audience URI (SP Entity ID) | https://app.example.com/api/iam/saml/acme-okta/metadata |
| Name ID format | EmailAddress: Better IAM's sign-in requests ask for urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress |
| Response and Assertion Signature (advanced settings) | Both signed |
Attribute statement email | The person's email, for example user.email |
Attribute statement displayName | The person's display name |
Attribute statement department (optional) | Any attribute you map in the next step |
Then they assign the app to the people or groups who should sign in, and download the app's IdP metadata XML from its Sign On tab.
- Better IAM requires both the SAML response and the assertion to be signed.
- The email is read from an attribute named
email,mail, orurn:oid:0.9.2342.19200300.100.1.3. The name comes fromdisplayName. - The account is keyed by connection, IdP issuer, and
NameID. ANameIDthat changes, such as an email address that is later renamed, makes the next sign-in look like a new person, so keep each person's Okta username stable.
Create the connection
Acme's administrator uploads the metadata file on your SSO settings page. saml.createConnection reads Okta's
sign-on URL, entity ID, and signing certificates from it, and returns the URLs to register. It requires
iam:saml:connections:create on saml/{id} in the organization, and a recent sign-in because it adds trust.
'use server';
import { revalidatePath } from 'next/cache';
import { saml } from '@/lib/iam';
import { iamNext } from '@/lib/iam-next';
export async function connectOkta(form: FormData) {
const session = await iamNext.requireSession({ stepUp: { maxAgeMs: 5 * 60_000 } });
const metadata = form.get('metadata');
if (!(metadata instanceof File)) throw new Error('Upload the IdP metadata file');
await saml.createConnection(await iamNext.credential(), {
tenantId: session.session.tenantId,
id: 'acme-okta',
name: 'Acme Okta',
metadataXml: await metadata.text(),
trustedEmailDomains: ['acme.com'],
attributeMapping: { department: 'department' },
});
revalidatePath('/[org]/settings/sso', 'page');
}import { saml } from '@/lib/iam';
import { iamNext } from '@/lib/iam-next';
import { connectOkta } from './actions';
export default iamNext.page(
async (_props: { params: Promise<{ org: string }> }, { session }) => {
const connections = await saml.listConnections(await iamNext.credential(), {
tenantId: session.session.tenantId,
});
return (
<main>
<h1>Single sign-on</h1>
{connections.map((connection) => (
<dl key={connection.id}>
<dt>Single sign-on URL</dt>
<dd>{connection.acsUrl}</dd>
<dt>Audience URI</dt>
<dd>{connection.entityId}</dd>
<dt>Signing certificates</dt>
<dd>
{connection.certificates
.map((certificate) => `${certificate.subject}${certificate.expired ? ' (expired)' : ''}`)
.join(', ')}
</dd>
</dl>
))}
<form action={connectOkta}>
<input type="file" name="metadata" accept=".xml" required />
<button type="submit">Connect Okta</button>
</form>
</main>
);
},
{ authorize: { action: 'iam:saml:connections:read', resource: () => ({ type: 'saml', id: '*' }) } },
);Connection options used here:
| Option | Type | Default | Description |
|---|---|---|---|
tenantId | string | required | The organization whose people sign in through the connection. |
id | string | generated | The ID in the connection URLs: 2 to 63 lowercase letters, digits, and hyphens. |
name | string | required | A label for administrators, up to 200 characters. |
metadataXml | string | none | Okta's metadata; fills in entryPoint, idpIssuer, and idpCertificates. |
trustedEmailDomains | string[] | [] | Exact domains whose emails this IdP may vouch for, so a first sign-in can create the account. |
attributeMapping | Record<string, string> | {} | Identity attribute name to SAML attribute name, copied on every sign-in. |
allowIdpInitiated | boolean | false | Also accept sign-ins started from the Okta dashboard tile. |
trustScimProvisioned | boolean | string[] | false | Link a first sign-in to the account a SCIM connection provisioned for the same verified email: true for any of the organization's SCIM connections, or a list of SCIM connection IDs. See Link existing accounts. |
enabled | boolean | true | false stops new sign-ins through the connection at once. |
trustedEmailDomains is not checked against verified domains. List only domains the organization verified in the
previous step, because an email in a trusted domain counts as verified and can create an account.
attributeMapping writes declared identity attributes, so declare them in the options
(permissions: { identityAttributes: { department: 'string' } }); an undeclared or invalid value fails the sign-in.
The full option list, certificate rollover, and encrypted assertions are in Tenant-managed connections.
Start sign-in from your login page
domains.discover maps an email address to its organization. It does not return SAML connections, so keep the
connection ID for each organization in your own data (or derive it, as here) and send the browser to the
connection's login URL:
'use server';
import { redirect } from 'next/navigation';
import { iam } from '@/lib/iam';
// Organizations with SSO, by tenant ID. Store this with your organization records.
const ssoConnections: Record<string, string> = {
/* [acmeTenantId]: 'acme-okta' */
};
export async function continueWithSso(form: FormData) {
const email = String(form.get('email'));
const found = await iam.api.domains.discover({ email }).catch(() => null);
const connectionId = found ? ssoConnections[found.tenantId] : undefined;
if (!found || !connectionId) redirect('/login'); // no SSO for this domain: use the regular sign-in form
redirect(`/api/iam/saml/${connectionId}/login`);
}domains.discover is public and also returns allowedMethods and requireMfa, so the page can hide the password
field for an organization that allows only federated. GET /api/iam/saml/{id}/login saves the request ID and a
RelayState, bound to a cookie, and redirects to Okta. Both are single use and expire after 10 minutes.
Route: POST /api/iam/domains/discover
Give new accounts their default access
On the first sign-in of someone without an account, Better IAM creates the identity with a verified email when the
email's domain is in trustedEmailDomains. That is just-in-time provisioning. The new identity has no role
bindings: it can sign in, but every authorization check is denied until it is given a role.
To give every Acme account a default role automatically, use a birthright rule: an
with autoAssign.
'use server';
import { iamNext } from '@/lib/iam-next';
// Run once by the owner: everyone with a verified acme.com address gets the Member role.
export async function createDefaultAccess(memberRoleId: string) {
const session = await iamNext.requireSession();
return iamNext.client().packages.create({
tenantId: session.session.tenantId,
name: 'Everyone at Acme',
roleIds: [memberRoleId],
autoAssign: {
include: [
{
StringEquals: { 'principal.kind': 'user' },
StringEqualsIgnoreCase: { 'identity.emailDomain': 'acme.com' },
Bool: { 'identity.emailVerified': true },
},
],
},
});
}packages.create requires iam:packages:create. The caller becomes the rule's owner, so they also need what
assigning the package by hand needs: iam:packages:assign, iam:bindings:create on the role, and a grant
authority. The organization's owner has all of them.
Rules re-evaluate right after identities.create, update, and setStatus, but federated sign-ins reach them only
through the scheduled reconciliation job. Run it every 15 minutes, after purge:
npx better-iam reconcile --config better-iam.config.mjs --fail-on-attentionUntil the job runs, a person who just signed in for the first time has no access. An administrator can apply the
rule at once with packages.reconcile. See Automatic assignment.
Route: POST /api/iam/packages/create
Link existing accounts
Better IAM does not join a SAML login to an existing account by email on its own. A person who already has an
account in Acme is refused on their first SAML sign-in with ACCOUNT_LINK_REQUIRED (the ACS sends them to
/login?error=ACCOUNT_LINK_REQUIRED), unless one of these links the account first:
-
People Okta provisions through SCIM. When the same Okta org also provisions Acme through SCIM, set
trustScimProvisionedon the connection to that SCIM connection's ID (ortruefor any of Acme's SCIM connections). A first sign-in whose email Okta vouches for (a domain intrustedEmailDomains) then links to the account SCIM created for that email, marks the email verified, and signs in. Owners, root administrators, accounts SCIM did not create, and accounts already linked through this connection under anotherNameIDare never linked this way. Turning it on is a trust change, so it needs a recent sign-in.app/[org]/settings/sso/actions.ts await saml.updateConnection(await iamNext.credential(), { tenantId: session.session.tenantId, connectionId: 'acme-okta', trustScimProvisioned: [scimConnectionId], // from scim.createConnection }); -
Accounts an administrator links. When you know a person's Okta
NameID, link it ahead of their first sign-in withidentities.linkProvider. The connection summary'sproviderIdandidpIssueridentify the connection. Linking needsiam:identities:updateand a recent sign-in; an owner's account needs another owner or a root administrator.app/[org]/settings/sso/link-actions.ts const connection = await saml.getConnection(credential, { tenantId, connectionId: 'acme-okta' }); await iam.api.identities.linkProvider(credential, { tenantId, identityId, providerId: connection.providerId, issuer: connection.idpIssuer, subject: 'ada@acme.com', // the NameID Okta sends for this person }); -
The person links it themselves, while signed in to that account by another method, within five minutes of signing in:
'use client';
export function LinkOktaButton({ connectionId }: { connectionId: string }) {
async function link() {
const response = await fetch(`/api/iam/saml/${connectionId}/login`, {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', 'x-better-iam': '1' },
body: '{}',
});
if (!response.ok) throw new Error((await response.json()).error);
const { url } = (await response.json()) as { url: string };
window.location.assign(url); // Okta, then back to the ACS, which links the account
}
return <button onClick={link}>Link my Okta account</button>;
}The POST needs a trusted Origin (by default, the origin of the connection's URLs), X-Better-IAM: 1, a JSON
body, and a user session in the connection's tenant authenticated within five minutes. Root administrators cannot
link. After linking, the person signs in with Okta and keeps their roles. See
First sign-in and account linking.
Require SSO for the organization
Once Okta sign-in works for the administrators, require it. Password and email-link sign-in are then refused for everyone in Acme before any credential is checked, so someone Okta removed cannot keep using an old password.
'use server';
import { iamNext } from '@/lib/iam-next';
export async function requireSso() {
const session = await iamNext.requireSession({ stepUp: { maxAgeMs: 5 * 60_000 } });
const tenantId = session.session.tenantId;
const current = (await iamNext.client().tenants.get({ tenantId })).authPolicy ?? {};
await iamNext.client().tenants.setAuthPolicy({
tenantId,
authPolicy: { ...current, allowedMethods: ['federated'] },
});
}After this step, an account that is not linked and cannot be linked on its first sign-in has no way to sign in, and
other methods fail with METHOD_NOT_ALLOWED. With trustScimProvisioned, SCIM-provisioned people link on their
first Okta sign-in, so you can require SSO right away; make sure everyone else (the owner, invited members) has
linked or was linked by an administrator first.
Route: POST /api/iam/tenants/setAuthPolicy
Verify it works
- In Okta, assign the app to a test user whose email is in
acme.comand who has no account in Acme. - On your login page, enter that email. The browser goes to Okta, then back to your app (the ACS's
303toafterSignIn), signed in to Acme. - Call
iam.api.identities.list(credential, { tenantId, query: 'test-user@acme.com' })as an administrator: the new identity hasemailVerified: true. Itsattributes.departmentholds the mapped value. - Run
npx better-iam reconcileand reload a project page: the person now holds the Member role. - Sign in with the owner's password, click Link my Okta account, and sign in at Okta as the owner's Okta user.
From then on the owner signs in through Okta. While the policy still has
requireMfaForOwners, the owner's Okta sign-in answers with an MFA challenge instead of a session (see Troubleshooting). - Turn on Require SSO and try a password sign-in for Acme: it fails with
METHOD_NOT_ALLOWED.
Troubleshooting
Next steps
Let Okta create, update, and deactivate Acme's accounts before anyone signs in.
SAML referenceEvery connection option, response validation rule, and route.
Enterprise onboardingSSO, SCIM, outbound provisioning, and offboarding for one customer.
Tenant authentication policyAllowed methods, MFA, IP ranges, and session limits.
Automatic assignmentBirthright rules, reconciliation, and the safety brake.
OAuth and OIDC sign-inConnect an identity provider over OpenID Connect instead.
Better IAM is created by Sean Filimon
Last updated
Build a multi-tenant B2B SaaS
Build a Next.js B2B app where each customer is an organization with its own owner, invited members, product roles, sign-in policy, organization switcher, and authorized routes.
SCIM provisioning with Entra ID and Okta
Let one organization's Microsoft Entra ID or Okta create, update, deactivate, and delete its people and groups in Better IAM through a SCIM 2.0 connection, with attribute and group-to-role mapping.