Enterprise SSO
Let your customers' employees sign in with their company's identity provider, through your own WorkOS account.
Enterprise single sign-on lets the employees of a company you sell to sign in to your app with their company account: Okta, Microsoft Entra ID, Google Workspace, or any SAML or OIDC identity provider. Their login page shows Continue with SSO, they type their work email, and their company's identity provider does the rest.
Setting it up for the first time? Set up Enterprise SSO is the short version, in order.
It runs through your own WorkOS account. BuildBase stores your WorkOS client ID and an encrypted API key, creates one WorkOS organization per customer company, and handles the sign-in callback on your tenant server. WorkOS bills you for each enterprise connection, invoiced monthly; see WorkOS pricing for the current rates.
Before you start
A WorkOS account, an auth client with a login page, and an owner or admin role in the BuildBase console.
How it fits together
| Piece | Where it lives | Who sets it up |
|---|---|---|
| WorkOS client ID and API key | Console, Authentication, Enterprise tab | You, once |
| An enterprise (one customer company) | Same tab, Add enterprise | You, per customer |
| The company's email domains | The enterprise, verified by WorkOS | You add them; the customer's IT admin verifies them in WorkOS |
| The identity provider connection | The WorkOS setup portal | The customer's IT admin |
| Who WorkOS alerts about the connection | The WorkOS setup portal, shown read only on the enterprise | The customer's IT admin |
| Continue with SSO on the login page | An Enterprise SSO method attached to the auth client | You, once |
| The workspaces its people join | The enterprise's Workspaces section | You, per customer |
| Provisioning from the company directory | The enterprise's Provisioning section | You, then the customer's IT admin |
| SSO status and setup inside your app | The SDK's Single sign-on settings screen | Nobody: it appears on its own |
| Company audit logs in WorkOS (optional) | Same tab, Company audit logs in WorkOS, off by default | You, once, if you want WorkOS's viewer, export and log streams |
| Agents approved by the company's identity provider (optional) | Agent readiness, then the company's issuer | You, once, then the customer's IT admin (agents through a company's identity provider) |
When your plan no longer includes it
Enterprise single sign-on is a plan feature (auth-sso-enterprise), and every new piece of setup asks for it: the WorkOS provider, an enterprise, an email domain, an API key, a service account, a workload credential, a company role or group, a log stream, an identity provider trusted for agents, provisioning, workload identity and role assignment in WorkOS. The console shows each of those controls locked, and the API answers 402 with FEATURE_NOT_IN_PLAN, from the console, from your app and from the company API alike.
Nothing that already works stops. People keep signing in with their company account, directories keep syncing, keys and streams keep working, and every screen still reads. Removing anything, turning anything off, and the security actions (signing everyone out, ending a session, resetting passkeys, revoking an app, the access policy) are never refused, so you can always tidy up or tighten.
1. Connect your WorkOS account
- Create a WorkOS account, or use the one you have. Use its staging environment while you test and production when you go live.
- On that environment's API Keys page, copy the client ID (
client_...) and create an API key (sk_...). Both values must come from the same environment: a key from staging and a client ID from production pass every check until the first sign-in, which then fails. - In the BuildBase console, open Authentication, then Enterprise, paste both, and choose Save and validate. BuildBase checks the key with WorkOS before it stores anything.
- The tab now shows your redirect URI. It has the form
https://<your server>/api/v1/auth/enterprise/<organization id>/callback. In WorkOS, add it under the application's Redirects and make it the default. - Leave AuthKit turned off in that environment. BuildBase uses WorkOS for single sign-on only and runs the sign-in itself.
The key never comes back from BuildBase: the console shows its first and last characters, and Check the key asks WorkOS whether it still works.
2. Add an enterprise
Add enterprise creates a WorkOS organization for the customer in your WorkOS account. Two settings come with it:
- Create accounts on first sign-in. On by default. An employee who signs in by SSO for the first time gets an account, even when public sign-up is closed, because their identity provider vouched for them. Turn it off and only people who already have an account, or an invitation, get in.
- Role for new accounts. The role an account created by SSO starts with. Any of your user roles except owner.
Once the connection works, the enterprise's settings also offer Require single sign-on. With it on, the enterprise's people are refused at every other door (password, magic link, email code, phone code, passkey, wallet, social sign-in, Google One Tap included, and the second step of two-step sign-in) with a sentence telling them to use Continue with SSO. On the hosted login page, a refused passkey opens the SSO field beside that sentence, and a magic link opens a page that says it in place of signing in. An app with its own password form gets success: false, reason: "sso_required" and the same sentence back from the sign-in call. Attach the Enterprise SSO method to every auth client their people sign in to, or the sentence points at a button that page does not have. It reaches everyone on the enterprise's verified domains: its people (signed in through it, provisioned, or joined by their email domain), and an account on one of those domains that no SSO sign-in has linked yet, which its first SSO sign-in then links. Break-glass covers only people already linked. It applies only while the enterprise is active and enterprise sign-in is on, so turning either off lets everyone back in, and people already signed in stay signed in. Turn it on after the connection is active: until it is, they cannot sign in at all. A company that does not require single sign-on can still narrow the other ways in, down to passkeys only, in its access policy: see sign-in methods without single sign-on.
3. Verify the email domain
WorkOS verifies the company's email domains. BuildBase publishes no DNS record of its own and reads nobody's DNS: it puts the domain on the company's WorkOS organization, and WorkOS's verdict is what counts.
- In the enterprise's Email domains section, Add domain with the company's domain (for example
acme.com). BuildBase creates it on the company's WorkOS organization, where it shows as Pending. - The customer's IT admin publishes the DNS record WorkOS's Admin Portal shows them: the portal works out their DNS provider and walks them through it. Open the portal on the domain step with Verify in WorkOS, a five-minute link for when you do it together on a call, or send the IT-admin invitation with Domain verification ticked (step 4). From SDK 1.0.0, the company's own admins can also open it from your app.
- Choose Verify. That asks WorkOS to check, and WorkOS answers while it is still checking, so the domain usually stays Pending for a moment. WorkOS's
organization_domainevent then moves it to Verified or Not verified. Refresh from WorkOS reads WorkOS's answer on demand, and a daily read catches any event that went missing.
With your WorkOS webhook registered from the console, WorkOS's verdict reaches your server within seconds; without it, within six hours, when your server next reads WorkOS's events. Refresh from WorkOS never waits for either.
A domain counts only while WorkOS verifies it: a pending or unverified domain routes nobody, and one that WorkOS stops verifying, or that the IT admin deletes in the Admin Portal, stops routing people at the next read. A domain the IT admin adds and verifies in the Admin Portal appears on the enterprise on its own. Remove takes a domain off the WorkOS organization too, and removing the only verified domain makes the connection inactive in WorkOS until another is verified. People already linked keep their accounts.
A subdomain is its own entry. Consumer mail domains (gmail.com, outlook.com and the like) are refused, and a domain another enterprise of yours already holds is never taken, whether you add it or WorkOS reports it.
The domain is also the security check on every sign-in: the email the identity provider asserts must be on one of the enterprise's verified domains, or the sign-in is refused. The same domains decide who joins the company by their email, which accounts it can claim and who counts as outside it in its workspaces: see domains and joining.
4. Hand the connection to the customer's IT admin
The customer's IT admin connects their identity provider in the WorkOS setup portal. Two ways to get them there, from the enterprise:
- Invite the IT admin by email. WorkOS emails them a setup link. They never need a BuildBase account. Tick Domain verification and the same email lets them verify the company's domains in WorkOS before they connect the identity provider, and Provisioning adds the directory setup once provisioning is on. One invitation is open per enterprise at a time; Withdraw it and resend replaces it, whichever of the company's contacts the open one went to. Their address joins the company's IT and security contacts, who also get WorkOS's certificate warnings and connection alerts.
- Open the setup portal now. A five-minute link opens in a new tab, for when you set the connection up together on a call. Do not forward it: anyone holding it can change the connection.
Send them the page for IT admins with the invitation. The enterprise's Connection section shows when their connection is active.
5. Turn it on
- Under Authentication, Methods, add an Enterprise SSO method, and attach it to the auth client whose login page should offer it.
- On the Enterprise tab, turn Sign-in on.
The login page now shows Continue with SSO, as one button under the client's other methods; pressed, it asks for a work email. When SSO is the client's only method, the field shows straight away. It never says whether a domain has SSO: someone whose company is not set up is told so after they continue, not before.
A sign-in that starts from the button can only finish in the browser that pressed it. So a sign-in code copied from an address bar or a log signs nobody in. And nobody can start a sign-in as themselves and get an employee's browser to finish it.
This is PKCE: a check that the sign-in finishes in the same browser that started it. The hosted page makes a secret (the verifier), keeps it in the tab, and sends only a fingerprint of it (an S256 challenge) with the start. The start (/api/v1/auth/enterprise/<org id>/start/<method id>) refuses a request without one. When the company's identity provider sends the person back, the sign-in waits under a single-use handoff code, and only the browser holding the matching verifier can redeem it.
A sign-in from the identity provider's dashboard has no start, so it keeps its own path. If you build a sign-in screen of your own, it must send the challenge too: see sign-ins that leave the page.
6. Bind workspaces
In the enterprise's Workspaces section, choose the workspaces its people join, the role each one joins with, and whether they join automatically. Save workspaces saves the whole list.
- Someone joins on their first sign-in through the enterprise: a new account, or an existing account signing in through it for the first time. People who signed in before you bound a workspace are not added; add them from the workspace's members.
- They join as members the enterprise manages. Someone a workspace admin removes later is not put back on their next sign-in.
- A workspace belongs to one enterprise. Binding needs workspaces that can take members: not Personal mode, and adding members turned on in the workspace settings.
- The usual limits still apply: your plan's member ceiling, the organization's members-per-workspace limit, and the workspace subscription's seats. A join one of them refuses is skipped, the person still signs in, and the enterprise shows Needs a look with the reason until you next save the list.
- A role removed from your workspace roles after you saved the binding falls back to the default role.
The same workspace also tells its admins which enterprise it belongs to, in your app (below). To have the company's directory create and remove its people instead of waiting for each first sign-in, see enterprise provisioning.
Roles from single sign-on
The company can decide roles in its own identity provider, without provisioning. WorkOS's role assignment puts the person's roles on the sign-in (as slugs, such as it-admin), and some connections also pass on the identity provider's group names. In the enterprise's Roles from the identity provider section, map either to a role in your app or in a bound workspace:
| From | Matches | Needs |
|---|---|---|
| WorkOS role | A role WorkOS assigns from the company's groups, read from its directory or its single sign-on (below) | Single sign-on |
| SSO role | A role slug the identity provider assigns: lowercase letters, digits, - and _ | Single sign-on |
| SSO group | A group name, where WorkOS passes the connection's group list on (see below) | Single sign-on |
| Directory group | A directory group (provisioning) | Directory Sync on your plan, and provisioning on |
The section lists the roles and groups recent sign-ins carried, and how many people had each, so you map what the identity provider actually sends. The rules are the ones directory groups follow: the highest role any of them gives wins, in the app and in each workspace; losing the role or group takes it back; a role changed by hand stands until they next disagree; ownership is never mapped; and bindings decide which workspaces someone is in, mappings only their role there.
Roles are worked out again at every sign-in through the enterprise, as WorkOS does, and before the session is made, so the session carries the new role. Saving the mappings applies them at once to everyone whose last sign-in carried roles or groups; someone who has not signed in since gets them at their next sign-in. Each change is in the enterprise's trail as enterprise_sso_roles_applied, with what the identity provider sent.
Roles from WorkOS
WorkOS can work out each person's role itself, from the groups they are in: the company's IT admin assigns their groups to roles in the WorkOS Admin Portal, or you do it in the WorkOS dashboard. BuildBase reads the role WorkOS gives each person, for every company, with nothing to turn on: from the SSO profile at each sign-in, and from the directory record at each directory sync. WorkOS role is the mapping source that matches it, by slug, and it is offered first. It needs only single sign-on, as SSO role does.
The section's list of roles to map shows the roles the company's WorkOS organization holds and the slugs its people carry, with how many people carry each, from the directory and from sign-in. A WorkOS role gives nothing until a mapping or a company group uses it, or until it is one of the company's own roles published to WorkOS. Every rule above still holds: the highest role wins, losing the WorkOS role takes back what it gave, a role changed by hand stands until they next disagree, and ownership is never mapped.
Someone the directory provisions can carry roles from both places. The enterprise's Where WorkOS roles come from setting (workosRolesFrom) says which counts:
| Setting | A person's WorkOS roles |
|---|---|
Automatic (auto, the default) | The directory's, for someone the directory provisioned whose record carries a role; everyone else's from their last sign-in |
The directory only (directory) | Only the directory's |
Single sign-on only (sso) | Only those from their last sign-in |
WorkOS recommends one source, and the directory when there is one. Choose Single sign-on only for a company whose IT admin assigns roles on its SSO connection while its directory is an HR system: WorkOS assigns roles only in SCIM and Google Workspace directories, and not on a Google OIDC connection. Changing the setting re-applies everyone's roles: the first 5,000 people at once, and the rest at their next sign-in or sync. The SSO role source keeps reading only the sign-in, exactly as before.
When WorkOS's answer is read. At each sign-in; on each directory change, including someone joining or leaving a group once a mapping, a company group or a published role uses WorkOS roles; when the IT admin comes back from the Admin Portal, and on Refresh from WorkOS, each of which queues a read of the whole directory when one is linked; and in the daily directory read. If WorkOS cannot be reached, nothing is taken away: the last roles read stand until a read succeeds. A directory person whose roles change is in the trail as enterprise_directory_roles_applied, with what changed.
WorkOS documents no event for when only the role it computes changes, for example when the IT admin assigns a group to a different role in the portal. For the directory's people, such a change reaches BuildBase with the directory read that coming back from the portal or Refresh from WorkOS queues, or else at the next daily read, so it can take a day. People whose roles come from sign-in get it at their next sign-in. When someone's access has to end at once, deprovision them in the directory rather than changing the role their group is assigned: a deprovisioned person is blocked and loses what the directory gave them as soon as the change arrives.
Your existing mappings keep working beside it. A directory group mapping is still the way for an HR-system directory or a Google OIDC connection, where WorkOS assigns no roles.
Groups from the identity provider
WorkOS does not always pass a connection's group list on at sign-in. In a test with Okta, over SAML and OIDC, Okta sent its groups every time and the profile WorkOS gave BuildBase carried none, so an SSO group mapping matched nothing. WorkOS uses those groups as input to its role assignment instead. To map the company's groups to roles in your app:
- In your WorkOS dashboard, under Developer, Admin Portal, turn on Role assignment for Single Sign-On groups. You do this once, for every company.
- The company's IT admin maps their groups to roles in the setup portal's Map Groups to Roles step.
- Map those roles here, by slug, with SSO role (or WorkOS role).
The person in a mapped group then gets the mapped role at their next sign-in, and everyone else keeps theirs. Keep SSO group for a connection whose sign-ins do carry group names: the section's list shows what recent sign-ins carried.
Single sign-on inside your app
Your customer's workspace admins can see whether the connection works and start their IT admin's setup without asking you. Apps on @buildbase/sdk 1.0.0 or later show a Single sign-on screen in the workspace settings dialog while enterprise sign-in is on, to members whose role includes workspace:settings:view:
| The workspace | The screen shows |
|---|---|
| Bound to no enterprise | "This workspace is not linked to an enterprise" |
| Bound to an active enterprise | The company's name and its connection (on, not set up yet, setup started, or off), when it was checked, and a Refresh button |
| Bound, for a member with settings edit | Set up SSO (or Open SSO setup once it works), which opens the setup portal for the IT admin in a new tab |
| Bound, with provisioning on | A Directory sync card: whether the directory is connected, its state, its last sync, and Set up directory sync |
| Bound, with a certificate warning | A SAML certificate card: expiring on a date, or expired, with Renew the certificate for members who can edit |
| Bound, for a member with settings edit | The company's Activity: its sign-ins, refusals, provisioning and setup changes, with Download CSV |
| Bound, for a member with settings edit | The company's Access policy: session length, IP allowlist, break-glass people, the ways its people may sign in without single sign-on, your team signing in as its people, keeping its network to its own accounts with Check this network, and each person's sessions, with ending one session, one person's or everyone's, and Reset passkeys |
| Bound, before a change to the company's settings | Confirm it's you, when the member last signed in or confirmed more than 10 minutes ago (see confirming it's you) |
| Bound, after the IT admin deleted the connection | The connection as not set up, with Set up SSO; the activity says when it was activated, deactivated or deleted |
| Bound, for a member with settings edit | The company's Insights and Log streams to its SIEM, described below |
| Bound, with company audit logs on | Export from WorkOS and Open the audit log viewer beside Download CSV, how long WorkOS keeps the trail, and the company's WorkOS log stream, read only (see company audit logs in WorkOS) |
| Bound, for a member with settings edit | The company's Company admins and Connected apps, described below |
| Bound, for a member with settings edit | The company's API keys and service accounts for its own tools (see the company API) |
| Bound, for a member with settings edit | The company's Groups and Company roles (see company groups and roles) |
| Bound, with WorkOS role assignment on | The Roles in WorkOS section: in sync, or what needs attention, and when it was read, with Refresh from WorkOS and Assign roles in WorkOS, which opens the portal for the IT admin. Each role and group gets Show in the Admin Portal (see assigning roles in WorkOS) |
| Bound, on SDK 1.0.0 and later | The company's domains with WorkOS's verdict, Verify in WorkOS and Refresh from WorkOS, and Joining: requests to join, accounts on its domains and people outside it (see domains and joining) |
| Bound, on SDK 1.0.0 and later | The company's IT and security contacts, read from WorkOS, for those whose company role views Company roles, and Change in WorkOS for those who manage Setup (see IT and security contacts) |
"A member with settings edit" is who manages the company until it names a Company admin. Once it has, each of the company's sections is for the people whose company role reaches it, with its buttons for those who can manage it, and other members see who manages the company instead.
Set up SSO opens the same five-minute setup link the console does, minted for that member. The link names the first redirect URL (not a wildcard) of the auth client the member signed in through as where the portal sends the IT admin when they finish or leave, and the SDK opens the Single sign-on screen on that workspace with a one-line note when it does. That URL must be HTTPS, or http://localhost while you test; otherwise the button answers that it cannot bring the IT admin back.
Do not count on the portal coming back. On WorkOS's staging environment it stays on "Connection activated" once the connection works, and its way back is your logo, which needs your branding set in the WorkOS dashboard. So the screen also reads the status again whenever the member returns to the app's tab, for half an hour after opening the setup (from SDK 1.0.0): the IT admin closes the portal, and the screen already says single sign-on is on.
Hide the screen with ui.settings.sections.enterprise, or keep the status and hide the button with ui.settings.enterprise.setupSso; ui.settings.enterprise.provisioning hides the Directory sync card and ui.settings.enterprise.setupProvisioning only its button, and ui.settings.enterprise.certificate, ui.settings.enterprise.security, ui.settings.enterprise.activity, ui.settings.enterprise.insights, ui.settings.enterprise.logStreams, ui.settings.enterprise.admins, ui.settings.enterprise.connectedApps, ui.settings.enterprise.apiKeys, ui.settings.enterprise.groups, ui.settings.enterprise.roles and ui.settings.enterprise.workosRoles hide the certificate card, the access policy, the activity, the insights, the log streams, the company admins, the connected apps, the API keys, the groups, the company roles and the Roles in WorkOS section (see settings screens). From your backend, bb.enterprise.status(workspaceId), bb.enterprise.createSetupLink(workspaceId), bb.enterprise.audit(workspaceId), bb.enterprise.exportAudit(workspaceId), bb.enterprise.createAuditExport(workspaceId) with auditExport, bb.enterprise.policy(workspaceId), bb.enterprise.savePolicy(workspaceId, input), bb.enterprise.people(workspaceId, search), bb.enterprise.signOut(workspaceId, scope), bb.enterprise.personSessions(workspaceId, userId) with endPersonSession(workspaceId, userId, sessionRef), bb.enterprise.resetPasskeys(workspaceId, userId), bb.enterprise.logStreams(workspaceId) with createLogStream, updateLogStream, rotateLogStreamSecret, testLogStream and deleteLogStream, bb.enterprise.logStream(workspaceId) (the WorkOS stream), bb.enterprise.insights(workspaceId, days), bb.enterprise.admins(workspaceId) with addAdmin (and a role), setAdminRole and removeAdmin, bb.enterprise.roles(workspaceId) with createRole, updateRole and deleteRole, bb.enterprise.groups(workspaceId) with createGroup, updateGroup and deleteGroup (updateRole and updateGroup take assignableInWorkOS), bb.enterprise.workosRoles(workspaceId) with refreshWorkOSRoles (createSetupLink(workspaceId, { purpose: 'roles' }) opens role assignment for the IT admin), bb.enterprise.connectedApps(workspaceId) with connectedAppGrants and revokeConnectedApp, and bb.enterprise.keys(workspaceId) with createKey and revokeKey, and bb.enterprise.serviceAccounts(workspaceId) with createServiceAccount, updateServiceAccount and deleteServiceAccount do the same with the member's session (see the server SDK). A change that needs the member to confirm it's them answers 403 reauth_required there too: the member confirms in their browser, then your server calls again.
What an employee sees on their first sign-in
They type their work email, sign in at their company's identity provider, and land back in your app signed in. On a person's first sign-in in a browser, WorkOS may ask them to confirm it was them ("Verify it's you"). That is WorkOS's sign-in consent, on by default in new WorkOS environments; declining it brings them back to your login page with sso_consent_denied.
A person created by SSO joins the workspaces bound to their enterprise. With none bound, they get a workspace the way anyone does: your workspace settings create the first one automatically, which is the default, or someone invites them. With none of the three, they see the SDK's "no workspaces" screen, and the enterprise's detail in the console warns you about it.
Testing with WorkOS staging
To try it before a customer does, point a test org's provider at your WorkOS staging environment and add its redirect URI there (staging accepts http://localhost). Add an enterprise for a test company, add a domain you control and verify it through Verify in WorkOS, and set up its connection in the setup portal with an identity provider you can sign in to, such as a free developer tenant at Okta or Microsoft Entra. Then sign in from your login page with an address on that domain. A sign-in is also the only check that your client ID and key belong to the same environment: saving the key checks the key alone.
The Test Organization and test identity provider WorkOS adds to every staging environment cannot stand in here: BuildBase creates its own WorkOS organization for each enterprise, and a test started from the WorkOS dashboard has no sign-in on your login page to come back to.
When a sign-in is refused
The person sees a sentence; the redirect back to the login page carries a reason, and your tenant logs WorkOS's error code (never the identity provider's free text).
reason | What happened | What you can do |
|---|---|---|
sso_not_available | No active enterprise has a verified domain for the email | Get the domain verified in WorkOS, or check the person typed their work email |
sso_disabled | Sign-in, or this enterprise, is turned off | Turn it on in the Enterprise tab |
sso_provider_error | WorkOS or the identity provider could not finish | Check the connection is active and the key works; retry |
sso_certificate_expired | The provider leg failed, and WorkOS reported this enterprise's SAML certificate expired | Send the IT admin the renewal portal (The SAML certificate) |
sso_wrong_organization | The profile came from another WorkOS organization | Ask the IT admin to check which organization the connection belongs to |
sso_consent_denied | The person declined the WorkOS consent screen | Nothing to fix; they can try again |
sso_email_mismatch | The asserted email is not on a verified domain | Verify the domain the identity provider sends, or fix the email there |
sso_not_routed | The email's domain is the company's, but none of its identity providers signs it in now: its own are all gone, or there is no default | Send the domain to an active identity provider, or choose the default (several identity providers) |
sso_connection_not_for_domain | The identity provider that answered is not the one the sign-in was sent to, or does not sign in the email's domain | Nothing, unless the routing is wrong: the person signs in through the identity provider their domain is sent to |
sso_choice_expired | A choice of identity provider on the login page was already used, or is older than five minutes. Nothing is recorded | Nothing: they start again from Continue with SSO |
sso_link_refused | The identity belongs to another account, or the person is in another enterprise | Look at the two accounts in Users before changing anything |
blocked | The account is blocked | Unblock it in Users if that was a mistake |
signup_disabled | Create accounts on first sign-in is off and there is no invitation | Turn it on, or invite the person |
signup_unavailable | A new account would take your org past its plan's end-user limit | Raise the limit with your plan, or remove accounts you no longer need |
sso_required | Another sign-in method was used; the enterprise of their account or email domain requires SSO | Nothing, unless Require single sign-on was a mistake |
sign_in_method_not_allowed | The company's list of sign-in methods without single sign-on does not include the way they used | Nothing, unless the company's list is wrong; the person uses a way it allows |
passkey_verification_required | Under the company's list, a passkey that did not check it was them (fingerprint, face or device PIN) | Nothing to fix on your side: they use a passkey that checks them, or set a PIN on their security key |
passkey_setup_required | The company asks for a passkey and the person has none yet: the hosted page takes them to add one, and they sign in with it | Nothing: it is their first passkey being set up, once |
ip_not_allowed | The person's enterprise allows sign-in only from listed addresses, and this is not one | Nothing, unless the list is wrong (Access policy) |
tenant_restricted | The network's proxy names companies of your app in allowed-company-ids, and this account is not one of theirs | Nothing, unless the company's IT team named the wrong company (the company's network) |
tenant_header_invalid | The network's proxy sent allowed-company-ids twice, and a line names a company of your app | Ask the company's IT team to make their proxy replace the header rather than add a copy |
Sign-in that starts at the identity provider
Employees can also start from your app's tile in their identity provider's dashboard (Okta, Microsoft Entra and others), for SAML connections only.
- In the Enterprise tab, under Sign-in from the identity provider's dashboard, choose the auth client people land in. Its first redirect URL that is not a wildcard is where they arrive.
- Copy the sign-in endpoint shown there (
https://<your server>/api/v1/auth/enterprise/<org id>/sign-in) into your WorkOS application's Redirects page, as its sign-in endpoint. - The customer's IT admin adds the app's tile in their identity provider, as they would for any SAML app.
A tile click then goes through WorkOS and the hosted login page and lands in that app signed in. Your app receives a code with no state: no sign-in was started in your app, so there is no state to check, and the SDK accepts it. WorkOS shows its "Verify it's you" screen on a person's first sign-in in a browser.
With several applications in one WorkOS environment, IdP-initiated sign-ins go to the environment's default application unless the identity provider's RelayState names a client_id. Without a landing client chosen, a tile click shows a page asking the person to start from your app.
The SAML certificate
A SAML connection trusts one signing certificate from the customer's identity provider, and those expire, usually after one to three years. The day one does, nobody at that company can sign in by SSO until their IT admin uploads a new one. WorkOS emails the company's IT and security contacts a renewal link before it expires. It also sends a warning event as the date approaches and once a week after it passes, and BuildBase reads those warnings for every enterprise:
- Every six hours, from the WorkOS Events API with your key. The first read after an upgrade goes back 30 days, the most the Events API keeps, so a warning WorkOS already sent is found.
- At once, when your WorkOS webhook delivers a certificate event. Register webhook subscribes the endpoint to the certificate, connection and domain events as well as the directory's; an endpoint registered before it carried them needs Register again (the webhook).
- On demand, with Check now in the enterprise's detail. When there is nothing it can read (no WorkOS key saved, enterprise sign-in off, or an enterprise without a WorkOS organization yet) it answers 409 with the reason (
not_configured,sso_off,no_organization) rather than "no warning".
A warning puts a badge on the enterprise's row and in its detail: "Certificate expires in 5 days", or "Certificate expired". The detail's SAML certificate section shows which certificate (ResponseSigning, RequestSigning or ResponseEncryption) and its date, and Open the renewal portal opens the setup portal on the certificate step, the same five-minute link as the setup. The customer's workspace admins see the same warning on their Single sign-on screen, with Renew the certificate (from SDK 1.0.0), so their IT admin can fix it without going through you. Once the IT admin uploads the new certificate, WorkOS reports it renewed and the warning goes. WorkOS only opens the portal on the certificate while it knows of an expiring or expired one: a renewal link asked for after the renewal is refused with nothing_to_renew (409), and the certificate is read again so the warning clears.
Each change is also a webhook event and a workflow trigger, so you can page someone or email the customer:
| Event | When | Payload |
|---|---|---|
enterprise.certificate_expiring | WorkOS warns the certificate expires soon, or that it has expired (state) | enterpriseId, name, state, certificateType, expiresAt, daysUntilExpiry, connectionId, and the identity provider's label and isDefault |
enterprise.certificate_renewed | WorkOS reports the certificate replaced | The same, with state: "ok" and the new expiresAt |
Each WorkOS event is stored and sent once, however many reads see it: the six-hourly read, Check now and a webhook can overlap, and an event older than the one already stored never moves the certificate back. WorkOS's own repeats are new events, so each is sent with the days left, which is the countdown a webhook consumer wants. The exception is the first read after an upgrade, which goes back 30 days: it sends only each enterprise's latest state, and nothing for a certificate that was warned about and renewed within that month.
When the connection changes
The same read carries the connection's own life. When the IT admin activates, deactivates or deletes the enterprise's connection, WorkOS says so, and BuildBase stores it, writes it to the trail and sends it on:
| Event | When | Payload |
|---|---|---|
enterprise.connection_activated | The connection works again | enterpriseId, name, state: "active", connectionId, connectionType, eventAt, ssoRequired, and the identity provider's label, isDefault, the domains it signs in, activeConnections (how many of the company's are active now) and unservedDomains (verified domains no active identity provider signs in now) |
enterprise.connection_deactivated | Nobody can sign in through it | The same, with state: "inactive" |
enterprise.connection_deleted | It is gone; a new one must be set up | The same, with state: "deleted" |
While the connection is down nobody signs in through it. With single sign-on required (ssoRequired: true), that means only the enterprise's break-glass people can sign in at all, so page someone on enterprise.connection_deactivated. The console's Identity providers section says the same while it lasts. A company with several identity providers has an event for each, and unservedDomains names the domains nobody can sign in at now.
Several identity providers
A company can sign its people in through more than one identity provider: after buying a company whose people use Microsoft Entra ID while its own use Okta, or while it moves from one to the other. Each identity provider is a separate connection on the company's WorkOS organization, and each email domain is sent to its own.
Adding one
You add it in WorkOS, as your team: in the WorkOS dashboard, open the company's organization and add a connection for the identity provider, then enter its details there or ask the company's IT admin to finish it in the setup portal. BuildBase creates no connection itself. Each one is a separate connection on your own WorkOS bill; see WorkOS pricing.
The next time BuildBase reads the company's connections (at most once a minute, or at once with Refresh in the enterprise's Identity providers section), it lists the new one. A new identity provider signs in nobody until a domain is sent to it or it is made the default:
- The first read that finds a second identity provider records the one the company already had as the default, which signs in every verified domain that has no identity provider of its own, so nobody's sign-in changes. The trail records it as a change by the system, and a banner asks your team and the company's Setup admins to check it.
- Up to 10 identity providers per company are listed and routed. WorkOS is read 100 at a time; past the first 10 by creation, the enterprise shows how many more WorkOS lists, not used by your app.
Which identity provider signs in each domain
The routing has three parts, saved together:
| Part | What it does |
|---|---|
| The default | Signs in every verified domain with no identity provider of its own. With two or more, one must be chosen |
| A domain's own | One verified domain, exact host (umbrella.io, not its subdomains), signed in by one to five identity providers, and never by the default |
| A name for each one | What the sign-in page, the company's admins and the trail call it, 1 to 60 characters, each its own: "Okta" and "Microsoft Entra ID" until named |
A domain whose identity providers are all inactive or deleted has nobody to sign it in: it never falls back to the default, so an identity provider the company did not choose never signs in its people. The enterprise's detail, the company's screen and the connection webhooks name such a domain ("Nobody at umbrella.io can sign in with single sign-on now"); with single sign-on required, only break-glass people can sign in there.
Your team changes the routing in the console (Edit routing in the enterprise's Identity providers). In your app, the company's admins whose role manages Setup change it on the Single sign-on screen, after confirming it's them, from @buildbase/sdk 1.0.0: its EnterpriseIdentityProviders component, the useEnterpriseSsoRouting(workspaceId) hook, and EnterpriseApi.saveSsoRouting(workspaceId, input) (bb.enterprise.saveSsoRouting(workspaceId, input) from your backend). A company API key reads it in GET /api/v1/company/ and cannot change it.
# Console routes: an org API key with the enterprises resource. ?refresh=1 reads WorkOS now
GET /api/auth/enterprise/enterprises/<enterprise id>/connections
PUT /api/auth/enterprise/enterprises/<enterprise id>/sso/routing
{
"version": 1,
"defaultConnection": "<ref>",
"routes": [{ "domain": "umbrella.io", "connections": ["<ref>"] }],
"labels": [{ "connection": "<ref>", "label": "Umbrella (Entra)" }]
}
# The company's admins in your app, with their session (after confirming it's them)
PUT /api/v1/public/workspaces/<workspace id>/enterprise/sso/routingThe whole routing is replaced: what the body leaves out is cleared. Identity providers are named by a ref, never by WorkOS's id, and a ref of another company's is refused. A save checks version against the one stored, so two people saving at once cannot overwrite each other.
| Answer | Why |
|---|---|
409 sso_routing_conflict | Someone saved since version was read. Read it again before you save |
400 sso_routing_invalid, with a detail | unknown_connection, unverified_domain, too_many_for_domain (more than 5), duplicate_domain, label_invalid, label_taken or default_required; message says it in words, for the person who saved |
Each change is in the company's trail as Single sign-on routing changed, with what changed (default, routes, labels), before and after, and who: the company's admin in your app, your team, or the system's first read. It is the enterprise.sso_routing_updated webhook event and workflow trigger, and, with company audit logs in WorkOS on, company.sso_routing_updated in WorkOS.
Signing in
A sign-in always names its identity provider to WorkOS, never the company's WorkOS organization (with two connections, WorkOS cannot choose between them by organization, and the sign-in would fail).
- One identity provider signs in the person's domain: they go straight to it, as before.
- Two or more do: the login page shows "Choose how to sign in as" their address, with each one's name and its icon, and nothing else about the company. The choice lives five minutes and is used once; their last pick for that domain is remembered in their browser and offered first.
- Confirming it's them: they go straight to the identity provider they last signed in through, asked to sign in afresh, with no choice to make.
- A domain no identity provider signs in now:
sso_not_routed.
The callback takes the identity provider's answer only from the one the sign-in was sent to, and only for an address on a domain that identity provider signs in, before it looks anyone up, links an account or creates one. Otherwise the person is refused with sso_connection_not_for_domain, and the trail records it with the identity provider's name: one company's identity provider cannot sign in the people of a domain another of its identity providers signs in. A sign-in from an identity provider's dashboard is checked the same way.
Each identity provider has its own state, certificate and health: the certificate and connection events carry its label and whether it isDefault, and the insights count sign-ins by identity provider.
IT and security contacts
WorkOS writes to each company's IT and security contacts: a setup link when you invite one, a warning before the company's SAML certificate expires, and an alert when its connection has errors. The list is WorkOS's, on the company's WorkOS organization in your WorkOS account: up to 20 email addresses, with no name or role beside them. BuildBase keeps no copy of it and changes nothing in it. It shows the list, read only, and opens WorkOS's Admin Portal, where the list is changed.
- Who changes it. The company's IT team, in the Admin Portal: WorkOS documents that "after setup, users can view and manage their IT contacts list from the Admin Portal post-configuration page" (WorkOS changelog). Your team can change it in your WorkOS dashboard, on the company's organization. Invite the IT admin by email adds the invited address when it is not on the list yet.
- Where it shows. In the console, the enterprise's IT and security contacts card: each address, when WorkOS added it, Refresh, and Change in WorkOS for roles that may create enterprises (as Open the setup portal now and the invitation need), which opens the portal on the single sign-on setup for five minutes. It shows once the company's connection is active: before that, the same link opens the setup itself, and the card says so. In your app, from SDK 1.0.0, the Single sign-on screen shows the same list to the company's admins whose company role views Company roles, and Change in WorkOS to those who manage Setup, as for every setup link, once the connection is active. That section is
<EnterpriseItContacts />, inside<EnterpriseAccess />; for your own screen,useEnterpriseItContacts(workspaceId)gives the list andopenInWorkOS(), which asks for the link before any tab opens. The company's own tools read it from the company API withadmins:read. - How fresh it is. BuildBase reads WorkOS at most once a minute per company, and Refresh reads again once the last read is ten seconds old. When WorkOS refuses, that answer stands for ten seconds, or a minute when WorkOS is rate limiting your key, before WorkOS is asked again. WorkOS sends no event when the list changes, so a change made in the portal or your dashboard shows at the next read and is in no trail. The invitation is, as
enterprise_it_contact_invited, withcontactCreated: truewhen it added the address. After Send invitation the console's card reads the list again at once, so an address the invitation added shows without Refresh. - In your WorkOS dashboard you can require an IT contact email when the IT admin sets the connection up in the portal, and have WorkOS send these emails from your own domain instead of a WorkOS address: see WorkOS's Admin Portal docs.
# Console routes: an org API key with the enterprises resource. ?refresh=1 reads WorkOS now
curl -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/it-contacts"
# A five-minute link to the portal page where the IT team changes the list
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"purpose":"it_contacts"}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/setup-link"The list answers:
{
"contacts": [
{
"ref": "3f9a0c2e71b4d5a8",
"email": "[email protected]",
"addedAt": "2026-10-18T09:12:44.000Z"
}
],
"max": 20,
"readAt": "2026-10-18T10:02:11.000Z"
}contacts is in WorkOS's order, oldest first. ref is BuildBase's reference for a contact, never WorkOS's id, so neither that id nor the address goes in a URL. The setup link answers intent: "sso" and action: "itContacts", and the portal sends whoever opened it back to the console with ?setup=done&action=itContacts&enterprise=<enterprise id>, where the card reads the list again. When WorkOS cannot be read, the console's route answers 409 not_configured (no WorkOS key saved), 429 rate_limited with Retry-After: 60, or 502 with WorkOS's code.
The status your app reads carries itContacts: { max, contacts, readAt, error }. contacts is the list for a member whose company role views Company roles, and null for anyone else, for whom WorkOS is not asked; when WorkOS cannot be read it is null too, error names WorkOS's code, and the rest of the status is unaffected. ?refresh=1 on the status reads WorkOS again. From SDK 1.0.0, bb.enterprise.createSetupLink(workspaceId, { purpose: 'it_contacts' }) opens the portal page for a member who manages Setup, and comes back with action:itContacts.
Access policy
Each enterprise has an access policy for its people, whichever way they sign in: by SSO, or as a break-glass person past it. Set it in the enterprise's detail, under Access policy; the company's own admins see and change the same policy on their Single sign-on screen (from SDK 1.0.0), unless you turn off Let the company's admins change this in the app.
| Setting | What it does |
|---|---|
| Sign out after inactivity | A session ends after this long without a request: 15 minutes to 30 days, or never. Checked on every request, so it applies to sessions already open; one not seen since the limit was set or changed starts its clock at its next request, so saving a limit never signs out people who are working. A held live-notification connection is not a request |
| Sign out after, however active | A session ends this long after sign-in: 1 hour to 180 days, or your app's own length. Tightening it ends longer sessions at their next request. A connected app or agent's refresh is refused once this long has passed since the person authorized it |
| Only allow these addresses | Up to 100 addresses or CIDR ranges, IPv4 or IPv6. Outside them a sign-in is refused with ip_not_allowed, and a request with a session gets a 403 ip_not_allowed, never a 401: the session is not ended, and works again from an allowed address |
| Break-glass people | Up to five of the company's people, by email, who may sign in another way while single sign-on is required, for when their identity provider is down. Only with a passkey opens the passkey sign-in alone. They follow the company's sign-in methods too |
| Sign-in methods without single sign-on | The ways its people may sign in when they do not use single sign-on: any of Password, Magic link, Email code, Phone code, Passkey, Google, Microsoft, GitHub, LinkedIn, Apple, Custom OpenID Connect, Ethereum wallet and Solana wallet, down to Passkey alone. Single sign-on is always allowed. Unset, every way your app offers. See sign-in methods without single sign-on |
| Connected apps | Which third-party apps and agents its people may connect to their account: Any app, Approved apps only (up to 50, chosen from your agent clients, or an agent's metadata document URL), or No apps. See connected apps and agents |
| Someone on a verified domain signs in | Off, Join automatically or Ask to join: whether someone with a verified email on the company's domains, signing in another way than SSO, becomes one of its people. See joining by email domain |
| People outside the company in its workspaces | Allowed, Limited (at most editor or viewer, and held to the IP allowlist) or Blocked. The workspace owner is never refused or lowered. See people outside the company |
| Your team may sign in as its people | Allowed, and recorded (allowed, the default) or Not allowed (refused): whether your team may open a session as one of the company's people. Every such session, and every refused attempt, is in the company's trail either way. See your team signing in as the company's people |
| Stricter rules for one workspace | Any of the company's bound workspaces held to shorter sessions, fewer addresses, fewer ways in, single sign-on, fewer agents, people outside it held tighter, or your team kept out there; never looser than the rest of this policy. See stricter rules for one workspace |
Two guards stop the company's admins locking themselves out: an allowlist saved from the app must include the address it was saved from (409 would_lock_you_out), and the screen offers that address to add. Your console is never subject to the policy. If your app calls BuildBase from its own server with a person's session, add that server's addresses to the list, or those calls are refused too. A break-glass person must already be one of the company's people (signed in through it, provisioned, or joined by their email domain), and the rest of the policy still applies to them.
In your app, the SDK (from 1.0.0) keeps the session when a request is refused for its address (ip_not_allowed) or its network (tenant_restricted, or tenant_header_invalid when the network's proxy sends its header twice), rather than signing the person out, and tells your app once, whichever screen asked. Show the sentence it carries, for instance above your sign-in button:
import { useAccessRefused } from '@buildbase/sdk/react';
export function NetworkNotice() {
const { refusal } = useAccessRefused();
return refusal ? <p role="alert">{refusal.message}</p> : null;
}auth.callbacks.onAccessRefused(refusal) on the provider does the same for an app that reports it elsewhere; outside React, onAccessRefused(listener) and isAccessRefused(error) come from @buildbase/sdk.
One session, one person, or everyone
Sign people out ends every session and every connected app or agent (OAuth refresh tokens) of one person, or of everyone at the company, at once: from the people list or Sign everyone out in the console, or from the company admin's screen. They can sign in again straight away; to keep someone out, deprovision them in their directory. Signing one person out also stops every sign-in token they held from before, so one taken from them cannot open a new session. In the console, Sign out, Sign everyone out and Sign them out show to roles that can create on the enterprises resource, which the sign-out route asks for.
Sessions, beside Sign out in the people list, lists one person's live sessions, newest first, and End ends one of them: a lost laptop is signed out and the person stays signed in everywhere else. Each session shows its device, browser and system, its address and place, when it began, when it was last active, when it ends (the earlier of its expiry and the company's maximum length), and how it began (method: password, magic_link, email_code, phone_code, passkey, social, wallet, sso, guest, app_team (your team's session, the console's Sign in as user included), or unknown for a session opened before sign-in methods were recorded). A session your team opened carries issuedBy with the key's name, and no address or place in the company's view or in the person's own list.
- Ending one session deletes that session only. The person's other sessions, their connected apps and consents, the company's Admin Portal sessions and its API keys stay; Sign out still ends all of them. The ended device is told why at its next request (when a company ends a session).
- Only the company's own people are listed: anyone else is 404
not_a_person, and a ref that is not one of that person's live sessions is 404session_not_found. A session that has expired, or is past the company's idle limit or maximum length, is not listed. - The company's admins list sessions with a role that views People and end one with a role that manages People. Ending one is allowed while you keep the company's settings, as signing out is.
- Apps and agents the person connected are not listed here: they are under connected apps.
- Ending a session or a device of one of the company's people from your console's Users page writes the company's trail too.
Over the API, with the same permissions as the trail below:
# Console routes: an org API key with the enterprises resource
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"idleMinutes":30,"maxSessionHours":12,"ipAllowlist":{"enabled":true,"ranges":["203.0.113.0/24"]},"appTeamSessions":"allowed"}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/policy"
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"everyone":true}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/sign-out"
# One person's sessions, and ending one (also needs the users resource)
curl -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/people/<user id>/sessions"
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/people/<user id>/sessions/<session ref>"The workspace routes are /api/v1/public/workspaces/<workspace id>/enterprise/policy, /people, /sign-out and /people/<user id>/sessions (GET, and DELETE with /<session ref>), with the member's session and the company role each needs. A session list answers { sessions } and an end { ended: 1 }, as on the company API. Each change is in the enterprise's trail and is a webhook event and workflow trigger:
| Event | When | Payload |
|---|---|---|
enterprise.policy_updated | The policy changed, from your console or the company's admin | enterpriseId, name, changed (the fields), via (console or app) |
enterprise.break_glass_used | A break-glass person signed in past required SSO | enterpriseId, name, userId, email, door |
enterprise.sessions_revoked | A person, or everyone, was signed out, one session ended, the sessions that began a way the company's sign-in methods no longer allow were ended, or agent sessions ended together | enterpriseId, name, scope (person, everyone, session, outside_methods or agents), userId, people, sessions (1 for session), via |
enterprise.app_team_session | Your team opened a session as one of the company's people, or the company's policy refused it | enterpriseId, name, userId, appKeyName, workspaceId (or null), expiresIn (seconds, null when refused), outcome (issued or refused) |
Page someone on enterprise.break_glass_used: it should only happen while the identity provider is down.
The company's network
A company's IT team can keep its network to its own accounts. Its proxy adds allowed-company-ids: <company id> to every request to your API host, and on that network only that company's people get into your app: a personal account, or another company's, is refused with 403 tenant_restricted. There is nothing for you to turn on: the proxy is the switch, and off that network nothing changes. Send them the page for IT admins.
- Where the ID is. The company's admins find it on their Single sign-on screen, in Keep the company's network to its own accounts, with the header line, your API host and Check this network. Your console has a card of the same name in the enterprise's access policy, with the company ID, the header line and the API host, each with copy, and a link to the page for IT admins; it has no Check this network, since the check reads the network it runs from, and that is the company's, not yours. The enterprise's page shows the Company ID with copy too. Over the API it is
networkRestrictionon the policy (header,companyId,hosts, andreceived, what this very request carried), and the company API'sGET /answers it ascompany.enterpriseId. - Every door. The sign-in funnel every method passes, the SSO start, IdP-initiated sign-in and the SSO callback (before the profile is linked or an account created), every SDK request, the live notification connection, the token exchange when the header is forwarded, the OAuth token endpoint (
/api/v1/auth/oauth2/token, the authorization code, the refresh and the ID-JAG grant) when a connected app or agent gets a token for the person, and the company API. A request is refused with a 403, never a 401: the session is kept, and works again off that network. The token endpoint answers in OAuth's shape,{ "error": "tenant_restricted", "error_description" }, except for an ID-JAG, which gets its grant's genericinvalid_grant, and a refused refresh leaves the refresh token to work off that network. The company's break-glass people and sessions your team opened as one of its people are the company's own, so they get in. - One API host serves every app. On BuildBase cloud your API host is https://api.console.buildbase.app, and so is every other app's, so a company that sets the header for your app sends it to every app built on BuildBase. The header therefore binds an app only when it names one of that app's companies: one that names none of your companies is ignored, and nothing changes for your users. Within your app the rule is strict: once the header names one of your companies, every account that is not one of the named companies' is refused. A company that wants an app blocked outright blocks its domain at the proxy.
- Your own servers are not covered, and neither is your console. Calls your server makes leave from your network and carry no header, and a session your server uses is still refused when the person's browser uses it on the company's network. The company's header reaches your own server only if its proxy adds it on your domain too; when it does, and your server calls BuildBase for that request, pass it on, so the rule holds there as well. Read its lines apart: the Fetch API's
headers.get(), andreq.headersin Node, join a header sent twice into one comma-separated line, so a list someone on the network added beside the proxy's would reach BuildBase as part of one line, naming their companies too, rather than being refused with 400tenant_header_invalid. The SDK'sheadersoption sends one line per name, so refuse such a request yourself. In Node (18.3 and later, Express included),req.headersDistinctkeeps the lines apart:
import type { IncomingMessage } from 'node:http';
import BuildBase from '@buildbase/sdk';
const HEADER = 'allowed-company-ids';
// One client per request, so the header passed on is this request's.
// Null when the header came on more than one line: answer that request with
// a 400 rather than pass on one line that names the companies of both.
export function buildbaseFor(req: IncomingMessage, sessionId: string) {
const lines = req.headersDistinct[HEADER] ?? [];
if (lines.length > 1) return null;
return BuildBase({
serverUrl: process.env.BUILDBASE_SERVER_URL!,
orgId: process.env.BUILDBASE_ORG_ID!,
...(lines.length === 1 ? { headers: { [HEADER]: lines[0] } } : {}),
}).withSession(sessionId);
}Where only the Fetch API's Headers is at hand (a Next.js route handler, an edge function), the lines cannot be told apart. The trade-off there: a header sent twice reaches BuildBase from your server as one list, while the person's own requests to your API host are still refused with the 400.
- Self-hosted. The server reads the header as it arrives. An ingress or load balancer you put in front of it must pass
allowed-company-idsthrough; nginx does by default, since the name has no underscore. See the self-hosted requirements.
A header that comes twice, on two lines, and names one of your companies is refused with 400 tenant_header_invalid, so someone on the network cannot add a list of their own beside the proxy's. In your app, useAccessRefused hears both codes.
Each refusal is in the trail of each of your companies the header names (up to five) as enterprise_tenant_restricted: the door, whose account it was (account: personal or another_company, never which company) and the refused account's full email, with no actor, and the IP address and user agent, the address being the company's own egress. A refusal at the token exchange keeps neither, since its caller is your own server. A refused sign-in is recorded every time; a refused request or live connection once an hour per session and company, and a connected app's or agent's token (door: "oauth_token") once an hour per person and company, since apps refresh on their own. There is no webhook, as for the IP allowlist's refusals: the trail, the log streams and insights carry it.
Your team signing in as the company's people
Your support tooling may open a session as one of your users with the token exchange and an owner or admin API key, and someone on your team may use Sign in as user on the user's page in your console. When that user is one of a company's people, the company sees it, and decides whether it may happen.
Sign in as user follows every rule below as an exchange does. In place of a key's name, the company sees "Sign in as user, from the console" (appKeyId: "console"), never the name of whoever on your team started it, which your own security log keeps. Not allowed refuses it twice: when your team starts it, with 403 app_team_sessions_refused, and when your app's backend turns its code into a session, in case the company changed its mind in between. A read-only session stays read-only, and ends with the window you chose.
- Always recorded. An exchange that names one of a company's people, other than the key's own user, writes
enterprise_app_team_sessionto the company's trail and sendsenterprise.app_team_sessionwithoutcome: "issued". The entry carries the person, the key's name (appKeyName), the workspace, the expiry and yourreason(appTeamReason). The reason ends the entry's row in the activity, after the key's name: "Support tool: Ticket 4812" beside the person in the company's view, and "Support tool, as [email protected]: Ticket 4812" in your console, whose row names the key's creator. The company also reads it in the entry's details in its CSV, the company API, its log streams and, with company audit logs on, WorkOS; the webhook does not carry it. The company sees "the app's team" and the key's name, never the member of your team behind the key, and not your server's address; your console sees the key's creator and the address. Name support keys for what they are, such as "Support tool": the company reads the name. - The company decides. The access policy's Your team may sign in as Globex's people (in your console; the company's admins see it as The app's team signing in as one of your people) is Allowed, and recorded (
allowed, the default) or Not allowed (refused). There is no setting that allows it without a record. Not allowed refuses the exchange with 403app_team_sessions_refusedand a sentence that names the company ("Globex does not let your team sign in as its people. Its admins can change this in their access policy."), mints nothing, writesenterprise_app_team_session_refused, and sends the event withoutcome: "refused". Changing it to Not allowed also ends the sessions your team already has open as the company's people: the next request on each is answered 401session_endedwithreason: "enterprise_session_ended"(when a company ends a session), and the policy'senterprise_policy_updatedentry counts them (appTeamSessionsEnded). When a support case needs it, ask the company's admins to allow it. - Who may change it is whoever may change the policy: your console always, and the company's admins whose role manages the access policy, while Let the company's admins change this in the app is on. Each change is an
enterprise_policy_updatedentry naming the field and anenterprise.policy_updatedevent. A session your team opened as one of the company's people cannot change it, either way, even when that person may change the policy. From such a session no change to the policy goes through at all: every policy save needs a recent sign-in, which a session your team opened never is, so the save is refused with 403reauth_requiredandreverifiable: false("This session was not opened by you signing in, so it cannot confirm it's you. Sign in to the app yourself to make this change."). Behind that, the server still refuses this one setting with 403app_team_self_change("The app's team cannot change whether it may sign in as your people from a session it opened as one of them. Your company's own admins decide this."). Your console can still change it. - Seen and ended like any session. The session is in the person's own sessions list as "Opened by the app's team", in the company's list of that person's sessions, where its admins can end it, and on the user's page in your console with the key's name. It never counts toward the person's session limit, so it cannot sign them out of their own devices.
- What happens inside it says who did it. An entry written with a request made in that session carries
byAppTeam: { appKeyId, appKeyName }, and so do the workspace trail's events, so a role change made as Priya names Priya and adds "by the app's team: Support tool" in the activity, in your console and in the app, rather than reading as Priya's own act. - It cannot confirm it is the person. It never counts as a recent sign-in, so no change that needs confirming it's you goes through from it: the policy, company admins and roles, keys and service accounts, log streams, setup links and passkey resets are each refused with 403
reauth_requiredandreverifiable: false. Make those changes from your console, or ask the company's admins. - A blocked user is refused. An exchange that names a blocked user is refused with 403
user_blocked, in every org, whether or not they belong to a company, so a person the directory deprovisioned cannot be signed in as.
With company audit logs on, these entries reach WorkOS as company.app_team_session and company.app_team_session_refused, with an app_team actor named by the key and the person as the target. An entry written inside such a session reaches WorkOS with the same app_team actor, and the person in its metadata (acting_as_user_id).
Sign-in methods without single sign-on
Require single sign-on is all or nothing, and it is your switch. A company with no single sign-on yet, or whose identity provider does not cover everyone, can still decide how its people get in: the access policy's Sign-in methods without single sign-on lists the ways its people may use when they do not sign in through it. Any of Password, Magic link, Email code, Phone code, Passkey, Google, Microsoft, GitHub, LinkedIn, Apple, Custom OpenID Connect, Ethereum wallet and Solana wallet, down to Passkey alone, which is the phishing-resistant choice. Single sign-on is always allowed and is never on the list. Unset, every way your app offers stays open, as before.
Whoever may change the policy sets it: your console, the company's admins whose role manages the access policy (while Let the company's admins change this in the app is on), and a company API key with policy:write. It is BuildBase's, as required SSO and break-glass are: WorkOS's per-organization sign-in rules belong to AuthKit, its hosted user management, which BuildBase does not use, so BuildBase checks the list at its own doors.
| The account | Signs in with | Result |
|---|---|---|
| One of the company's people, or an account on its verified domains no SSO sign-in has linked yet | A way on the list | In |
| The same | A way not on the list | Refused with sign_in_method_not_allowed, after the credential matched |
| The same, with Passkey on the list and no passkey yet | A way not on the list | Taken to add a passkey (passkey_setup_required), then signed in with it |
| The same, with a list set | A passkey that did not check it was them | Refused with passkey_verification_required |
| A break-glass person, while single sign-on is required | A way on the list (only Passkey, with Only with a passkey) | In, recorded as enterprise_break_glass_used |
| A break-glass person, while single sign-on is required | Any other way | sso_required, as for everyone else: nobody is told they are break-glass |
| Anyone else at the company, while single sign-on is required | Any way but single sign-on | sso_required, as before |
| Anyone signing in through the company's single sign-on | Single sign-on | In |
| Someone outside the company in its workspaces | Anything | Not covered: the outsider policy is theirs |
| A session your team opens as one of its people | The token exchange | Not a sign-in: Your team may sign in as its people decides |
At the door. Every door checks the list only after the credential matched, so a wrong password still reads "Invalid email or password" and nobody learns the company's rule without a credential. Password, magic link and passkey answer sign_in_method_not_allowed with allowed, the ways the company allows, and the sentence "Your company asks its people to sign in with a passkey or Google." On the hosted sign-in page the form then shows only those ways. An app with its own password form gets success: false, reason: "sign_in_method_not_allowed", the sentence and allowed back from the sign-in call. Social sign-in comes back to the login page with "Your company does not allow that way to sign in here. Choose another way, or ask your IT team.", without the list. A sign-up the list refuses, Google's included, is refused before the account is made. Anyone can type any address into the sign-up form, so a sign-up refusal is the plain sentence, without allowed, and is not recorded in the company's trail.
When the rules cannot be read. If the company's policy cannot be read at that moment (a database blip), the door never takes that as "no list": it refuses with 503 policy_unavailable and Retry-After, "We could not check your company's sign-in rules just now. Try again in a moment.", and social sign-in comes back with the same sentence. Accounts no company governs sign in as usual.
Passkeys under the list.
- They must check the person. While a company has a list, a passkey signs in only when the device verified who is holding it: a fingerprint, a face or the device's PIN. A security key with no PIN is refused with
passkey_verification_required("Your company asks for a passkey that checks it is you, with your fingerprint, face or device PIN. Use one that does."). While the list includes Passkey, a passkey being added must verify the person too. Without a list nothing changes. - The first one. Someone with no passkey who signs in their usual way, when the list has Passkey and not their way, is taken straight to Add a passkey to continue on the hosted page, with no Not now. No session is made: the sign-in answers a token that can only set up a first passkey, for 10 minutes (
passkeySetup: true), which/redirectrefuses with 403passkey_setup_required(any other kind of token there is a 401). They add a passkey their device verifies, and the page signs them in with it at once. That works only while they have no passkey on your app's sign-in domain, it is recorded asenterprise_passkey_enrolledwith the way they signed in (setupBy), and the sign-in that sets it up gets the new-device email as any other would. Someone on the company's verified domains who is not one of its people yet joins it by their domain, when the policy says so, at the passkey sign-in that follows, not at the sign-in that set it up. - Later ones. Once one of the company's people has a passkey, adding another needs a passkey sign-in from the last 10 minutes, with or without a list, or the hosted page's registration is refused with 403
passkey_sign_in_required("You already have a passkey. Sign in with it, then add another."), so a stolen password cannot add a passkey to an account that has one, and that passkey could not then confirm it is them. More passkeys are added on the hosted sign-in page. Someone no company governs adds passkeys as before. The new passkey must verify the person, and the addition is recorded, only while the list includes Passkey. - Removing one. For one of the company's people, or under any list, removing a passkey needs a passkey that checked them in the last 10 minutes: from the SDK's Security screen, a passkey sign-in or a passkey confirmation, else 403
reauth_requirednamingpasskey; from the hosted page, a passkey sign-in, else 403passkey_sign_in_required("Sign in with one of your passkeys first, then remove this one."). Two removals at once never take the last passkey: one waits for the other, and one that cannot get its turn is refused with 409passkey_change_in_progress("Another change to your passkeys is in progress. Try again in a moment."). - The last one. While the list includes Passkey, nobody can remove their last passkey, from the SDK's Security screen or the hosted page: 409
last_passkey_required, "Your company asks you to sign in with a passkey. Add another before you remove this one." - A lost one. Reset passkeys, in the person's sessions panel, removes their passkeys for your app and signs them out; they read "Your company signed you out. Sign in again." and their next sign-in sets up a new passkey. Every sign-in token they held from before stops working, so one taken from them can neither open a session nor set up a passkey, and they get an email saying the company reset their passkeys, with no link or code in it. In the console, and in your app for the company's admins whose role manages People, after they confirm it is them, and with a company API key that has
passkeys:write. Recorded asenterprise_passkeys_reset, with the sign-out's ownenterprise_sessions_revoked. Signing one person out stops their earlier sign-in tokens too. - A new sign-in domain. A passkey belongs to the domain it was made on. Moving your sign-in page to a custom domain strands the passkeys made on the old one; under a passkey list, people set up a new one at their next sign-in, as for a first passkey.
- Devices without passkeys. Someone on an old browser or a locked-down device cannot sign in under Passkey alone except by single sign-on. The card says so beside the checkbox, and counts who has a passkey.
Sessions already open stay open, as when single sign-on becomes required. The policy counts the live sessions of the company's people that began a way the list does not allow (outside), and Sign them out ends those: { "outsideMethods": true } on the sign-out routes. A session whose way in was not recorded is neither counted nor ended: one opened before servers recorded sign-in methods (unknown), and a social sign-in opened before servers recorded its provider, which could have been any of Google, Microsoft, GitHub, LinkedIn, Apple or the app's own OpenID Connect provider, and a wallet sign-in whose chain was not recorded.
Guards at save.
- Every way on the list must be turned on for at least one of your auth clients, or the save is refused with 409
sign_in_method_not_offered: "Passkey is not turned on for this app's sign-in. Ask the app's team to turn it on, or choose another way." Turn the method on under Authentication, Methods. - A company admin saving from your app must keep a way they can use. One whose own session began a way the new list refuses gets 409
would_lock_you_outwithreason: "sign_in_method", in one of two cases. When the list includes Passkey and they have none: "You signed in with a password, which this list does not allow, and you have no passkey. Add a passkey first, or keep Password on the list." When the list leaves Passkey out: "You signed in with a password, which this list does not allow. Sign in a way it allows first, or keep Password on the list." An admin who holds a passkey may save a list that keeps Passkey. Your console and company API keys are not held to it, as they are not to the allowlist's guard. - The list names at least one way. "Single sign-on only" is Require single sign-on, your switch.
In the console, the access policy has a Sign-in methods without single sign-on card: a checkbox for each way, a way your app does not offer shown disabled with Turn it on in Sign-in methods, a link to Authentication, Methods, Any way your app offers to clear the list, how many of the company's people have a passkey, and Sign them out with the count of sessions outside the list. With Passkey alone the count reads "31 of 40 people have a passkey; the others set one up at their next sign-in, unless they use single sign-on."; with Passkey among other ways, "31 of 40 people have a passkey; the others set one up if they next sign in a way the list does not allow." The company's admins have the same card on their Single sign-on screen (from SDK 1.0.0), shown only when your server answers the field.
# Console routes: an org API key with the enterprises resource
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"signInMethods":{"allowed":["passkey","google"]}}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/policy"
# Back to every way your app offers
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"signInMethods":null}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/policy"
# End the sessions that began a way the list does not allow
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"outsideMethods":true}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/sign-out"
# A lost passkey (also needs the users resource)
curl -X POST -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/people/<user id>/passkeys/reset"allowed takes password, magic_link, email_code, phone_code, passkey, google, microsoft, github, linkedin, apple, custom_oidc, ethereum_wallet and solana_wallet, each once. The policy read answers the list with what the card shows:
{
"signInMethods": {
"allowed": ["passkey", "google"],
"offered": ["password", "magic_link", "passkey", "google"],
"people": 40,
"withPasskey": 31,
"outside": { "sessions": 6, "people": 4 }
}
}| Field | What it is |
|---|---|
allowed | The list, or null for every way your app offers |
offered | The ways your app's sign-in offers today |
people | The company's people |
withPasskey | How many of them hold a passkey they can sign in with |
outside | Live sessions of its people that began a way not on the list, and how many people they belong to |
A reset answers { removed, sessions }: the passkeys removed and the sessions ended. Someone who is not one of the company's people is 404 not_a_person. The workspace routes are /api/v1/public/workspaces/<workspace id>/enterprise/policy, /sign-out and /people/<user id>/passkeys/reset, the last needing People at Can manage, and the company API's are under policy:write, people:write and, for the reset, passkeys:write (the company API).
Saving the list is enterprise_policy_updated naming signInMethods, and the enterprise.policy_updated event as for every field. Ending the sessions outside it is enterprise_sessions_revoked with scope: "outside_methods" and the enterprise.sessions_revoked event with the same scope. Each refusal at a door is enterprise_sign_in_method_refused in the company's trail, and Insights count both refusals by reason. There is no new webhook event. A server from before this answers no signInMethods, and the SDK then hides the card and Reset passkeys.
Stricter rules for one workspace
A company can hold one of its bound workspaces to stricter rules than its own policy: a finance workspace with shorter sessions, only the office network, Passkey alone or single sign-on required, while the help desk keeps the company's policy. The company's policy is the floor. A workspace's rules are as strict or stricter, field by field, from every door, your console included, and every request into the workspace is held to the stricter of the two, after the company's own checks. Someone a workspace refuses is asked to confirm it's them there, not signed out: the same session keeps working in the company's other workspaces.
| Rule | Stricter means |
|---|---|
| Idle limit | Shorter or equal, measured away from this workspace (any length when the company sets none) |
| Confirm it's them every (session length) | Shorter or equal, from their latest sign-in or confirmation; the company's own maximum still ends the session on time |
| Only from these addresses | Each range inside one of the company's ranges (any list when the company has none). An address must be on both |
| Ways in | Some of the company's ways (or of the ways your app offers when it has no list). Single sign-on always counts |
| Require single sign-on | Only while the company's single sign-on works; break-glass people pass as they pass the company's |
| Third-party apps and agents | Some of the agents the company allows, or none |
| People outside the company | Limited at a lower role, or blocked |
| Your team may sign in as its people | Refused in this workspace |
Who sets them: your console (Stricter rules for one workspace, under the enterprise's access policy), the company's admins whose role manages the access policy, in your app, through any of its bound workspaces and after confirming it's them (removing the rules asks too), and a company API key with policy:write. While Let the company's admins change this in the app is off, the app and keys can read them and change nothing (403 managed_by_app). In your app, from SDK 1.0.0, the section is <EnterpriseWorkspacePolicy />, inside <EnterpriseSecurity />, and useEnterpriseWorkspacePolicies(workspaceId) reads the rules and offers save(targetId, rules) and remove(targetId) for your own screen. One set of rules per workspace, rules for up to 20 workspaces per company, up to 100 ranges and 50 agents each.
Saving. A save replaces the workspace's rules whole and answers how many open sessions they will meet (affected: { sessions, people }, from the ways and sign-in times BuildBase knows). Nothing is ended: each session is held at its next request in that workspace, at once on the server that saved and within a minute on every other. A rule that is not as strict is refused with 422 workspace_policy_not_stricter and each field in fields: "Globex finance's idle limit (2 hours) is longer than Globex's (1 hour). Rules for one workspace can only be stricter.", or "10.0.0.0/8 is not inside Globex's addresses. Each range must be inside one of them." The other answers:
| Answer | When |
|---|---|
400 invalid_workspace_policy | A value out of bounds, or no rule at all: "Choose at least one rule. To go back to the company's policy, remove these rules." |
409 sso_not_usable | Requiring single sign-on while it does not work for the company |
409 sign_in_method_not_offered | A way your app does not offer, when the company has no list |
409 would_lock_you_out | From the app, saved through that same workspace, when the admin's own session would be refused there by the new rules (reason: "workspace_policy", with refusal) |
409 workspace_policies_full | Rules for 20 workspaces already |
409 workspace_policy_changed | expectedUpdatedAt is not the rules' updatedAt: someone saved since the form was read |
404 workspace_not_bound | The workspace is not one of the company's bound workspaces; the answer never says whose it is |
404 workspace_policy_not_found | No rules to read or remove for that workspace |
When the company changes its own policy, a workspace's rules stay. A rule the company now matches reads "same as Globex" (sameAsCompany), and a company list that leaves a workspace no address or no way in is a warning on the company's save and on the rules (warnings: no_address, no_way, sso_not_usable).
Unbinding a workspace removes its rules in the same write. From your console, a save of the bindings that leaves out a workspace with rules is refused with 409 binding_has_workspace_policy ("Globex finance has stricter rules. Removing the binding removes them too.") until it says removeWorkspacePolicies: true, which the console's confirmation sends. A workspace bound again starts with none.
A refusal in the workspace is always a 403, never a 401, so the SDK keeps the session:
{
"error": true,
"code": "workspace_access_refused",
"reason": "sso_required",
"workspaceId": "6710c0ffee0000000000f1a2",
"message": "Globex asks you to sign in with single sign-on to use Globex finance. Confirm it's you to continue.",
"reverifiable": true,
"methods": ["sso"]
}reason is, in the order the rules are checked, app_team_session, app_not_allowed, ip_not_allowed, sso_required, sign_in_method, max_age or idle. The first three cannot be fixed by confirming: the sentence says what to do instead. For the others, reverifiable and methods say whether and how the person can confirm it's them: single sign-on while one of the company's connections signs in one of its verified domains and the auth client their session began on has the Enterprise SSO method attached, and a passkey they hold when the rules allow one. Someone with neither signs in again, and reverifiable is false, so the SDK offers Sign in again rather than a confirmation that cannot start. Refusals are recorded once an hour per session, workspace and reason as enterprise_workspace_access_refused, and only for a member of the workspace: anyone else gets the workspace's own answer, so the rules never tell them the workspace exists or whose it is. If the rules cannot be read, the answer is 503 policy_unavailable with Retry-After, never a pass.
What is held. Every SDK route under the workspace, the company's own settings screens reached through it included; the inbox filtered to the workspace; and, from the unfiltered inbox and its live connection, the workspace's notifications are left out for a session the workspace would refuse. Agents meet its agents, lengths and addresses; your team's sessions meet its addresses and lengths, and an exchange naming the workspace is refused at once (app_team_sessions_refused). Company API keys reach the company's settings, never a workspace's data, and keep the company's own address list. Your console is never held. If your app calls BuildBase from its own server with a person's session, that server's addresses must be on the workspace's list too.
Getting back in. In your app, useWorkspaceAccess(workspaceId) from @buildbase/sdk/react (from SDK 1.0.0) answers { refusal, confirm(), signInAgain(), dismiss() }, and <WorkspaceAccessRefused /> shows the sentence with Confirm it's you or Sign in again; the SDK's workspace settings show it for a refused workspace. confirm() is requireReverification({ workspaceId, purpose: 'workspace' }): the hosted sign-in page says "Confirm it's you to continue in Globex finance", and the confirmation counts in that workspace only (and as a recent sign-in, as any confirmation does). signInAgain() signs out and opens the sign-in page with the notice "Your company asks you to sign in again to keep using one of its workspaces." An SDK from before this release shows the 403's message and keeps the session.
# Console routes: an org API key with the enterprises resource
curl -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/policy/workspaces"
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"idleMinutes":15,"maxSessionHours":8,"ipAllowlist":{"ranges":["10.20.5.0/24"]},"signInMethods":{"allowed":["passkey"]}}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/policy/workspaces/<workspace id>"
# Back to the company's policy
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/policy/workspaces/<workspace id>"The app's routes are /api/v1/public/workspaces/<workspace id>/enterprise/policy/workspaces[/<target id>] and the company API's /api/v1/company/policy/workspaces[/<workspace id>]. The company's policy read answers every workspace's rules in workspaceExceptions. Saving and removing are enterprise_workspace_policy_updated and enterprise_workspace_policy_removed (with reason: "unbound" for an unbinding) and one event, enterprise.workspace_policy_updated, with change (set, updated or removed); enterprise.policy_updated does not fire for them. Refusals have no event. With company audit logs on, they reach WorkOS as company.workspace_policy_updated, company.workspace_policy_removed and company.workspace_access_refused, with the company and the workspace as targets.
Company admins
Until a company names its own admins, anyone who can edit the settings of a workspace bound to it (workspace:settings:edit) manages its security settings in your app: the setup links, the access policy, the trail, log streams, insights, connected apps, and signing people out. That is fine for a company with one workspace and one admin, and wrong for one whose workspace admins are team leads.
Name company admins, and from then on only they manage the company's settings in the app. Other members of a bound workspace keep their workspace role and see who manages the company instead. Up to 20 people are named, all of them the company's own people (signed in through it, provisioned or joined by their email domain, and not blocked).
Each is named with a company role: Company admin, the default, manages everything, and Security admin, Auditor and the company's own roles each manage or view only the areas they name. Roles can also come through the company's own groups. See company groups and roles. Until a Company admin is named, the workspace admins manage everything, whoever else holds a role.
| Named by | How |
|---|---|
| You | Company admins and roles in the enterprise's detail, from its people, with a role |
| The company | Company admins on the Single sign-on screen, by whoever manages the company then. The screen asks first, since naming the first one hands management over |
| The company's identity provider | A role mapping with the target Company admin, from a WorkOS role, an SSO role, an SSO group or a directory group. Publishing Company admin to WorkOS, which only your team can do, adds that mapping for you (see assigning roles in WorkOS). Other roles come through a company group linked to one, or by publishing them |
An admin the identity provider made is the identity provider's: they stay while the mapping matches them, at each sign-in and directory sync, and nobody removes them by hand (409 managed_by_idp). Change the mapping or their groups instead. A person who is blocked or deprovisioned stops being an admin. Removing the last admin hands the company back to the workspace admins, and the screen asks first. While Let the company's admins change this in the app is off, the list is read-only in the app (403 managed_by_app), as the rest of the policy is.
A member who holds no company role gets 403 not_company_admin from every company route, with a sentence to show; one whose role does not reach a route gets 403 company_role_required, naming the area and level. The status the SDK reads says whether the company has named a Company admin, whether this member is one (admins: { named, you }), and what this member may do, area by area (access).
# Console routes: an org API key with the enterprises resource
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"userId":"<user id>","role":"company-admin"}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/admins"
curl -X DELETE -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/admins/<user id>"The workspace routes are /api/v1/public/workspaces/<workspace id>/enterprise/admins (GET and POST) and /admins/<user id> (PATCH to change the role, DELETE). Each change is in the trail as enterprise_admin_added or enterprise_admin_removed, and is a webhook event and workflow trigger:
| Event | When | Payload |
|---|---|---|
enterprise.admin_added | Someone was given a company role, or a different one | enterpriseId, name, userId, email, source (console, app, mapping), role, roleName, and on a change previousRole, previousRoleName |
enterprise.admin_removed | Someone lost their role, by hand or by a mapping | enterpriseId, name, userId, email, source, role, roleName |
Connected apps and agents
People connect third-party apps and AI agents to their account through your app's OAuth server: the agent asks, the person approves on the consent screen, and the agent holds a refresh token that acts for them. A company's security team wants to see those, end them, and decide which may connect at all. On BuildBase an end user holds no API token of their own, so a connected app or agent is what acts for a company's person; your app's own sign-in clients are never limited here.
Seeing and revoking. The enterprise's detail, and the company admins' Single sign-on screen, list each app or agent the company's people have connected: how many people, the scopes they granted, the last grant, and whether the policy still allows it. Open one for its people, and Disconnect one person or Disconnect for everyone: the consent, its refresh tokens and the sessions bound to them end at once, and the app is told, as when a person disconnects it themselves. Recorded as enterprise_app_revoked with how many people.
Which apps may connect. The access policy's Connected apps setting is checked when the app asks the person to approve it (the hosted consent page shows the company's sentence and only a way back), when the code is exchanged, at every refresh and when a company's identity provider approves an agent, so tightening it refuses an agent's next token, and its live sessions end at once (below). A refusal is access_denied at authorization and invalid_grant with the same sentence at the token step, and is recorded as enterprise_app_refused (once an hour per person and app).
Signing people out and directory deprovisioning also remove their consents, so an agent has to ask again rather than reconnect without the person seeing it, and your Application revoke URL is told (enterprise_signout, user_deprovisioned), so the token your app minted ends too. Tightening the policy ends a newly refused agent's live sessions among the company's people at once (enterprise_app_not_allowed), rather than at its next refresh.
Approved by the company's identity provider. With enterprise-managed authorization on, the company's identity provider can approve an agent for its people with no consent screen. Such a grant is listed here like any other, and each app counts how many of its people the identity provider approved (viaIdentityProvider). Disconnecting ends its live access, but the identity provider may approve it again: to keep an agent out, stop allowing it in the policy or in the identity provider.
# Console routes: an org API key with the enterprises resource
curl -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/connected-apps"
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"clientId":"<client id>"}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/connected-apps/revoke"The workspace routes are /api/v1/public/workspaces/<workspace id>/enterprise/connected-apps, /connected-apps/<client id> and /connected-apps/revoke.
Company API keys and service accounts
A company's security team runs tools of its own: a compliance archive that pulls the trail every night, a script that signs a leaver out of every app the moment HR closes the ticket, a SIEM job that checks who connected which agent. Each of those needs to call your app as the company, not as a person, and without your team in the loop.
Company API keys give them that. A key belongs to one company, carries only the scopes it was given (read the trail, read people, sign people out, change the policy, and so on), and calls the company API at https://<your server>/api/v1/company. It is shown once. A key never manages keys, so a leaked one cannot make another.
| Owner | Made by | Stops working when |
|---|---|---|
| A company admin, for themselves | Someone whose role manages API keys, on the Single sign-on screen. Needs the company to have named a Company admin. Its scopes stop at what their roles reach | They hold no company role any more, or are blocked, deactivated or deprovisioned |
| A service account | Whoever manages the company, in the app, where its scopes stop at what their roles reach, or your team in the console | The service account is deleted |
Service accounts are the company's non-human accounts: they need no seat, never sign in, and own keys and nothing else, so a script keeps working when the person who set it up leaves. A company has up to 20, and up to 50 working keys.
Every key lasts 30, 90 or 365 days, or does not expire, and may list the addresses it works from. The company's own IP allowlist, when on, applies to its keys too. While Let the company's admins change this in the app is off, keys still read but every change is refused (403 managed_by_app), as the company's admins are in the app.
A change made with a key is in the trail with via: "api" and the key's name, and the company sees it, since it was the company's own key. Making and revoking keys and service accounts is in the trail too, and enterprise.key_created and enterprise.key_revoked are webhook events and workflow triggers.
Keys are BuildBase's, not WorkOS's. BuildBase issues them and checks every call against its own copy, so they do not appear in your WorkOS dashboard, and they keep working when WorkOS cannot be reached. With company audit logs on, making and revoking a key reaches the company's audit log in WorkOS (company.key_created, company.key_revoked) and its WorkOS log stream like any other entry.
# Console routes: an org API key with the enterprises resource. The console
# makes keys only for a service account.
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"name":"SIEM reader"}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/service-accounts"
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"name":"Nightly trail","serviceAccountId":"<id>","scopes":["audit:read"],"expiresInDays":365}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/keys"The workspace routes are /api/v1/public/workspaces/<workspace id>/enterprise/keys (GET and POST), /keys/<key id> (DELETE), /service-accounts (GET and POST) and /service-accounts/<id> (PATCH and DELETE), for whoever manages the company. The full reference, with every scope and refusal, is the company API.
Confirming it's you
A company admin's session can be weeks old, on a laptop left unlocked on a desk. So before a change to the company's security settings, your app asks the admin to confirm it is them: at their company's identity provider, or with their passkey. It is on for every company, with a fixed window of 10 minutes, and there is nothing to set up.
| In your app | Route, under /api/v1/public/workspaces/<workspace id>/enterprise |
|---|---|
| Changing the access policy, any field | PUT /policy |
| Signing everyone out, or the sessions outside the sign-in methods | POST /sign-out with everyone or outsideMethods |
| Opening a setup link, except the audit log viewer's | POST /setup-link |
| Naming, changing or removing a company admin | POST /admins, PATCH and DELETE /admins/<user id> |
| Making, changing or deleting a company role | POST /roles, PATCH and DELETE /roles/<role id> |
| Making, changing or deleting a group that reaches a company role (its own or through a group that includes it), before or after | POST /groups, PATCH and DELETE /groups/<group id> |
| Making a company API key, or making or changing a service account | POST /keys, POST /service-accounts, PATCH /service-accounts/<id> |
| Adding, changing or removing a log stream, or rotating its signing secret | POST /log-streams, PATCH and DELETE /log-streams/<id>, POST /log-streams/<id>/rotate-secret |
| Resetting a person's passkeys | POST /people/<user id>/passkeys/reset |
| Trusting the company's identity provider to approve agents, or turning it back on | PUT /agent-access/issuer, PATCH /agent-access with status: "active" |
Not asked: reading anything; revoking a key, deleting a service account, disconnecting an app, or turning off, shortening the tokens of or no longer trusting the identity provider that approves agents, which take access away and are often done in a hurry; signing one person out or ending one session; a group that gives workspace access only; requests to join, claims, syncing domains, refreshing WorkOS roles, checking the identity provider's keys for agents, exports and test events. One of the company's people removing one of their own passkeys (DELETE /api/v1/public/passkeys/<id>), and anyone under a company's sign-in methods, is asked too, and confirms with a passkey: the SDK starts that confirmation with methods: ['passkey'].
What counts. A proof counts only for one of the ways in the table below. The admin's sign-in counts when it was one of them: a passkey that checked it was them, or single sign-on that went through the identity provider asked to sign them in afresh. A password, a magic link, a social sign-in, a passkey that did not check them, or an ordinary single sign-on never counts, however recent, so the admin confirms. A confirmation counts only for the way it used: a passkey confirmation does not pass a company whose admins confirm with single sign-on.
The window. Ten minutes from the proof that counts: the sign-in, or the last confirmation, whichever is later. It does not slide: making a change does not extend it. Confirming does not lengthen the session either: the company's maximum session length still counts from when the session began. The check runs after the company role check, so someone without the role is told that first (company_role_required), not asked to confirm.
Which way counts.
| The admin | The company's single sign-on | Confirms with |
|---|---|---|
| One of the company's people | Required, and working | Single sign-on |
| One of its break-glass people, signed in without single sign-on | Required | Their passkey |
| One of the company's people | Working, not required | Single sign-on or their passkey |
| One of the company's people | Not set up, off, or its connection down | Their passkey |
| Not one of its people: a workspace admin managing it before it names a Company admin | Any | Their passkey |
| Anyone, in a session your team, an agent, a connected app or the token exchange opened | Any | Nothing: the session cannot confirm |
"Working" is the test single sign-on itself uses: the enterprise active, enterprise sign-in on, and its connection active.
A passkey confirmation is not held to the company's sign-in methods: the list says how its people sign in, not how its admins confirm it's them, so an admin of a company that allows only passwords still confirms with their passkey. The passkey must still check it is them, and the confirmation is still held to required single sign-on, a blocked account, the IP allowlist and the network. The passkey sign-in answers a token that can finish only this confirmation, for this person: it is never a session or a code, and an ordinary passkey sign-in is held to the list as before.
An admin who must use a passkey and has none on your app's sign-in domain sets one up first, on the same page. The page says they have no passkey yet and offers their usual ways instead, with their email fixed: the company's list when it leaves Passkey out, else every way your app offers, with a password form where your app takes passwords, never single sign-on. That sign-in, held to the list as any sign-in is, answers a token that can only set up a first passkey; they add one their device verifies, recorded as enterprise_passkey_enrolled with setupBy, and confirm with it. That works only while they have no passkey there, as for a first passkey: one on another of their devices counts, and they confirm with it.
What the admin sees.
- They press Save. The server answers 403
reauth_required, with the ways that count and a sentence: "Confirm it's you to change Globex's settings: sign in again at your company's identity provider." In your app (from SDK 1.0.0) the company's screen opens Confirm it's you with that sentence, and keeps the change they had not sent. - They continue to your hosted sign-in page, bound to their session. It reads "Confirm it's you", shows their email fixed, offers only the ways that count (or, with no passkey yet, their usual ways to set one up), skips the trust-this-device and add-a-passkey screens, and has Cancel back to where they were.
- With single sign-on, the page sends them to WorkOS with
prompt=login, which asks their identity provider to sign them in afresh. With a passkey, the passkey must check it is them. - They come back to the same page, in the same session, with
?reverified=1and a one-timereverify_code. Your app completes the code from the browser that started the confirmation, with the code verifier it kept there, and only then is the session confirmed: a code read off the address is worthless anywhere else. Nobody is signed out and no new session is made. The screen puts their change back and says "You confirmed it's you. Save again to apply it." (for an action, such as a sign-out, "Try it again to go ahead."). It never sends the change on its own. They save, and the change goes through.
The confirmation is for the company whose screen asked for it: the app sends the workspace with it, and the company bound to that workspace that the admin may manage decides the ways that count and records it. An admin of two companies confirms for the one they are changing.
The confirmation is held to every rule a sign-in is: the company's IP allowlist and network, a blocked account, and the same person as the session. Signing in as anyone else is refused with wrong_account ("You signed in as someone else. Sign in with the account you are confirming."), and the session keeps its old time. A way that does not count, such as a passkey that did not check them, is refused with reauth_method_not_allowed. A session that ended while they were away answers 401 session_ended, the confirmation is dropped, and the page sends them back to your app, which signs them in again.
What WorkOS does, and what is BuildBase's. WorkOS's part is the fresh sign-in at the identity provider: BuildBase adds prompt=login to the SSO authorization URL, and WorkOS asks the identity provider to sign the person in again, as ForceAuthn="true" for SAML. WorkOS documents that "providers that don't support it are unaffected", and its SSO profile carries no authentication time, so BuildBase records that it asked (forced: true), never that the identity provider signed the person in again. Microsoft Entra ID documents honouring ForceAuthn; other identity providers are unverified. WorkOS's own re-authentication (max_age and auth_time) belongs to AuthKit, which BuildBase does not use. Everything else is BuildBase's: which changes need it, the window, the passkey path, the hosted page and the record.
Your console is not asked in this release: it is your team's tool, on its own sign-in. Company API keys are never asked: a key has no person to sign in again. Making a key or service account in your app needs a confirmation, so a stolen admin session cannot mint one, every act of a key is in the trail under its name, and keys expire and are revoked as before.
Sessions that cannot confirm. A session your team opened as one of the company's people, an agent's or connected app's session, and a session from the token exchange never count as recent and cannot confirm: nobody signed in to them. Their 403 carries reverifiable: false and "This session was not opened by you signing in, so it cannot confirm it's you. Sign in to the app yourself to make this change." A session opened before servers recorded sign-in times is not recent either; the admin confirms once and carries on. A session an agent got through the company's identity provider (agents through a company's identity provider) is not asked at all: any change to the company's settings inside it is refused first, with 403 agent_session_refused, before the company role or the confirmation is checked.
Your own screens. From SDK 1.0.0, useStepUp(scope, { workspaceId }) does what the ready-made screens do: wrap the change in guard(write, draft), and on a 403 reauth_required it opens Confirm it's you (<ConfirmItsYou />), keeping draft; back from confirming, returned holds the change and whether it was confirmed, never sent again by itself. scope names the screen, so each gets back only its own change, and methods: ['passkey'] offers only a passkey.
Your own server. A call your server makes with a member's session (withSession) gets the same 403. Send the member to confirm in their browser with requireReverification (confirming it's you in your app), then make the call again within the 10 minutes.
An app on an older SDK shows the server's sentence in the screen's banner, and cannot confirm: its start sends no code challenge and is refused with invalid_request. Signing out and in helps only an admin who signs in with a passkey that checks it is them. Update the SDK.
What is recorded. Each confirmation is enterprise_reauthenticated in the trail of the company it was for, written when your app completes it, with method (sso or passkey) and forced. The change it allowed carries stepUp: { method, at }, and reads "after confirming" in the activity. A 403 reauth_required is not recorded: it is a prompt, not an attempt. There is no webhook event.
The enterprise's audit trail
Every enterprise keeps its own trail, in the same auth audit log as the rest of your sign-in configuration, and kept for as long as your plan keeps that log (auth-audit-retention-days):
| Entry | Written when |
|---|---|
enterprise_sign_in | Someone signed in through the enterprise, from your login page or the identity provider's dashboard (origin), and whether the account was created |
enterprise_sign_in_refused | A sign-in through the enterprise was refused, with the reason from the table above and the address the identity provider asserted |
enterprise_sso_required_refusal | Someone on the enterprise's domains was sent to SSO at another sign-in method, with the door (password, magic link, social, passkey), and byDomain: true for an account no SSO sign-in had linked yet |
enterprise_user_provisioned, enterprise_user_updated, enterprise_user_deprovisioned | The directory added, changed or removed a person (provisioning), from an event or the daily reconcile |
enterprise_certificate_expiring, enterprise_certificate_renewed | WorkOS reported the certificate expiring, expired or renewed |
enterprise_policy_updated, enterprise_break_glass_used, enterprise_sessions_revoked, enterprise_access_refused | The access policy changed, a break-glass person signed in, people were signed out or one session ended (scope: person, everyone or session, with the device and method for one session, outside_methods, with the methods, for the sessions outside the sign-in methods, or agents when the agent sessions an identity provider approved ended together), or a request came from outside the allowlist (once an hour per session and address) |
enterprise_sign_in_method_refused | Someone was refused at a door for a way the company's sign-in methods do not allow, or for a passkey that did not check them: the door, the method, the reason, the email and the allowed list, with byDomain for an account no SSO sign-in had linked yet and breakGlass for a break-glass person. The actor is the person, or nobody for an account not yet linked |
enterprise_passkey_enrolled, enterprise_passkeys_reset | One of the company's people set up a passkey under its sign-in methods, with setupBy (the way they signed in to set it up); or someone reset a person's passkeys, with removed, sessions and via, beside the sign-out's own enterprise_sessions_revoked |
enterprise_reauthenticated | A company admin confirmed it was them: method (sso or passkey), forced (BuildBase asked the identity provider to sign them in afresh; WorkOS does not say whether it did) and returnOrigin, with the IP address and user agent |
enterprise_tenant_restricted | An account that is not the company's was refused on its network (the company's network), with the door, the account (personal or another_company) and the refused account's full email. No actor; the IP address and user agent are kept, and the address is the company's own egress, except at the token exchange, whose caller is your server |
enterprise_app_team_session, enterprise_app_team_session_refused | Your team opened a session as one of the company's people with the token exchange, or the company's policy refused it (your team signing in), with the person's userId and email, the key's appKeyId and appKeyName, the workspaceId, your appTeamReason, via: "app_team", and expiresIn when a session was issued |
enterprise_sso_roles_applied | The roles or groups a sign-in carried changed someone's role (reason: "sign_in"), or a mappings save did (reason: "mappings"), with what changed |
enterprise_directory_roles_applied | The roles WorkOS assigned a directory person changed their roles, with the slugs, their WorkOS roles and what changed |
enterprise_connection_activated, enterprise_connection_deactivated, enterprise_connection_deleted | WorkOS reported the connection activated, deactivated or deleted |
enterprise_log_stream_created, enterprise_log_stream_updated, enterprise_log_stream_deleted | A log stream was added, changed (with what changed, never a secret) or removed, named in stream |
enterprise_log_stream_failing, enterprise_log_stream_recovered | A log stream failed three times in a row (with the receiver's last status), and its first delivery after that; or WorkOS reported the company's WorkOS stream in error or invalid, and active again (source: "workos") |
enterprise_audit_retention_updated, enterprise_audit_exported | How long WorkOS keeps the company's audit log was raised (from and to, in days), or the trail was exported (exportedVia: workos for WorkOS's export, csv for the CSV, with from and to) |
enterprise_admin_added, enterprise_admin_removed | Someone was given, or lost, a company role, with the source (console, app or mapping), the role and roleName, and on a change the previousRole |
enterprise_group_created, enterprise_group_updated, enterprise_group_deleted, enterprise_role_created, enterprise_role_updated, enterprise_role_deleted | A company group or custom role was made, changed or deleted, with what changed |
enterprise_workos_roles_toggled, enterprise_role_published, enterprise_role_unpublished | Your team turned WorkOS role assignment on or off, or a company role or group was published to WorkOS or taken out of it, with the kind, the slug and via |
enterprise_member_invited, enterprise_invitation_revoked, enterprise_member_added, enterprise_member_role_changed, enterprise_member_removed | Membership of a bound workspace changed: the workspace, the person or invited address, the role (and previousRole), and via (app, console, sso, directory, mapping, group with the group, domain for a join by email domain, or policy for a role the outsider policy lowered) |
enterprise_app_revoked, enterprise_app_refused | A connected app or agent was disconnected for a person or everyone, or refused by the policy, with the app and the step |
enterprise_agent_issuer_added, enterprise_agent_issuer_updated, enterprise_agent_issuer_removed, enterprise_agent_access_granted, enterprise_agent_access_refused | The company's identity provider was trusted to approve agents, changed or no longer trusted, approved an agent for one of its people (once a day per issuer, person, agent and how the person was found, and every time for a match by email), or an agent it approved was refused (once an hour per issuer and reason, and per subject and agent once the signature holds). See the trail |
enterprise_workload_credential_created, enterprise_workload_credential_updated, enterprise_workload_credential_deleted, enterprise_workload_token_issued, enterprise_workload_token_refused | A workload credential was added to one of the company's service accounts, changed or removed (every time, with the live tokens it ended), a CI job or cloud workload exchanged its token for a company API token (every time, with its run), or an exchange was refused (once an hour per credential and reason). See workload identity |
enterprise_key_created, enterprise_key_revoked, enterprise_service_account_created, enterprise_service_account_deleted | A company API key was made or stopped working (with the reason: by hand, its owner no longer a company admin, or its service account deleted), or a service account was made or deleted |
enterprise_person_joined, enterprise_join_requested, enterprise_join_request_declined, enterprise_outsider_refused | Someone joined the company by their email domain (joinedBy: domain, request or claim), asked to join or was declined, or someone outside the company was refused in one of its workspaces (reason). See domains and joining |
enterprise_created, enterprise_updated, enterprise_domain_*, enterprise_setup_link_issued, enterprise_it_contact_invited, enterprise_directory_toggled | The enterprise's setup changed, and who changed it. A domain WorkOS verified or removed has no actor, and enterprise_domain_removed says by whom (removedBy: console or workos) |
An entry shows who, when, the IP address and the user agent. Never the request's headers: the customer's admins read this trail too.
The daily reconcile writes an entry for each person it adds or removes, with source: "reconcile", so a person who joined or left while events were missed still shows up. It writes no entry for the people it re-applies unchanged.
A change made with a company API key carries via: "api", the keyId and the keyName, and keeps its owner when the key is a company admin's own.
A change made in your app after the admin confirmed it was them carries stepUp: { method, at }: how they confirmed and when. Its row in the activity ends with ", after confirming".
An entry written with a request made inside a session your team opened as one of the company's people carries byAppTeam: { appKeyId, appKeyName }, so it is not read as the person's own act. The company's view of enterprise_app_team_session withholds the actor and address, as for every change your team makes, and keeps appKeyName and appTeamReason.
The customer's admins see their own people in full, and not your team: an entry for a change made from your console (adding a domain, issuing a setup link) reaches them with the actor, IP and user agent empty. Sign-ins and refused sign-ins keep them, since that request was the person at their door, and so does a setup or renewal link one of their own admins asked for from your app, and a change made with one of the company's own API keys. The console, the console route and an org API key see every entry in full.
In the console, the enterprise's detail has an Activity section: newest first, filtered by kind (sign-ins, refused sign-ins, provisioning, connection and certificate, roles from sign-in, access policy, the app's team, workspace members, company roles, groups, joining, connected apps, API keys, log streams, audit log, setup changes) and by date, with Show older and Download CSV.
In your app, the people whose company role views the activity (or, until it names a Company admin, members of a bound workspace who can edit its settings) see the same list on their Single sign-on screen, with the same download (from SDK 1.0.0). It is the trail their security team asks for at renewal time, without a ticket to you.
Over the API, for a SIEM or a script:
# Console route: an org API key with the auth resources
curl -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/audit?action=enterprise_sign_in_refused&limit=100"
# The same filter as CSV, at most 50,000 rows a request
curl -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/audit/export?from=2026-10-01T00:00:00Z"action takes a comma-separated list of the entries above, and an unknown one is refused with a 400 rather than ignored. from and to are ISO dates; limit is 1 to 200 (50 by default). An answer is { entries, nextCursor }: pass nextCursor as cursor for the next, older page; it stays right while new entries arrive. The CSV quotes every cell and prefixes ' to one that starts with =, +, -, @, a tab or a carriage return, so a spreadsheet never runs a user agent as a formula. The workspace route is /api/v1/public/workspaces/<workspace id>/enterprise/audit (and /audit/export), with the member's session.
Company audit logs in WorkOS
Everything above is every org's, and it is all a company gets until you turn on company audit logs. Turned on, each company's trail also goes to WorkOS Audit Logs, on the company's WorkOS organization in your WorkOS account, and the company gets what WorkOS builds around it:
- WorkOS's audit log viewer in the Admin Portal, where its IT team reads, filters and exports the trail as you would in the WorkOS dashboard.
- WorkOS's export: a CSV of up to 31 days at a time, from the last three months or as long as WorkOS keeps the log, if less (30 days until you raise retention), as a link that works for ten minutes.
- Longer retention, from 30 days up to ten years, which you set and WorkOS keeps.
- WorkOS log streams to seven kinds of destination, set up by your team (below).
It is off by default because it is a WorkOS add-on, billed to your own WorkOS account: Audit Logs retention, and each log stream per SIEM connection. Read WorkOS pricing before you turn it on. Off, nothing changes: the trail, the Activity screens, the CSV, the company API and BuildBase's own log streams work as described on this page.
Turning it on. The Enterprise tab has a Company audit logs in WorkOS card with the switch, and it applies to every enterprise of the org. Turning it on registers an event schema for each company action in your WorkOS environment first, then starts sending; if WorkOS refuses, the switch stays off and the card shows WorkOS's answer. The card then shows how many schemas are registered and missing, how many events WorkOS refused and how many wait to be sent. Registering again adds any schema that is missing, for example after you save a key from another WorkOS environment.
WorkOS checks each event against its action's schema, and a schema never changes once made. BuildBase therefore registers its own version of each action's schema and names that version on every event it sends. If your WorkOS environment already holds a company.* action of your own under the same name, it keeps yours: BuildBase adds a version beside it and never overwrites one.
Turning it off stops sending at once. WorkOS keeps what it already holds for its retention, and a WorkOS log stream receives nothing new. An entry written while the switch is off is never sent later, and nothing from before you turned it on is sent: a company's audit log in WorkOS starts on the day you turn it on. Each switch is recorded in your org's auth audit log as enterprise_audit_logs_toggled (with enabled), not in any company's trail.
# Console routes: an org API key with the enterprise config resource
curl -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/config/audit-logs"
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"enabled":true}' \
"https://<your server>/api/auth/enterprise/config/audit-logs"
# Check the schemas in WorkOS and register any that are missing
curl -X POST -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/config/audit-logs/schemas"Each answers { enabled, enabledAt, schemas: { registered, missing, conflicts, checkedAt }, failed, pending }. GET /config carries auditLogs: { enabled, enabledAt } too, and the status the SDK reads answers auditLogs: { enabled, retentionDays }.
What goes to WorkOS
Every entry of a company's trail, as the company sees it, and nothing else. The redaction above applies before anything leaves, so an entry your team caused reaches WorkOS without the actor, IP or user agent; your org's own auth log (OAuth clients, auth page edits, the switch itself) never goes. Each entry becomes one WorkOS event:
| WorkOS field | What BuildBase sends |
|---|---|
action | company. and the trail action without its enterprise_ prefix: enterprise_sign_in is company.sign_in. The company sees these names in the portal and its SIEM, so they never carry BuildBase's name, and they are fixed once registered |
actor | A person (user, with their id and name), a company API key (api_key, with its name), your team (app_team): with the details withheld for a change made in your console, or named by the key for a session one of your keys opened as one of the company's people, never by the member behind it, or a job such as the directory reconcile (system) |
targets | The company first (company, with the enterprise's id and name), then what the entry is about, as the table below lists |
context | The IP address, or system for an entry no request made, and the user agent |
metadata | The entry's details, flattened to WorkOS's limits (50 keys, names of 40 characters, values of 500), and entry_id, the trail entry's id. For an entry written inside a session one of your keys opened as one of the company's people, whose actor is the key, the person follows entry_id: acting_as_user_id, and acting_as_email and acting_as_name when the company's view shows them. Never a request header |
occurred_at | When the entry was written |
Events leave from a queue, so a sign-in never waits on WorkOS. At most 1,000 a minute leave for one org: your WorkOS API key's rate limit is shared with sign-ins and the directory pull, and a mass deprovisioning must not crowd them out. WorkOS drops a repeat by its idempotency key, the entry's id. An event WorkOS refuses for its shape is counted on the console's card and not sent again; one WorkOS never confirmed is sent again for up to seven days.
The schema each trail action is registered with: its name in WorkOS and its targets, the company always first. A target is listed only where every entry of that action has one, since WorkOS refuses an event whose targets differ from its schema. Only entries that belong to a company are sent, so four never are and have no schema in WorkOS: the three that record your own setup (your WorkOS provider saved or removed, and the switch itself) and a company's deletion. The table marks them never sent, and WorkOS's export refuses them as filters (400).
| Trail entry | In WorkOS | Targets | What it records |
|---|---|---|---|
enterprise_provider_configured | Never sent | - | The app's WorkOS provider was saved or changed. |
enterprise_provider_removed | Never sent | - | The app's WorkOS provider was removed. |
enterprise_created | company.created | company | The company was set up for single sign-on. |
enterprise_updated | company.updated | company | The company's settings or workspaces changed. |
enterprise_deleted | Never sent | - | The company was deleted. |
enterprise_domain_added | company.domain_added | company, domain | A domain was added to the company. |
enterprise_domain_verified | company.domain_verified | company, domain | One of the company domains was verified. |
enterprise_domain_removed | company.domain_removed | company, domain | A domain was removed from the company. |
enterprise_setup_link_issued | company.setup_link_issued | company | An Admin Portal setup link was issued. |
enterprise_it_contact_invited | company.it_contact_invited | company | The IT admin was invited to set something up. |
enterprise_directory_toggled | company.directory_toggled | company | Provisioning from the directory was turned on or off. |
enterprise_sign_in | company.sign_in | company | Someone signed in through single sign-on. |
enterprise_sign_in_refused | company.sign_in_refused | company | A single sign-on attempt was refused. |
enterprise_sso_required_refusal | company.sso_required_refusal | company | Another sign-in method was refused because the company requires single sign-on. |
enterprise_user_provisioned | company.user_provisioned | company | The directory added a person. |
enterprise_user_updated | company.user_updated | company | The directory changed a person. |
enterprise_user_deprovisioned | company.user_deprovisioned | company | The directory removed a person. |
enterprise_certificate_expiring | company.certificate_expiring | company | The SAML certificate is about to expire, or has. |
enterprise_certificate_renewed | company.certificate_renewed | company | The SAML certificate was renewed. |
enterprise_policy_updated | company.policy_updated | company | The company's access policy changed. |
enterprise_break_glass_used | company.break_glass_used | company | A break-glass person signed in without single sign-on. |
enterprise_sessions_revoked | company.sessions_revoked | company | A person, or everyone, was signed out. |
enterprise_access_refused | company.access_refused | company | A request from outside the company's allowed addresses was refused. |
enterprise_sso_roles_applied | company.sso_roles_applied | company | Roles from a sign-in changed a person's roles. |
enterprise_connection_activated | company.connection_activated | company, connection | The single sign-on connection was activated. |
enterprise_connection_deactivated | company.connection_deactivated | company, connection | The single sign-on connection was deactivated. |
enterprise_connection_deleted | company.connection_deleted | company, connection | The single sign-on connection was deleted. |
enterprise_log_stream_created | company.log_stream_created | company | A log stream to the company SIEM was added. |
enterprise_log_stream_updated | company.log_stream_updated | company | A log stream to the company SIEM was changed. |
enterprise_log_stream_deleted | company.log_stream_deleted | company | A log stream to the company SIEM was removed. |
enterprise_log_stream_failing | company.log_stream_failing | company | A log stream stopped delivering. |
enterprise_log_stream_recovered | company.log_stream_recovered | company | A log stream delivers again. |
enterprise_admin_added | company.admin_added | company, user | A person was given a company role. |
enterprise_admin_removed | company.admin_removed | company, user | A person's company role was taken away. |
enterprise_member_invited | company.member_invited | company, workspace | Someone was invited to one of the company workspaces. |
enterprise_invitation_revoked | company.invitation_revoked | company, workspace | An invitation to one of the company workspaces was withdrawn. |
enterprise_member_added | company.member_added | company, workspace | A person joined one of the company workspaces. |
enterprise_member_role_changed | company.member_role_changed | company, workspace | A person's role in one of the company workspaces changed. |
enterprise_member_removed | company.member_removed | company, workspace | A person was removed from one of the company workspaces. |
enterprise_app_revoked | company.app_revoked | company, app | A connected app's access was revoked. |
enterprise_app_refused | company.app_refused | company, app | The company's policy refused a connected app. |
enterprise_key_created | company.key_created | company | A company API key was created. |
enterprise_key_revoked | company.key_revoked | company | A company API key was revoked. |
enterprise_service_account_created | company.service_account_created | company, service_account | A company service account was created. |
enterprise_service_account_deleted | company.service_account_deleted | company, service_account | A company service account was deleted. |
enterprise_group_created | company.group_created | company, group | A company group was created. |
enterprise_group_updated | company.group_updated | company, group | A company group changed. |
enterprise_group_deleted | company.group_deleted | company, group | A company group was deleted. |
enterprise_group_members_changed | company.group_members_changed | company, group | People were added to or removed from a company group. |
enterprise_group_manager_added | company.group_manager_added | company, group | Someone was named a manager of a company group. |
enterprise_group_manager_removed | company.group_manager_removed | company, group | Someone stopped managing a company group. |
enterprise_role_created | company.role_created | company, role | A custom company role was created. |
enterprise_role_updated | company.role_updated | company, role | A custom company role changed. |
enterprise_role_deleted | company.role_deleted | company, role | A custom company role was deleted. |
enterprise_person_joined | company.person_joined | company | Someone became one of the company people by their email domain. |
enterprise_join_requested | company.join_requested | company, join_request | Someone on a company domain asked to join. |
enterprise_join_request_declined | company.join_request_declined | company, join_request | A request to join the company was declined. |
enterprise_outsider_refused | company.outsider_refused | company | Someone outside the company was refused in one of its workspaces. |
enterprise_directory_roles_applied | company.directory_roles_applied | company | Roles WorkOS assigned on the directory changed a person's roles. |
enterprise_workos_roles_toggled | company.workos_roles_toggled | company | Role assignment by the IT admin in WorkOS was turned on or off. |
enterprise_role_published | company.role_published | company | A company role or group was published for the IT admin to assign. |
enterprise_role_unpublished | company.role_unpublished | company | A company role or group was taken out of WorkOS. |
enterprise_workos_roles_attention | company.workos_roles_attention | company | The company roles in WorkOS started to need attention. |
enterprise_workos_roles_recovered | company.workos_roles_recovered | company | The company roles in WorkOS are back in sync. |
enterprise_tenant_restricted | company.tenant_restricted | company | An account that is not the company's was refused on its network. |
enterprise_app_team_session | company.app_team_session | company, user | The app's team opened a session as one of the company people. |
enterprise_app_team_session_refused | company.app_team_session_refused | company, user | The company's policy refused a session the app's team asked to open as one of its people. |
enterprise_sign_in_method_refused | company.sign_in_method_refused | company, user | A sign-in was refused because the company does not allow that way to sign in. |
enterprise_passkey_enrolled | company.passkey_enrolled | company, user | A person set up a passkey to sign in with. |
enterprise_passkeys_reset | company.passkeys_reset | company, user | A person's passkeys were removed and they were signed out. |
enterprise_reauthenticated | company.reauthenticated | company, user | A person confirmed it was them before a sensitive change. |
enterprise_agent_issuer_added | company.agent_issuer_added | company | The company's identity provider was trusted to approve agents. |
enterprise_agent_issuer_updated | company.agent_issuer_updated | company | Agents through the company's identity provider were turned on or off, or changed. |
enterprise_agent_issuer_removed | company.agent_issuer_removed | company | The company's identity provider is no longer trusted to approve agents. |
enterprise_agent_access_granted | company.agent_access_granted | company, user | The company's identity provider approved an agent for one of its people. |
enterprise_agent_access_refused | company.agent_access_refused | company, app | An agent the company's identity provider approved was refused. |
enterprise_sso_routing_updated | company.sso_routing_updated | company | Which identity provider signs in each email domain was changed. |
enterprise_workload_credential_created | company.workload_credential_created | company, service_account | A workload credential was added to a company service account. |
enterprise_workload_credential_updated | company.workload_credential_updated | company, service_account | A company service account's workload credential was changed, turned on or turned off. |
enterprise_workload_credential_deleted | company.workload_credential_deleted | company, service_account | A company service account's workload credential was removed. |
enterprise_workload_token_issued | company.workload_token_issued | company, service_account | A CI or cloud workload exchanged its token to act as a company service account. |
enterprise_workload_token_refused | company.workload_token_refused | company, service_account | A CI or cloud workload's token exchange was refused. |
enterprise_workspace_policy_updated | company.workspace_policy_updated | company, workspace | Stricter rules for one of the company workspaces were saved. |
enterprise_workspace_policy_removed | company.workspace_policy_removed | company, workspace | Stricter rules for one of the company workspaces were removed, by hand or with its binding. |
enterprise_workspace_access_refused | company.workspace_access_refused | company, workspace | A request of one of the company's people was refused by a workspace's stricter rules. |
enterprise_audit_logs_toggled | Never sent | - | Company audit logs in WorkOS were turned on or off. |
enterprise_audit_retention_updated | company.audit_retention_updated | company | How long WorkOS keeps the audit log was raised. |
enterprise_audit_exported | company.audit_exported | company | The audit log was exported. |
How long WorkOS keeps it
WorkOS keeps a company's audit log for 30 days unless you raise it. With company audit logs on, the enterprise's Activity section says how long WorkOS keeps it and offers 1, 3 or 6 months, or 1, 2, 3, 5, 7 or 10 years. Raising it is one way: WorkOS does not support lowering a retention, so the console asks before it saves, and a shorter value is refused with 422 retention_cannot_shrink. WorkOS bills retention to your WorkOS account. You choose it, not the company: its admins see it on their Single sign-on screen. Each change is in the trail as enterprise_audit_retention_updated.
BuildBase's own copy, which the console, the app's Activity screen, the CSV, the company API and insights read, stays for your plan's auth-audit-retention-days whatever WorkOS keeps.
# Console route: an org API key with the enterprises resource. 30, 90, 180, 365, 730, 1095, 1825, 2555 or 3650 days
curl -X PUT -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"retentionDays":365}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/audit-retention"Two exports, and when to use which
| Detail | WorkOS's export | The CSV |
|---|---|---|
| Needs | Company audit logs on | Nothing: every org has it |
| Covers | Up to 31 days at a time, inside the last three months and what WorkOS keeps | Any range your plan's auth-audit-retention-days keeps, up to 365 days |
| You get | A link to WorkOS's CSV that works for ten minutes | The file, at most 50,000 rows a request, formula-guarded |
| Actions | The company.* names | The trail's enterprise_* names |
| Asked by | The company's IT team in the Admin Portal, its admins in your app, its tools with the company API, your console | Its admins in your app, its tools with the company API, your console |
Use WorkOS's when the company's IT team already works in the Admin Portal, or keeps its records in WorkOS's format. Use the CSV for anything older than WorkOS exports (three months, or less while WorkOS keeps the company's log for less), for more than 31 days in one file, and for a script that pulls the trail every night: it needs no switch and no second request.
In the console, the enterprise's Activity keeps Download CSV. With company audit logs on it adds Export from WorkOS, which takes a range of up to 31 days, from the last three months or as long as WorkOS keeps the log, if less, waits while WorkOS prepares the file, then shows Download the WorkOS export, a button that opens the file in a new tab for ten minutes (the link is never in the page, so analytics that record outbound links never see it), and Open in WorkOS, the Admin Portal's audit log viewer for your team. In your app (from SDK 1.0.0), the company's Activity has Export from WorkOS and Open the audit log viewer beside Download CSV, for the people whose company role views the activity, with a line saying which covers what.
Over the API, the export is two calls: ask, then read until it is ready.
# Ask WorkOS for an export: refused sign-ins in the first half of October
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"from":"2026-10-01T00:00:00Z","to":"2026-10-15T00:00:00Z","actions":["enterprise_sign_in_refused"]}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/audit/exports"
# Read it until state is "ready", then fetch url within ten minutes
curl -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/audit/exports/<export id>"from, to and actions are optional, and with no range the export covers the last 31 days. actions takes the trail's action names, as the CSV's action does. The first call answers { id, state: "pending", rangeStart, rangeEnd }; the second answers the same with state pending, ready with a url, or error, in which case ask for the export again. The url works for ten minutes, and reading the export again gives a fresh one. It is answered with Cache-Control: no-store, and BuildBase never logs or stores it. An export id works for an hour, and only for the company that asked for it: another company's id is a 404.
A range longer than 31 days, or starting before the last three months or before what WorkOS keeps, is refused with 422 export_window, whose earliest is the oldest from WorkOS will take (and maxDays is 31 when the range was too long); the CSV covers the rest. A from up to a day before earliest is read as earliest, so a date picked at the edge of the window still fits, and a to alone covers the 31 days before it. A range WorkOS holds nothing in is 422 export_empty, and either call while company audit logs are off is 409 audit_logs_off. Each export is in the trail as enterprise_audit_exported with via: "workos", and each CSV download with via: "csv".
The workspace routes are /api/v1/public/workspaces/<workspace id>/enterprise/audit/exports and /audit/exports/<export id>, needing the company role that views the activity, and the company API has the same pair under audit:read (the company API).
The audit log viewer in the Admin Portal
WorkOS's viewer opens from a setup link with the audit_logs intent: Open in WorkOS in the console, or Open the audit log viewer in your app for the people whose company role views the activity. It is a five-minute link, like the other setup links; in the portal the company's IT team views and exports the trail as you would in the WorkOS dashboard. WorkOS's invitation email cannot carry it, so an IT admin who is not one of your app's users opens it with you on a call. While company audit logs are off, the link is refused with 409 audit_logs_off.
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"intent":"audit_logs"}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/setup-link"Log streams to the company's SIEM
A security team wants the trail in its own SIEM, not in another screen to check. Who sends it depends on whether you turned on company audit logs:
| Detail | Company audit logs off (the default) | Company audit logs on |
|---|---|---|
| Sent by | BuildBase, from your server | WorkOS, from its own addresses |
| Destinations | HTTPS endpoint, Datadog, Splunk HTTP Event Collector | Datadog, Splunk, AWS S3, Google Cloud Storage, Microsoft Sentinel, Snowflake, Generic HTTPS |
| Set up by | Your team in the console, or the company's admins in your app | Your team in the console, or the company's IT admin from the WorkOS invitation you send |
| Per company | Up to three | The stream WorkOS reports for the company |
| What arrives | BuildBase's payload; an HTTPS request is signed as webhooks are | WorkOS's payload for each destination, with no BuildBase signature |
| While the SIEM is out | Everything waits, and goes in order once it answers, as far back as the trail is kept | Once the stream is fixed, WorkOS sends what it still keeps: 30 days unless you raised its retention |
| Billed | Nothing on your WorkOS account | Per SIEM connection, to your WorkOS account (WorkOS pricing) |
Streams sent by BuildBase
Each enterprise can have up to three, which send every trail entry as it is written:
| Destination | What it needs | What arrives |
|---|---|---|
| HTTPS endpoint | A URL, and optionally an Authorization header to send | { event: "enterprise.audit", timestamp, data: { enterprise, events } }, signed as webhooks are |
| Datadog | The company's site (us1, us3, us5, eu1, ap1, ap2, us1-fed) and an API key | Log records on the site's logs intake, ddsource: buildbase, tagged enterprise_id and action, the entry under buildbase |
| Splunk HTTP Event Collector | The collector's URL (https://<host>:8088/services/collector/event), a HEC token, and optionally an index | One HEC event a line, sourcetype: buildbase:enterprise:audit, time in epoch seconds |
An HTTPS request carries the three headers a webhook does: x-buildbase-signature (sha256= and the HMAC-SHA256 of <x-buildbase-timestamp>.<body>), x-buildbase-timestamp and x-buildbase-event: enterprise.audit, so the receiver verifies both the same way. BuildBase makes the signing secret and shows it once, when the stream is added and when it is rotated. A Datadog key, a HEC token, the Authorization header and the URL itself (which can carry a token) are encrypted at rest and never shown again, only a preview.
What is sent is what the company's admins see in their own trail: their people in full, and an entry your team caused with the actor, IP and user agent empty. The destination is theirs, so your team never reaches it. A stream sends from the moment it is added, never the history; the CSV export is for that.
Delivery is in order and at least once. Each event carries its trail id; a receiver that sees one twice drops it. A sign-in reaches the receiver within about ten seconds. When a receiver does not answer within ten seconds, or answers anything but a 2xx, the stream stays where it was and tries again after 1, 2, 4, 8, 16 and 32 minutes, then every hour until it answers. Nothing is dropped while it fails: the entries wait, and go in order once it answers. The third failure in a row marks it Failing (when a stream fails).
- Its state. Each stream shows what was delivered and when, how many entries wait, its last error with the receiver's status and when it tries next. Send a test event posts one event (
action: "enterprise_log_stream_test") and says at once what the receiver answered; it is not a trail entry and changes nothing else. Pause holds entries until Resume, which sends what waited. - Credentials stay put. A stream's token and
Authorizationheader never follow it to a new address by themselves: changing the URL of a Splunk stream, or of an HTTPS stream with a header, asks for the token or the header again (400invalid_log_streamwithfield), so whoever may edit a stream cannot point it at a host of their own and read a credential that is never shown back. The HTTPS signing secret does follow, since a signature does not reveal it. Deleting an enterprise deletes its streams. - Public addresses only. On the hosted service a stream's URL must be HTTPS and reach a public address, checked when it is saved and again on every request, with no redirects followed. A self-hosted server may send to a private address over HTTP, as its webhooks may.
In the console, the enterprise's detail has a Log streams section below the activity. In your app, members who can edit a bound workspace's settings manage them on the Single sign-on screen (from SDK 1.0.0), unless you turned off Let the company's admins change this in the app in the access policy, which keeps the streams yours to change: they see them read-only. Over the API:
# Add a Splunk stream (console route, org API key)
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/log-streams" \
-d '{"name":"Acme Splunk","kind":"splunk","url":"https://splunk.acme.com:8088/services/collector/event","secret":"<HEC token>","index":"security"}'
# Check it, pause it, remove it
curl -X POST ".../log-streams/<stream id>/test"
curl -X PATCH ".../log-streams/<stream id>" -d '{"enabled":false}'
curl -X DELETE ".../log-streams/<stream id>"GET .../log-streams lists them; POST .../log-streams/<stream id>/rotate-secret answers a new signing secret for an HTTPS stream. A refusal names its field: 400 invalid_log_stream with field (url, site, secret, name), 409 log_stream_limit past three, 403 managed_by_app from the app while your team keeps them. The workspace routes are /api/v1/public/workspaces/<workspace id>/enterprise/log-streams, with the member's session.
While company audit logs are on, adding one is refused with 409 use_workos_log_streams, from the console, the app and the company API alike: a new stream is WorkOS's. Streams made before keep delivering, and through the API can still be changed, paused, tested, rotated and deleted, so a company moves when it is ready. The console keeps every one of those. Your app (from SDK 1.0.0) offers sending a test event, rotating an HTTPS stream's signing secret, so one that leaked can still be replaced, and removing; the company's admins pause or change one through the API.
WorkOS log streams
With company audit logs on, a company's new stream is WorkOS's. WorkOS streams only its own Audit Log events, so a WorkOS stream carries what reached WorkOS after you turned company audit logs on.
Setting one up is your team's. Each stream is billed per SIEM connection to your WorkOS account, so it starts from your console. The enterprise's Log streams section shows a WorkOS card on top, with:
- Set up in WorkOS, or Change it in WorkOS once there is one: a five-minute link to the Admin Portal's log streams step, for when you set it up with the company's IT admin on a call.
- Invite the IT admin with Log streams ticked: WorkOS emails them, and they choose the destination and enter their SIEM's credentials in the portal. Those credentials stay in WorkOS: neither you nor BuildBase sees them.
Your app does not open it. A setup link for log_streams asked for from the app is refused with 403 app_team_only, and the company's admins see the stream on their Single sign-on screen read only: its destination, its state and its last delivery. When they want one, they ask you, and you send the invitation.
Its state comes from WorkOS: active, inactive, error (an error WorkOS does not retry) or invalid (the destination refused the stream's credentials or configuration). WorkOS never moves a stream from error or invalid back to active on its own. Whoever set it up fixes it in WorkOS: your team, from Change it in WorkOS, with the company's IT admin when the destination's credentials changed. WorkOS then sends what it still keeps from the gap. Screens read it at most once a minute, and BuildBase reads it every hour to send the events below.
What arrives is WorkOS's format for each destination, documented on WorkOS's log streams page. BuildBase does not sign it; on a Generic HTTPS stream, set the authentication headers WorkOS offers. Each event carries the trail entry's id as metadata.entry_id. WorkOS sends from a fixed set of IP addresses, listed on the same page: a company that allowlists the senders to its SIEM adds those, not your server's.
# The company's WorkOS stream, or null, and how long WorkOS keeps its log
curl -H "Authorization: Bearer $API_KEY" \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/log-stream"
# A five-minute link to the portal's log streams step, for your team
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"intent":"log_streams"}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/setup-link"
# Or WorkOS's email to the company's IT admin
curl -X POST -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"email":"[email protected]","intents":["log_streams"]}' \
"https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/invite"The read answers { stream, retentionDays }, where stream is null until one is set up, then its type, state, lastSyncedAt and createdAt. All three are refused with 409 audit_logs_off while company audit logs are off. The workspace route is /api/v1/public/workspaces/<workspace id>/enterprise/log-stream, needing the company role that views log streams, and the company API's is GET /log-stream under streams:read.
Moving a company to a WorkOS stream
Turning company audit logs on does not touch a company's BuildBase streams: they keep delivering until someone deletes them.
- Turn company audit logs on.
- Set up the WorkOS stream to the same SIEM, or invite the company's IT admin to.
- While both run, the SIEM gets each entry twice, in two formats. Drop the repeat: a WorkOS event's
metadata.entry_idis theidof the BuildBase event for the same entry. - Once the WorkOS stream is
active, delete the BuildBase stream. The console lists those under Streams sent by BuildBase, with that note.
What changes for the receiver: WorkOS's payload in place of BuildBase's, no x-buildbase-signature, company.* action names in place of enterprise_*, and a backlog bounded by WorkOS's retention rather than the trail's.
Turning company audit logs off again leaves the WorkOS stream set up in WorkOS, receiving nothing new, and lets the company add BuildBase streams again.
When a stream fails
Both kinds say so the same way: an entry in the company's trail, and a webhook event and workflow trigger whose source says which kind.
| Event | Payload |
|---|---|
enterprise.log_stream_failing | source (buildbase or workos), enterpriseId and name (the enterprise). A BuildBase stream adds streamId, stream (its name), kind, failures, status (the receiver's last answer, or null) and message; a WorkOS stream adds streamId (WorkOS's id), type and state |
enterprise.log_stream_recovered | The same, for a BuildBase stream's first delivery after the spell, or a WorkOS stream back to active |
A BuildBase stream is failing after its third failure in a row; a WorkOS stream as soon as BuildBase's hourly read finds it in error or invalid. The first read of a WorkOS stream records its state without an event, and an unchanged state sends nothing. Page someone on enterprise.log_stream_failing.
Insights
The enterprise's detail has an Insights section above the activity, and the company's admins have the same on their Single sign-on screen (from SDK 1.0.0). Over 7, 30 or 90 days, inside your plan's audit retention:
| Card | What it counts |
|---|---|
| People | The enterprise's people, how many signed in inside the window and how many did not, deactivated, and how many came from the directory |
| Sign-ins | Sign-ins, the people they were, first sign-ins, those started from the identity provider's dashboard, and break-glass sign-ins |
| Refused | Refused sign-ins by reason (another sign-in method tried while single sign-on is required counts as other_door; a way the company's sign-in methods do not allow, and a passkey that did not check the person, count as sign_in_method_not_allowed and passkey_verification_required), the addresses refused most, requests refused for their address, and sign-ins and requests refused on the company's network (refusals.network) |
| Day by day | Sign-ins and refusals for each day |
| Health | The connection, the certificate, the directory and each log stream, BuildBase's and, with company audit logs on, WorkOS's, as they stand |
They are read from the trail, so they cover what the trail keeps. GET .../enterprises/<enterprise id>/insights?days=30 answers the same in JSON (it needs the audit log's read as well as the enterprise's), and /api/v1/public/workspaces/<workspace id>/enterprise/insights?days=30 for the app. The JSON also counts the sessions your team opened as the company's people and the attempts its policy refused, as appTeam: { sessions, refused }.
Removing it
Delete an enterprise from its detail: its WorkOS organization and connection are deleted, and its people keep their accounts and sign in another way. The WorkOS provider can only be removed once no enterprise is left.