SSO (SAML & LDAP)
workeros supports per-tenant SAML 2.0 SSO and LDAP / Active Directory
for the workspace end-user pool (the app_users table, served via
/api/t/<slug>/auth/*). The admin app itself stays on the existing
/api/auth/* better-auth surface.
Both flows land in the same external_identities row store (SAML rows
carry provider_type = 'saml', LDAP rows provider_type = 'ldap').
SAML is covered first; LDAP / AD reference is at the bottom of this
page.
How it fits together
+-------------+ AuthnRequest +-------------+ SAMLResponse +----------------+| | ---------------------> | | <--------------------- | || customer | | IdP | | workeros || end-user | <--------------------- | (Okta etc) | -- 302 to ACS POST --> | (workspace SP)|| | sign-in form | | | |+-------------+ +-------------+ +----------------+ | v provision app_user, issue app_session, 302 to RelayState with #token=...URL pattern, per workspace + provider slug:
| Purpose | URL |
|---|---|
| SP-initiated login | ${APP_URL}/api/t/<slug>/auth/saml/<provider-slug>/login?relayState=… |
| Assertion Consumer | ${APP_URL}/api/t/<slug>/auth/saml/<provider-slug>/acs (HTTP-POST) |
| SP metadata XML | ${APP_URL}/api/t/<slug>/auth/saml/<provider-slug>/metadata |
| SLO | ${APP_URL}/api/t/<slug>/auth/saml/<provider-slug>/slo |
The slug part of the URL is the workspace slug (e.g. default), and the
provider slug is the one you choose when creating the SAML provider in the
admin UI (Authentication → Add SAML).
Configure an IdP
The admin dialog has three tabs:
- From template — pick Okta / Azure / Google / ADFS / JumpCloud / Auth0.
The attribute map (
email,firstName,lastName,groups) pre-fills with that vendor’s defaults. - Import metadata — paste the IdP’s metadata XML or its URL; we pull out the entityID, SSO URL, SLO URL, and signing cert.
- Manual entry — type every field.
The signing cert is stored encrypted (AES-256-GCM via AUTH_SECRET); we
only ever decrypt it inside resolveSamlProvider, never return it.
Okta
- Okta admin → Applications → Create App Integration → SAML 2.0.
- Single Sign On URL: paste the ACS URL from the workeros admin dialog
(
Authentication → SAML provider → ACS URL). - Audience URI (SP Entity ID): paste the SP entity id (= metadata URL by default).
- Name ID format:
EmailAddress(matches our default). - Attribute Statements: add
email,firstName,lastName, and (optionally)groups. Use the names from the attribute-map fields in the workeros dialog. - Okta will give you a metadata URL — paste it into the workeros dialog’s Import metadata tab and click Fetch & parse.
Azure AD / Entra ID
- Microsoft Entra admin → Enterprise applications → New application → Create your own application → Non-gallery.
- Single Sign-On → SAML.
- Identifier (Entity ID): paste the SP entity id.
- Reply URL (ACS): paste the ACS URL.
- Attributes: keep the default
http://schemas.xmlsoap.org/…namespace — the workeros Azure AD / Entra ID template maps those already. - Download the Federation Metadata XML and import it via the dialog, or paste the SAML Signing Certificate (Base64) into the manual tab.
Google Workspace
- Workspace admin → Apps → Web and mobile apps → Add app → Add custom SAML app.
- Download the metadata file Google offers; paste into the dialog’s Import metadata tab.
- Service provider details: ACS URL = the workeros ACS URL; Entity ID =
SP entity id; Name ID format =
EMAIL. - Attribute mapping: map
Primary email→email,First name→first_name,Last name→last_name. The workeros Google Workspace template uses those keys.
Security knobs
linkByVerifiedEmail(off by default): when on, an existing app-user with the same email is linked to the SAML subject on first login. This makes a hostile IdP an account-takeover vector for any local account sharing an email; only enable it for IdPs you actually trust.wantSignedAssertions(on by default): rejects unsigned Assertions.- Replay protection: every AssertionID lands in
app_verificationsuntilNotOnOrAfter. A second POST of the same Assertion before then is rejected with 401. InResponseTo: SP-initiated logins persist the AuthnRequest id inapp_verifications; the ACS handler rejects responses whoseInResponseTodoesn’t match a known request.relayState: validated againstauth_config.redirectUrlsbefore every redirect (open-redirect protection).
Group → role sync
Set groupsToRoles on a provider (admin UI field reserved for v2 — for
now use the JSON attributeMap/groupsToRoles columns directly). On
each login the provisioner snapshots the assigned roles on
external_identities.rolesFromGroups; the diff against the prior
snapshot drives the role-add and role-remove calls. Manual role
assignments aren’t touched.
Troubleshooting
- “SAML audience mismatch” → the IdP set
Audienceto a different value than the workeros SP entity id. Set the IdP’s Audience URI / Entity ID to the SP entity id printed in the admin dialog. - “SAML issuer mismatch” → the IdP’s
<Issuer>doesn’t match the configuredentityId. Copy the IdP entity id exactly from its metadata. - “SAML verification failed: digest mismatch” → the IdP isn’t signing with the cert we have on file (e.g. rotated cert, wrong cert). Re-paste the current cert via the dialog’s manual tab.
- Replay rejected immediately after IdP redirect → can happen when the
same browser tab re-POSTs the form (e.g. devtools “preserve log” re-fires
a request). Treat as the design: re-initiate by hitting the
/loginendpoint again. - Clock skew → samlify enforces
NotBefore/NotOnOrAfter. If your IdP or workeros host is more than ~5 min off NTP, fix that first. - Cert format → must be PEM (
-----BEGIN CERTIFICATE-----…). DER binary or.crtwith Windows line endings: re-export as PEM. - Cloudflare Workers runtime — samlify imports
xml-cryptowhich usesnode:crypto. Workers expose those undernodejs_compat(apps/web/wrangler.toml); deploying without that flag will fail at boot.
LDAP / Active Directory
LDAP / AD is the second federated-identity option alongside SAML. Use it when your customer wants to keep username + password sign-in but bind against their existing directory (no IdP-side SAML configuration needed).
Runtime requirements
LDAP needs raw TCP via node:net/node:tls. Cloudflare Workers do not
expose raw sockets, so the LDAP adapter is gated off there: the route
returns 503 UNAVAILABLE and apps/web/src/server/lib/auth-select.ts:: buildLdapAdapter short-circuits to undefined. Use SAML on Workers, or run
the app on Bun / Vercel / Netlify where node:net is available.
The Worker bundle still resolves import "ldapts" — it’s aliased to
apps/web/src/server/shims/ldapts-shim.ts (wired in both wrangler.toml
[alias] and vite.config.ts), which throws if anything ever actually
calls new Client(...) on Workers.
Config storage
Per-workspace single-row config in ldap_configs (PK on tenant_id; the
_global sentinel works as the instance-wide fallback, same pattern as
email_config). bind_password and the optional ca_pem (custom TLS CA
for self-signed LDAPS) live in secrets as enc:v1:… AES-256-GCM
ciphertext — never returned by the API, just a per-key “is it set” flag.
Admin CRUD lives at /api/admin/ldap-config (GET, PUT, POST /test). The
admin UI in Authentication → LDAP / Active Directory writes through here.
Sign-in flow
POST /api/t/<slug>/auth/ldap/sign-in with {username, password}:
- Resolve the workspace + its LDAP config (missing/disabled/unsupported → 503).
- Per-
(tenant, normalized_username, ip)rate limit (config.rateLimitPerMinute, default 10/min). Both successes and failures count. - If
domainMatchis set and the username contains@, reject pre-LDAP when the domain isn’t in the allow-list (saves the directory round-trip). - Service-bind, escape the username per RFC 4515, search by
userFilter, then user-bind with the supplied password. Returns 401 on bad credentials or no match (same response + timing — no enumeration). - Provision via
provisionAppUser(linkByVerifiedEmail: false— directory-bound users don’t cross-link by default), applydefaultRoleId+groupsToRoles, issue anapp_sessionsrow. - Returns
{token, user: {id, email}}— not a redirect; LDAP is form-driven from the customer’s own UI.
Recipe: OpenLDAP
Typical OpenLDAP layout:
dc=example,dc=com ou=users uid=alice,ou=users,dc=example,dc=com (objectClass: inetOrgPerson) ou=groups cn=engineers,ou=groups,dc=example,dc=com (objectClass: groupOfNames) member: uid=alice,ou=users,...Settings:
- URL —
ldaps://ldap.example.com:636(useldaps://in production). - Bind DN — a read-only service account, e.g.
cn=workeros-readonly,ou=service,dc=example,dc=com. - Base DN —
ou=users,dc=example,dc=com. - User filter —
(&(objectClass=inetOrgPerson)(uid={{username}})). - Attribute map —
email = mail,firstName = givenName,lastName = sn,groups = memberOf(with thememberofoverlay loaded on the OpenLDAP server; without it, switchgroupsto the empty string and forgo group sync).
Recipe: Active Directory
Settings:
- URL —
ldaps://dc1.corp.example:636. Workers can’t reach AD over raw TCP — host on Bun/Node. - Bind DN — typically a domain account:
cn=workeros,ou=Service Accounts,dc=corp,dc=exampleor the UPNworkeros@corp.example. - Base DN —
dc=corp,dc=example(or scope tighter —ou=Users,...— if all sign-in users live below one OU). - User filter —
(&(objectClass=user)(sAMAccountName={{username}}))for legacy login names, or(&(objectClass=user)(userPrincipalName={{username}}))when users sign in with their email/UPN. - Attribute map —
email = mail,firstName = givenName,lastName = sn,groups = memberOf(AD exposes group DNs directly on the user entry). - Pagination — AD truncates
memberOfat 1 500 entries and returnsmemberOf;range=0-1499. The adapter detects the ranged form and re- queries withmemberOf;range=<next>-*until exhausted, so users in many groups still work.
TLS notes
- LDAPS is just LDAP over TLS on port 636 — recommended for any internet-reachable directory.
- StartTLS (negotiate TLS on port 389) isn’t supported by the current adapter; use LDAPS.
- Self-signed CAs — paste the root/intermediate PEM into the
Custom CA PEM field. It’s encrypted into
secrets.caPemand only decrypted on the way into the TLS handshake. - Disable cert verification (
Reject unauthorized certsoff) only as a last-ditch debugging step. Production deployments should always reject unauthorized certs.
Filter-injection protection
The adapter escapes the username per RFC 4515 before substituting it into
userFilter. So userFilter = "(uid={{username}})" with username
alice)(uid=* becomes
(uid=alice\29\28uid=\2a) — a valid filter that matches nothing — instead
of collapsing the filter to match all users. Don’t try to pre-escape on
the client; the server always escapes.
Troubleshooting
- 503 / “LDAP is not available on this runtime” — running on Cloudflare Workers. Move to Bun/Vercel/Netlify or use SAML SSO.
- 401 every time — likely either the bind DN/password is wrong (check
the Test connection dialog), or the
userFilterdoesn’t match (try it againstldapsearch -x -H <url> -D '<bindDn>' -W -b '<baseDn>' '(...your filter with the username...)'). - Login succeeds but groups are empty — the
memberOfoverlay isn’t enabled (OpenLDAP) or the user really isn’t in any groups. Runldapsearch ... '(uid=alice)' memberOfto confirm. hostname-mismatch/unable to get issuer cert— the LDAPS cert chain isn’t trusted by the host’s CA store. Paste the issuing CA’s PEM into Custom CA PEM.