BetterIAM

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.com domain, 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/next with 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-HTTPS baseUrl, and its request cookie is Secure with SameSite=None. For local testing, run the app behind an HTTPS tunnel and set BETTER_IAM_BASE_URL to 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/saml

Create 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).

lib/iam.ts
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:

URLUse
https://app.example.com/api/iam/saml/{id}/metadataSigned SP metadata. It is also the SP entity ID (the audience).
https://app.example.com/api/iam/saml/{id}/acsThe assertion consumer service (ACS), HTTP-POST binding.
https://app.example.com/api/iam/saml/{id}/loginWhere 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=/invoices comes back to /invoices instead of afterSignIn. returnTo must be a path on your site; anything else is refused with 400. IdP-initiated sign-in (a tile in Okta) always lands on afterSignIn.
  • MFA. When the organization requires your product's own MFA (requireMfa in its policy, or requireMfaForOwners for an owner, which the B2B guide's tenantDefaults sets for every new organization), no session is issued yet: the browser goes to mfaUrl with the challenge in the URL fragment, #tenantId=…&mfaRequired=true&challenge=…&enrollmentRequired=…, plus returnTo when there was one. Fragments never reach a server or a log. The page reads location.hash and finishes with auth.verifyMfa (or auth.beginMfa and auth.confirmMfa when enrollmentRequired is true). 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 errorUrl with ?error=CODE appended, for example /login?error=ACCOUNT_LINK_REQUIRED or /login?error=SAML_INVALID. Without errorUrl the ACS answers 401 with { "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:

app/[org]/settings/sso/domain-actions.ts
'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 fieldValue
Single sign-on URLhttps://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 formatEmailAddress: 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 emailThe person's email, for example user.email
Attribute statement displayNameThe 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, or urn:oid:0.9.2342.19200300.100.1.3. The name comes from displayName.
  • The account is keyed by connection, IdP issuer, and NameID. A NameID that 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.

app/[org]/settings/sso/actions.ts
'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');
}
app/[org]/settings/sso/page.tsx
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:

OptionTypeDefaultDescription
tenantIdstringrequiredThe organization whose people sign in through the connection.
idstringgeneratedThe ID in the connection URLs: 2 to 63 lowercase letters, digits, and hyphens.
namestringrequiredA label for administrators, up to 200 characters.
metadataXmlstringnoneOkta's metadata; fills in entryPoint, idpIssuer, and idpCertificates.
trustedEmailDomainsstring[][]Exact domains whose emails this IdP may vouch for, so a first sign-in can create the account.
attributeMappingRecord<string, string>{}Identity attribute name to SAML attribute name, copied on every sign-in.
allowIdpInitiatedbooleanfalseAlso accept sign-ins started from the Okta dashboard tile.
trustScimProvisionedboolean | string[]falseLink 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.
enabledbooleantruefalse 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:

app/login/sso-action.ts
'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.

app/[org]/settings/sso/access-actions.ts
'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-attention

Until 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

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 trustScimProvisioned on the connection to that SCIM connection's ID (or true for any of Acme's SCIM connections). A first sign-in whose email Okta vouches for (a domain in trustedEmailDomains) 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 another NameID are 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 with identities.linkProvider. The connection summary's providerId and idpIssuer identify the connection. Linking needs iam:identities:update and 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:

app/[org]/settings/sso/link-button.tsx
'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.

app/[org]/settings/sso/require-actions.ts
'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

  1. In Okta, assign the app to a test user whose email is in acme.com and who has no account in Acme.
  2. On your login page, enter that email. The browser goes to Okta, then back to your app (the ACS's 303 to afterSignIn), signed in to Acme.
  3. Call iam.api.identities.list(credential, { tenantId, query: 'test-user@acme.com' }) as an administrator: the new identity has emailVerified: true. Its attributes.department holds the mapped value.
  4. Run npx better-iam reconcile and reload a project page: the person now holds the Member role.
  5. 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).
  6. Turn on Require SSO and try a password sign-in for Acme: it fails with METHOD_NOT_ALLOWED.

Troubleshooting

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page