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:
| Term | Meaning |
|---|---|
| Entity ID | A unique name for each side. Your SP entity ID is also the audience the assertion must be addressed to. |
| ACS URL | The assertion consumer service: the URL on your side where the browser posts the IdP's response. |
| Metadata | An XML file describing one side: its entity ID, URLs, and signing certificates. Exchanging metadata sets up the trust. |
NameID | The IdP's identifier for the person inside the assertion. |
RelayState, InResponseTo | Values 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.
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:
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 IdPcreateConnection 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:
| URL | Use |
|---|---|
{baseUrl}/saml/{id}/metadata | Signed SP metadata, which some IdPs can import directly. Also the SP entity ID (the audience): connection.entityId. |
{baseUrl}/saml/{id}/acs | The HTTP-POST assertion consumer service: connection.acsUrl. |
{baseUrl}/saml/{id}/login | Where 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:
| Method | Permission | What 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:read | Reads one connection. |
updateConnection(credential, { tenantId, connectionId, ...changes }) | iam:saml:connections:update | Changes settings, uploads refreshed metadata, or rolls certificates. Audited as iam:saml:UpdateConnection. |
deleteConnection(credential, { tenantId, connectionId }) | iam:saml:connections:delete | Removes the connection and its pending sign-ins. Audited as iam:saml:DeleteConnection. |
Permissions apply in the connection's tenant. 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.
External identities linked through a deleted connection stay with their accounts. 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).
- Create a SAML 2.0 app integration.
- Set Single sign-on URL to
connection.acsUrland Audience URI (SP Entity ID) toconnection.entityId. - Keep both the response and the assertion signed.
- Add attribute statements, for example
emailfromuser.emailanddepartmentfromuser.department. - 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:
| Route | What it does |
|---|---|
GET {basePath}/{id}/login | Starts SP-initiated sign-in and redirects (302) to the IdP. |
POST {basePath}/{id}/login | Starts account linking for the signed-in account; answers { url }. |
GET {basePath}/{id}/metadata | Signed SP metadata (application/samlmetadata+xml), for IdPs that import it. |
POST {basePath}/{id}/acs | The assertion consumer service of a tenant-managed connection. |
POST on a configured callbackUrl | The assertion consumer service of a configured connection. |
The assertion consumer service accepts only the SAML HTTP-POST binding. Every failure answers 401 with
{ "error": "SAML_INVALID" } and no further detail, so an attacker learns nothing from probing it.
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 subjectRecipient(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
InResponseTorequest. - A persistent connection-scoped cache and a transaction around verification prevent replay across processes.
- The
RelayStateand request ID are bound to aSecure, HTTP-only,SameSite=Nonecookie, 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 withACCOUNT_LINK_REQUIRED.
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
InResponseToand pass the same signature, issuer, audience, destination, recipient, and five-minute age checks. - Each assertion ID is accepted once per connection, recorded in
samlAssertionsfor ten minutes. - The response signs the person in without account linking, and a
RelayStateis ignored. - Responses that do carry
InResponseTostill 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.
Next steps
Better IAM is created by Sean Filimon
Last updated
OAuth and OIDC sign-in
Sign people in with Google, GitHub, Microsoft Entra ID, any OpenID Connect provider, or a plain OAuth2 provider, with PKCE and explicit account linking.
OAuth/OIDC provider
Run a tenant-aware OAuth 2.0 and OpenID Connect authorization server for your own apps, CLIs, devices, and service accounts.