BetterIAM

SAML

Accept SAML 2.0 single sign-on from Okta, Entra ID, ADFS, and Google Workspace, with tenant-managed connections, metadata import, and IdP-initiated login.

(Security Assertion Markup Language) is the long-established standard for enterprise single sign-on (SSO): one company account that opens every work application. The company's identity provider (IdP: Okta, Microsoft Entra ID, ADFS, Google Workspace) signs an XML document, the assertion, that says who the person is. The browser carries it to your application, the service provider (SP), which checks the signature and signs the person in.

The problem it solves. Many enterprise customers require SSO before they buy, and their IT teams often prefer SAML because every IdP supports it and their existing apps use it. With SSO, people use their company account under the company's password and MFA rules, and IT removes access in one place. (If the customer's IdP supports , OAuth and OIDC sign-in works too.)

createSamlService makes Better IAM a SAML service provider. Each connection has a fixed , IdP issuer, SP audience, and callback URL. Connections come from two places, and both work side by side:

  • Tenant-managed connections live in the database. An organization's administrator creates one at runtime by uploading their IdP's metadata file, with no deployment change. This is what most products want.
  • Configured connections are fixed in your deployment configuration.

Who configures it. Your team sets up the service-provider key pair once and builds an SSO settings screen that calls the connection methods below. The customer's IT administrator then does the rest: they register your app in their IdP and upload the IdP's metadata file on that screen. No deployment is needed per customer.

A few SAML terms you will meet on this page:

TermMeaning
Entity IDA unique name for each side. Your SP entity ID is also the audience the assertion must be addressed to.
ACS URLThe assertion consumer service: the URL on your side where the browser posts the IdP's response.
MetadataAn XML file describing one side: its entity ID, URLs, and signing certificates. Exchanging metadata sets up the trust.
NameIDThe IdP's identifier for the person inside the assertion.
RelayState, InResponseToValues that tie a response to the sign-in request your side started.

Validation is built on Node-SAML.

SP-initiated sign-in

"SP-initiated" means the person starts at your application, which sends them to the IdP and waits for the answer:

The request ID and RelayState are single use and expire after 10 minutes. When the service is mounted through IAM, a successful sign-in sets the standard IAM cookie and, with afterSignIn, redirects to your app (Redirect after sign-in).

Tenant-managed connections

Tenant-managed connections let each organization connect its own IdP at runtime, which is how a multi-tenant product offers SSO to many customers. Give the service one service-provider identity (a key pair and certificate) shared by every managed connection, plus the host's authorization checks:

iam.ts
import { createSamlService } from 'better-iam/saml';

export const saml = createSamlService({
  ...iam.protocolHost,
  serviceProvider: {
    baseUrl: 'https://identity.example',
    privateKey: secrets.samlSpKey,
    publicCertificate: secrets.samlSpCertificate,
  },
});
iam.useProtocol(saml);

Then an organization administrator connects their IdP:

const connection = await saml.createConnection(credential, {
  tenantId: organization.id,
  id: 'acme-okta',
  name: 'Acme Okta',
  metadataXml: uploadedIdpMetadata,
  trustedEmailDomains: ['acme.com'],
  attributeMapping: { department: 'department' },
});
// connection.entityId, connection.acsUrl → register them at the IdP

createConnection reads the IdP's sign-on URL, entity ID, and signing certificates from the metadata, stores the connection, and returns the values the IdP needs in return. Each managed connection gets fixed URLs under the base URL:

URLUse
{baseUrl}/saml/{id}/metadataSigned SP metadata, which some IdPs can import directly. Also the SP entity ID (the audience): connection.entityId.
{baseUrl}/saml/{id}/acsThe HTTP-POST assertion consumer service: connection.acsUrl.
{baseUrl}/saml/{id}/loginWhere sign-in starts: connection.loginUrl. Link to it from your sign-in page.

/saml is the default basePath. Connection IDs are 2 to 63 lowercase letters, digits, and hyphens, because they appear in these URLs; without id, one like saml-3f9a1c0b2d4e is generated.

Prop

Type

IdP details come from metadataXml or from explicit entryPoint, idpIssuer, and idpCertificates. Explicit values override imported ones.

Manage connections

These methods back an SSO settings screen. Each one checks the caller's permission on the connection in its tenant, so only administrators who hold the iam:saml:connections:* permissions there can change it:

MethodPermissionWhat it does
createConnection(credential, input)iam:saml:connections:create on saml/{id}Adds an IdP. Audited as iam:saml:CreateConnection.
listConnections(credential, { tenantId })iam:saml:connections:read on saml/*Lists the tenant's connections, for an SSO settings page.
getConnection(credential, { tenantId, connectionId })iam:saml:connections:readReads one connection.
updateConnection(credential, { tenantId, connectionId, ...changes })iam:saml:connections:updateChanges settings, uploads refreshed metadata, or rolls certificates. Audited as iam:saml:UpdateConnection.
deleteConnection(credential, { tenantId, connectionId })iam:saml:connections:deleteRemoves the connection, its pending sign-ins, and the account links made through it. Audited as iam:saml:DeleteConnection.

Permissions apply in the connection's tenant. Creating a connection, and any update that widens what it trusts (another IdP issuer, an added certificate, IdP-initiated sign-in, more trusted email domains), also passes the host's assertTrustChange check from iam.protocolHost: a recent sign-in, and, when owners or root administrators sign in through the connection, a caller who could control those accounts anyway. Connection summaries include the values to enter at the IdP (entityId, acsUrl, metadataUrl, loginUrl) and, for each certificate, its SHA-256 fingerprint, subject, validity, and expired, so certificate rollover can be monitored.

Deleting a connection deletes the external identity links made through it, so the accounts stay but can no longer sign in through that IdP; a connection created later, even with the same ID, never inherits them. Configured connections keep working alongside managed ones, and their IDs are reserved. To offer the signed SP metadata XML as a download in your admin UI, call getMetadata(id), which covers both kinds of connection; the synchronous metadata(id) covers configured ones only.

Register the app at the IdP

The IdP administrator needs two values from the connection: the entity ID (audience) and the ACS URL. Better IAM requires both the SAML response and the assertion to be signed, and reads the email address from an attribute named email or mail (or the urn:oid:0.9.2342.19200300.100.1.3 OID).

  1. Create a SAML 2.0 app integration.
  2. Set Single sign-on URL to connection.acsUrl and Audience URI (SP Entity ID) to connection.entityId.
  3. Keep both the response and the assertion signed.
  4. Add attribute statements, for example email from user.email and department from user.department.
  5. Download the IdP metadata from the app's Sign On tab and pass it as metadataXml.

Then share connection.loginUrl, or route people to it from home-realm discovery (see Enterprise onboarding).

Import IdP metadata

createConnection and updateConnection parse metadataXml with parseIdpMetadata, which is also exported, for example to preview an upload before saving it:

import { parseIdpMetadata } from 'better-iam/saml';

const idp = parseIdpMetadata(xml);
// idp.entityId, idp.entryPoint (HTTP-Redirect sign-on URL), idp.certificates (PEM), idp.singleLogoutUrl?

It reads the entity ID, the HTTP-Redirect sign-on URL, and every signing certificate. It refuses DTDs, entities, documents over 512 KiB, and documents that do not describe exactly one IdP.

Metadata signatures are not checked

Import metadata only from administrators or from the IdP's own HTTPS URL. Whoever controls the metadata controls which certificates Better IAM trusts for the connection.

Certificate rollover

IdP signing certificates expire, typically every one to three years, and the IdP then switches to a new one. If Better IAM does not know the new certificate yet, sign-in breaks. To avoid that, list the old and new certificates together through updateConnection until the IdP switches, then remove the old one:

await saml.updateConnection(credential, {
  tenantId,
  connectionId: 'acme-okta',
  idpCertificates: [currentCertificate, nextCertificate],
});

Refreshed metadata that already lists both certificates works the same way. Watch certificates[].notAfter and expired in connection summaries to warn administrators before a certificate lapses.

Configured connections

For connections fixed in your deployment, for example a single-tenant installation, pass connections:

const saml = createSamlService({
  ...iam.protocolHost,
  connections: [
    {
      id: 'acme',
      tenantId: acme.id,
      entryPoint: 'https://acme.okta.com/app/product/exk1a2b3c4/sso/saml',
      idpIssuer: 'http://www.okta.com/exk1a2b3c4',
      idpCertificates: [secrets.acmeIdpCertificate],
      entityId: 'https://product.example/saml/acme/metadata',
      callbackUrl: 'https://product.example/saml/acme/callback',
      privateKey: secrets.samlSpKey,
      publicCertificate: secrets.samlSpCertificate,
      trustedEmailDomains: ['acme.com'],
    },
  ],
});

Prop

Type

The service itself (createSamlService) takes these options, shared by both kinds of connection:

Prop

Type

Routes

Once mounted with iam.useProtocol, the service answers these routes itself:

RouteWhat it does
GET {basePath}/{id}/loginStarts SP-initiated sign-in and redirects (302) to the IdP. ?returnTo=/path replaces afterSignIn for this sign-in.
POST {basePath}/{id}/loginStarts account linking for the signed-in account; answers { url }.
GET {basePath}/{id}/metadataSigned SP metadata (application/samlmetadata+xml), for IdPs that import it.
POST {basePath}/{id}/acsThe assertion consumer service of a tenant-managed connection.
POST on a configured callbackUrlThe assertion consumer service of a configured connection.

The assertion consumer service accepts only the SAML HTTP-POST binding. Without afterSignIn it answers the sign-in result as JSON, and every failure answers 401 with { "error": "SAML_INVALID" } and no further detail, so an attacker learns nothing from probing it.

Redirect after sign-in

Browsers land on the ACS, so a JSON answer leaves people looking at JSON. Give the service the pages to continue on:

lib/iam.ts
export const saml = createSamlService({
  ...iam.protocolHost,
  serviceProvider,
  afterSignIn: '/dashboard',
  mfaUrl: '/login/mfa',
  errorUrl: '/login',
});
iam.useProtocol(saml);
OutcomeThe ACS answers
Session issued303 to the returnTo path the sign-in started with ({basePath}/{id}/login?returnTo=/invoices), else afterSignIn, with the session cookie set. IdP-initiated sign-ins go to afterSignIn.
MFA required303 to mfaUrl (default afterSignIn) with the challenge in the fragment: #tenantId=…&mfaRequired=true&challenge=…&enrollmentRequired=…, plus authenticatorEnrolled, emailCodeAvailable, passkeyAvailable, and returnTo when they apply. The page finishes with auth.verifyMfa (or auth.beginMfa and auth.confirmMfa when enrollmentRequired is true).
Failure303 to errorUrl?error=CODE, such as SAML_INVALID, ACCOUNT_LINK_REQUIRED, or VERIFIED_EMAIL_REQUIRED; without errorUrl, 401 JSON.
  • returnTo must be a path on your site (/…, not //…); anything else is refused with 400 INVALID_INPUT. afterSignIn, mfaUrl, and errorUrl are paths or URLs on trustedOrigins (or the SP origin); anything else is refused when the service is created.
  • The fragment never reaches a server, a log, or a Referer header, so the MFA challenge stays in the browser.
  • The redirect is made by the IAM handler: the ACS adds an x-better-iam-redirect hint to its JSON answer, and the protocol mount follows it only to a path or a trusted origin, adding the session cookie and dropping the body. Called directly (saml.handler), the ACS keeps answering JSON.

Without the IAM handler, call the steps yourself: begin(connectionId, credential?) returns the IdP url plus relayState and a binding value to keep in a cookie, and callback(connectionId, { samlResponse, relayState, binding }) validates the response and returns the session.

Response validation

Every response goes through these checks, each of which blocks a known SAML attack:

  • Both the response and the assertion must be signed, so no part can be swapped.
  • The IdP issuer must match exactly, and so must the SP audience, the response Destination, and every subject Recipient (the ACS URL), so an assertion meant for another app is refused.
  • The assertion age is bounded (five minutes, with 30 seconds of clock skew), and the response must answer an outstanding InResponseTo request.
  • A persistent connection-scoped cache and a transaction around verification prevent replay across processes.
  • The RelayState and request ID are bound to a Secure, HTTP-only, SameSite=None cookie, so the response is only accepted in the browser that started the sign-in.
  • DTDs and entity declarations are rejected (they enable XML attacks), and so are oversized responses.
  • Better IAM signs its metadata and requests with SHA-256.

Encrypted assertions

Assertions are signed but, by default, readable by anyone who sees them in the browser. Some organizations require them encrypted. Supply decryptionPrivateKey and decryptionCertificate (on a configured connection, or on serviceProvider for managed ones) to accept encrypted assertions. requireEncryptedAssertions: true rejects plaintext assertions.

First sign-in and account linking

The external identity is keyed by the connection, the IdP issuer, and the assertion's NameID. The name comes from displayName.

  • SAML email attributes are unverified by default, because an IdP administrator can type any address. For first-login enrollment, explicitly configure trustedEmailDomains, and only for domains whose account email attributes this IdP is authorized to verify. An email in a trusted domain counts as verified, so the first sign-in can create the account.
  • Otherwise the person signs in through an already linked external identity, or links an existing, authenticated local account.
  • Matching an existing email alone never links it. An unverified email fails with VERIFIED_EMAIL_REQUIRED, and a verified one that belongs to an existing account fails with ACCOUNT_LINK_REQUIRED, with one opt-in exception: with trustScimProvisioned, a verified email that belongs to an account a trusted SCIM connection of the tenant provisioned links to it (and marks the email verified). Owners, root administrators, disabled accounts, and accounts already linked through this connection under another NameID are never linked this way; the link is audited as identity:link-provider with via: 'scim'.
  • An administrator can link an existing account ahead of its first sign-in with identities.linkProvider: providerId is the managed connection's providerId (configured connections: their providerId, else id), issuer its idpIssuer, and subject the person's NameID.

To link, POST to the login path with the same trusted-origin and recent-authentication requirements as OAuth linking: a trusted Origin, X-Better-IAM: 1, a JSON body, and a user session in the connection's tenant authenticated within five minutes. Root administrators cannot link this way.

Federation invokes the tenant's local MFA requirements before issuing a session.

Map attributes

The IdP usually knows facts your policies can use, such as a person's department or cost center. attributeMapping on a managed connection copies them in, mapping identity attribute names to SAML attribute names and taking the first value of multi-valued attributes:

attributeMapping: { department: 'department', title: 'title', costCenter: 'costCenter' }

Configured connections use mapAttributes(profile) with the validated assertion profile instead. Either way the values reach completeAuthentication, are validated against permissions.identityAttributes inside the sign-in transaction, and replace the 's stored attributes on every sign-in, so directory data drives principal.department-style policy conditions. An invalid mapping fails the sign-in closed.

IdP-initiated sign-in

"IdP-initiated" means the person starts in their IdP's portal (app tiles in Okta or the Entra My Apps page) and clicks your app, so the IdP sends a response nobody asked for. It is off by default. Set allowIdpInitiated: true on a connection to accept such responses at its callback URL.

  • They must carry no InResponseTo and pass the same signature, issuer, audience, destination, recipient, and five-minute age checks.
  • Each assertion ID is accepted once per connection, recorded in samlAssertions for ten minutes.
  • The response signs the person in without account linking, and a RelayState is ignored.
  • Responses that do carry InResponseTo still need the browser-bound request.
  • idpInitiated(connectionId, samlResponse) is the direct call, for when you receive the POST yourself instead of through the built-in assertion consumer route.

Weaker than SP-initiated sign-in

IdP-initiated SSO cannot be tied to a browser the way SP-initiated login is, so a stolen, still-unused response can be replayed once in another browser within those minutes. Enable it only for IdPs your organizations rely on for portal launches.

Logout and limits

logout(credential) calls the revokeSession callback you configure, which ends the local IAM session. Upstream SAML sessions are not modified: the person stays signed in at their IdP. SAML IdP functionality (Better IAM acting as a SAML IdP for other apps) and federated single logout are outside this release; use the OAuth/OIDC provider to be an identity provider for your own apps.

Schema

CollectionWhat it holds
samlConnectionsTenant-managed connections: id, tenantId, name, enabled, entryPoint, idpIssuer, idpCertificates, trustedEmailDomains, requireEncryptedAssertions, attributeMapping, allowIdpInitiated, trustScimProvisioned, providerKey, timestamps. Configured connections are not stored.
samlRelaysOne row per sign-in in progress, keyed by a hash of the relay state, bound to the connection and the browser binding cookie, with the linking session when linking. Deleted when the response arrives.
samlRequestsOutstanding AuthnRequest IDs, so a response's InResponseTo must match a request this service sent.
samlAssertionsAssertion IDs already accepted, kept until they expire, so an assertion works once.
externalIdentitiesThe link between an IdP subject and an identity, shared with OAuth sign-in.

Errors

CodeStatusWhen
SAML_INVALID401Any assertion consumer service failure (the built-in route answers 401 even where the direct calls use 400, 413, or 415): an unsafe document, wrong binding, bad signature, wrong issuer, audience, or recipient, an expired or replayed assertion, an unknown InResponseTo, an unsolicited response without allowIdpInitiated, a missing encrypted assertion when required, or an oversized response. The body says only SAML_INVALID.
NOT_FOUND404The connection ID is unknown or the connection is disabled.
INVALID_INPUT400createConnection or updateConnection with an invalid ID, name, sign-on URL, issuer, certificates (one to five), email domains, attribute mapping, or encryption settings without a decryption key.
CONFLICT409A connection with that ID exists, or the connection changed during an update.
TENANT_INACTIVE403The connection's tenant is suspended.
CSRF403A linking POST without a trusted Origin and the X-Better-IAM header.
RECENT_AUTH_REQUIRED403Linking without a recent sign-in, as a root administrator, or from another tenant's session; or creating a connection, or widening its trust, without a recent sign-in.
ACCESS_DENIED403Widening a connection's trust when an owner or root administrator signs in through it and the caller could not control those accounts.
VERIFIED_EMAIL_REQUIRED400A first sign-in whose email is not in the connection's trusted email domains, or has no email.
ACCOUNT_LINK_REQUIRED409An account with that email exists; link it explicitly.
ACCOUNT_LINK_CONFLICT409The IdP subject is already linked to another account.

createSamlService throws a configuration error at startup for incomplete or duplicate configured connections.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page