Enterprise Single Sign-On Administration: identity provider, email domains, configuration check
Enterprise Single Sign-On (SSO) lets an organization delegate authentication to its own identity provider. An org administrator connects one identity provider to the org, proves ownership of the email domains that provider speaks for, runs a configuration check, and then offers SSO to the org's users.
This page covers the administrative configuration surface only — reading, writing, checking and removing an org's SSO configuration, and claiming, verifying and releasing its email domains. The sign-in flow the configuration switches on is summarised under How a User Signs In below; its endpoint-by-endpoint reference — parameters, response fields and error tables — lives in the Auth reference, under Enterprise SSO Sign-In. Directory provisioning (SCIM) is documented separately.
Enterprise SSO is not the personal Google / Microsoft sign-in available to any individual account. That is a per-user convenience that needs no org configuration. Enterprise SSO is org-wide policy, owned by an administrator, and scoped to email domains the org has proven it controls.
On the dedicated API hosts (api.fast.io) callhttps://api.fast.io/current/org/{org_id}/sso/with no/apiprefix. From any other hostname (for examplego.fast.io), include the/apiprefix:https://go.fast.io/api/current/org/{org_id}/sso/.
{org_id} accepts a 19-digit numeric org ID or the org's domain name, exactly as elsewhere in the Organizations reference.
Who Can Call These Endpoints
Every endpoint on this page requires org administrator authority (admin or owner) on {org_id}. A member or view-level caller is refused, and so is a credential that is not admin-capable — see Scope errors in the Auth reference for what "admin-capable" means for API keys and OAuth tokens.
Reading the configuration is always allowed for an administrator. Writing is additionally plan-gated (below).
Enterprise Plan Requirement
Enterprise SSO is an Enterprise plan feature. Configuration writes on a lower plan are refused with reason = plan_required.
Actions that reduce exposure always work, on every plan:
| Action | Plan-gated? |
|---|---|
Read the configuration (GET .../sso/) | No |
Enable SSO, change protocol, credentials or mapping (POST .../sso/) | Yes |
Run the configuration check (POST .../sso/test/) | Yes |
Claim a domain (POST .../sso/domains/) | Yes |
Verify a domain (POST .../sso/domains/{domain}/verify/) | Yes |
Step the mode down, including turning SSO off (mode alone, to a lower mode) | No |
Delete the configuration (POST .../sso/delete/) | No |
Release a domain (POST .../sso/domains/{domain}/delete/) | No |
The asymmetry is deliberate: a plan change must never trap an org in a login configuration it can no longer manage. If an org moves off the Enterprise plan, an existing configuration keeps working and keeps being manageable downward — an administrator can always step the mode down, drop the configuration, or release a domain — while new enablement and new domain claims are refused until the plan is restored.
Detect availability before you offer the feature. Read capabilities.sso on GET /current/org/{org_id}/details/ rather than inferring availability from a refused write.
The Setup Order
The steps are ordered because each one gates the next:
- Claim an email domain —
POST /current/org/{org_id}/sso/domains/returns a TXT record to publish. - Publish the TXT record in DNS, then verify it —
POST /current/org/{org_id}/sso/domains/{domain}/verify/. - Configure the identity provider —
POST /current/org/{org_id}/sso/with the protocol, display name and the provider's own values. Paste the returnedspURLs into the identity provider at the same time. - Run the configuration check —
POST /current/org/{org_id}/sso/test/. This setstestedtotruefor the configuration as it stands right now. - Run a test sign-in — the same endpoint with
mode: "signin", whilemodeis stilloff. It completes the real protocol and creates nothing. See The Test Sign-In. - Choose a mode —
POST /current/org/{org_id}/sso/withmode=optional, thenmode=requiredwhen the organization is ready to enforce (see Enforcement).
Connecting an Identity Provider below walks the same order in full, with per-vendor notes for Okta, Microsoft Entra ID, Google Workspace and JumpCloud.
Steps 1-2 and step 3 can be done in either order; a configuration can be written before any domain is verified. What cannot be skipped is the check before enforcement: mode=required needs at least one verified domain and a successful configuration check (see Enforcement).
Modes
mode is the single switch that says what SSO does for this org.
| Mode | Meaning |
|---|---|
off | Configured but not offered. Nothing about sign-in changes for anyone. This is the mode a new configuration starts in, and the mode a deleted configuration returns to. |
optional | Users in a verified domain may sign in with SSO. Existing sign-in methods keep working. |
required | Users in a verified domain must sign in with SSO. Password and social sign-in, signup, password reset, setting or rotating a password, changing an email address and creating a new API key are all refused for those addresses; the organization's owner, its admins, and any address on the exception list keep a break-glass password path. Needs at least one verified domain and a successful configuration check. See Enforcement. |
required is the only mode that takes something away, so it is gated on the way in: read enforcement_available on the read response to know whether it may be offered at all, rather than probing with a write. Enforcement below has the prerequisites, what users on a verified domain experience, the break-glass path for the owner and the admins, and how to step back down.
Enforcement (required Mode)
mode=required tells Fastio that every email address on this organization's verified domains must authenticate through the organization's identity provider. It is the only mode that takes something away from users, so the platform gates it on the way in and always leaves a way back out.
Prerequisites — checked on the write:
| Requirement | Refusal when it is missing |
|---|---|
| The Enterprise plan | plan_required |
| At least one verified domain | domains_unverified |
A successful configuration check for the configuration as it stands now (tested: true) | test_required |
Read enforcement_available — do not work the rule out for yourself. The read response carries enforcement_available, a boolean saying whether required may be selected for this organization right now. It is computed on the server from the same rule the refusal uses, so the control you draw and the server that answers it cannot disagree. Read that field to decide whether to offer the required option; do not probe with a write.
What changes for users on a verified domain. Password sign-in, signup, the personal Google / Microsoft sign-in, password reset, setting or rotating a password, changing an email address, and creating a new API key all refuse with 403 and reason = sso_required, carrying the organization's domain and the start_path to sign in through instead. The refusal is identical for every address on the domain: it does not say whether an account exists, nor whether that account is exempt, so it cannot be used to map who works there or who still holds a password. The endpoint-by-endpoint detail is in the Auth reference, under Enterprise SSO Enforcement.
Existing access is not withdrawn by the gate itself. API keys already issued keep working — the gate refuses the creation of new ones. (This describes the login-time gate alone. A separate, request-time check — Credential-request enforcement below — does reach already-issued credentials.)
Switching to required revokes web sessions. Every user on the organization's verified domains loses their existing web sessions — except the exempt ones — gets a 401 on their next call, and signs back in through the identity provider. The same happens when a new domain is verified while the mode is already required, when a user's address moves onto an enforced domain, when an administrator loses their admin role, when ownership of the organization is transferred away — the account that used to own it — and when an address is taken off the exception list. Expect a burst of re-authentication immediately after the switch, and warn the organization's users before you throw it.
Break-glass: the owner, the admins, and any address on the exception list keep a password path, so an administrator can never be locked out of the console they administer:
GET /current/user/auth/?break_glass=true— the ordinary password check runs first, and only a correct password is then tested for owner, admin, or an address on the organization's exception list. A non-exempt account gets the identical403sso_required. Unlike the default sign-in surface, failures here count toward the ordinary sign-in lockout.POST /current/user/email/reset/?break_glass=true— always answers202, exactly like a reset request for an address the platform does not recognize. The email is sent only when the account is the organization's owner, an admin, or an address on the exception list, and the response never reveals which.
Enforcement keys on the organization's verified domains, and on nothing else. An address that is not on one of those domains is untouched by required, whatever its relationship to the organization: a member whose address sits on a domain the organization has not verified keeps every ordinary sign-in route. The rule follows the address, not the membership — which cuts both ways. Somebody who works at the company and holds only a guest share on a verified domain address is enforced; a contractor who belongs to the organization on their own domain is not.
Two things exempt an account: a role, and a list.
- By role — the organization's owner and its administrators, always. An administrator who loses the role loses the password path, and their existing sessions, at the same moment — unless their address is also on the exception list, which exempts them on its own and keeps both.
- By list —
enforcement.exceptions, up to 100 addresses an administrator names on the configuration. A listed address is exempt exactly like an administrator: on the same surfaces, and on no others.
An exemption reaches exactly three surfaces, and every one of them authenticates the caller before anything can differ: the break-glass password sign-in; setting or rotating the account password, whether by spending a reset code that was mailed to the account or by changing an existing password from a signed-in session; and creating a new API key. On the other five enforced surfaces — the ordinary password sign-in, signup, the personal Google / Microsoft sign-in, requesting a password reset, and changing an email address — a listed address gets the byte-identical sso_required refusal every other address on the domain gets. A listed user reaches a first password only through break-glass: requesting an ordinary reset code stays refused, so the only code they can be mailed is the break-glass one. Once they hold a password they can rotate it, which is why the rotation surface is exempt — an account allowed to keep a credential must be able to change it.
An entry whose address is not on a verified domain does nothing. It is stored, it is returned, and it never applies — there was nothing for it to be exempt from. It is not an error and it is not a back door: the exception list cannot admit an outside collaborator, because enforcement never reached them in the first place.
Addresses are canonicalised on the way in — lowercased, with plus-extras stripped, exactly as sign-in canonicalises the address it is given. alice+it@acme.com and alice@acme.com are therefore one entry. Read the list back after a write and render what comes back; the stored form is the canonical one.
Removing an address while the organization is enforcing revokes that user's sessions, in the same request that saves the shortened list. They get a 401 on their next call and sign back in through the identity provider. Removing an address the organization does not actually enforce — the owner, an administrator, an address on somebody else's domain, an address with no Fastio account — is a no-op, not a failure.
Stepping back down is never plan-gated. A request whose only field is mode, moving to optional or off, is accepted on any plan, so an organization that leaves the Enterprise plan can always stop enforcing. Releasing the last verified domain while the organization is enforcing is refused with last_verified_domain — step the mode down first.
If the platform cannot determine an organization's enforcement state, the affected endpoints answer with a retryable, temporarily-unavailable error rather than letting a password through. Retrying is the correct response; treating it as "not enforced" is not. Folding an outage into "no enforcement" would, during an incident, invite exactly the password sign-ins that enforcement exists to prevent.
Credential-request enforcement
required mode also reaches API keys, OAuth grants, and other scoped credentials — not just interactive sign-in — on every authenticated request that resolves to a governing organization, not only at credential creation. This is a separate, request-time check layered on top of the login-time gate above: an existing key or OAuth grant, minted before enforcement was ever turned on, is checked on every call it makes.
What is checked. The credential's owner — the account the key or grant belongs to — is looked up against this organization's SSO configuration. If the org's mode is not required, this check does not run at all: no domain lookup, no cost. If it is required, the owner's email domain is resolved and compared against this org's verified domains; a match that is not exempt (see Two things exempt an account, above — the same owner/admin/exception-list rule) is refused.
Refusal. 403 with params.reason = credential_policy_sso, and params.org.domain naming the enforcing organization. This is a distinct reason from the login-time sso_required above — the refusal never carries start_path, since there is no sign-in redirect to offer mid-request, so it never collides with the login-redirect payload. See Org Credential Policy in the Auth reference for the full refusal-reason table (credential_policy_mode, credential_policy_scope, credential_policy_sso).
Provenance is not proven. A scoped credential carries no evidence of how its owner signed in. A user who authenticated through the identity provider and then uses a first-party app, agent, or MCP token in this organization is still refused if their own account is enforced and not exempt — signing in through SSO earlier does not attach to the credential. There is no first-party bypass.
A lookup failure is 503, never a silent pass and never a 401. If the platform cannot determine this organization's SSO mode or domain match, the request answers 503 (temporarily unavailable) — retryable, and preserved through deferred and two-stage authorisation flows rather than collapsing into a generic authentication failure. Folding an outage into "not enforced" would let through exactly the credentials this check exists to constrain, during an incident.
What is not covered. A public File Share single-file link carries no owning API key and no account session and is not evaluated by this check — an enforcing organization's link recipients are unaffected. Three collection endpoints — listing an account's own orgs and shares in bulk — resolve no governing organization per row and are outside this check; a small number of late-loaded cloud write-back endpoints are checked, if at all, at their own endpoint rather than on this shared path. None of these are silent gaps — they are documented limits, not undiscovered ones.
Protocols
An org configures exactly one identity provider, under exactly one protocol.
oidc — OpenID Connect
| Field | Direction | Description |
|---|---|---|
| oidc.issuer_url | read/write | The provider's issuer URL. The configuration check fetches its discovery document from here. |
| oidc.client_id | read/write | The client ID the provider issued for Fastio. |
| oidc.client_secret | write-only | The client secret. Never returned. |
| oidc.client_secret_set | read-only | true once a secret has been stored. This is how a client shows "a secret is configured" without ever reading it. |
| oidc.groups_claim | read/write | The claim in the provider's token that carries the user's group names. Used by role mapping. |
Requested scopes are fixed by Fastio and are not configurable.
saml — SAML 2.0
| Field | Direction | Description |
|---|---|---|
| saml.idp_entity_id | read/write | The identity provider's entity ID. |
| saml.idp_sso_url | read/write | The identity provider's sign-on URL. |
| saml.idp_certificate | write-only | The signing certificate, PEM encoded. Never returned. |
| saml.idp_certificate_fingerprint | read-only | Fingerprint of the stored certificate — enough to confirm which certificate is loaded. |
| saml.idp_certificate_not_after | read-only | When that certificate expires, so a client can warn before it does. |
| saml.idp_certificate_rollover | write-only | An optional second certificate, for a rollover window where the provider may sign with either. |
| saml.idp_certificate_rollover_fingerprint | read-only | Fingerprint of the rollover certificate, or null. |
| saml.idp_certificate_rollover_not_after | read-only | Expiry of the rollover certificate, or null. |
| saml.groups_attribute | read/write | The assertion attribute carrying the user's group names. Used by role mapping. |
| saml.nameid_format | read/write | Which NameID format to ask the provider for: emailAddress (the default), persistent or unspecified. See Choosing a NameID Format below. |
A certificate is public material, so its fingerprint and expiry are readable; the PEM you submit is not echoed back. Configure the rollover certificate before the provider starts signing with it, and drop it once the old certificate is retired.
Write-only means write-only. Neither the OIDC client secret nor either SAML certificate PEM appears in any response, at any output level, to any caller. A client that needs to show whether a secret is present reads oidc.client_secret_set; a client that needs to show which certificate is loaded reads the fingerprint.
Choosing a NameID Format (SAML)
saml.nameid_format is the format Fastio asks the identity provider to put in the assertion's subject, and it accepts three values:
| Value | Use it when |
|---|---|
emailAddress | The default, and the right answer for almost every organization. The subject is the user's address. |
persistent | The provider issues an opaque, stable per-user identifier instead of an address. The assertion must then also carry an email attribute — Fastio has no address to fall back on. |
unspecified | The provider will not commit to a format. Treated like emailAddress when the value it sends looks like an address. |
transient is deliberately not accepted. The federated subject is the NameID, so a format that issues a new value on every sign-in would bind a new identity each time and fail every sign-in after the first with account_conflict.
The format locks once the organization has SAML identities bound to it. A change after the first federated sign-in is refused with nameid_format_locked, because it would orphan every identity row the organization already has — every one of those users would arrive as somebody new. Choose the format during setup, before the first sign-in; if it genuinely has to change afterwards, that is a support conversation, not a write.
Claim and Attribute Names (attribute_map)
By default Fastio reads the user's address and name from the names providers usually use. attribute_map overrides them when a provider does not:
{ "email": "mail", "given_name": "firstName", "family_name": "lastName" }
- Each value is an exact claim name (OIDC) or attribute name (SAML), up to 64 characters. There are no dotted paths, no XPath and no JSONPath — a nested value cannot be reached, so release it as a top-level claim at the provider instead.
- Every key is optional. An omitted key keeps the default behaviour for that field.
- Send
{}— an empty object — to clear the whole map and go back to the defaults. A JSONnullis indistinguishable from an omitted key at this endpoint and therefore does nothing. - A configured name that the assertion does not carry refuses the sign-in. It does not quietly fall back to the default name. A mapping is a statement about the provider, and silently ignoring it would create accounts from whatever the default name happened to hold.
- The reserved OpenID Connect names —
iss,aud,sub,exp,iat,nbf,nonce,azp— are refused as the email mapping.
Mapping the email claim changes what "verified" means under OIDC. The provider's email_verified signal describes the literal email claim and nothing else, so an OIDC address read out of a different claim is never treated as provider-verified: it will not auto-link to an existing Fastio account or claim a pending invitation, and those sign-ins refuse with email_unverified. Under SAML there is no such signal and none is needed — every attribute inside a validated, signed assertion carries the assertion's own trust, so a remapped SAML email attribute links exactly as the default one does.
Read attribute_map_invalid before you render the map. If the stored map cannot be read back, attribute_map comes back as an empty object {} with attribute_map_invalid: true; an unset map is null. Read the flag rather than inferring anything from the shape of the map. Show it as an error state, "the claim mapping could not be read, save it again", never as "defaults in use": while it is true, every sign-in for that organization is refused.
Importing the Provider's Metadata (SAML)
Rather than copying the entity ID, sign-on URL and certificate one at a time, a SAML provider's metadata document can be handed over whole. Send exactly one of these on a POST:
| Field | Direction | Description |
|---|---|---|
| saml.metadata_url | write-only | An https:// address Fastio fetches the document from. Never echoed back. |
| saml.metadata_xml | write-only | The document itself, pasted. Up to 256 KB. Never echoed back. |
What the import takes is exactly three things — the entity ID, the HTTP-Redirect sign-on URL, and one or two signing certificates — and it writes them into the ordinary saml.idp_* fields. Nothing is stored verbatim, there is no standing subscription to the URL, and nothing re-fetches it later: a re-import is a new POST. An import replaces the certificate set, so a document carrying a single signing certificate clears any rollover certificate that was set.
The rules that make an import predictable:
- Send one metadata field, and send it alone. Both keys in one request is refused with
metadata_conflict. Either key alongside a manualsaml.idp_*field is refused withmetadata_conflicts_with_manual_fieldsrather than resolved by precedence — the request does not say which the administrator meant. Either key while the effective protocol is OIDC is refused withmetadata_requires_saml. - These checks look at whether the key is present, not at whether it has a value. An empty string is a real request. Omit both keys entirely from every ordinary save — sending
"metadata_url": ""beside the manual fields is a refused request, not a no-op, and a lone empty key reaches the importer and fails there. - Repeated URL imports are throttled more tightly than an ordinary save, because a URL import opens a connection to an address the caller chose. Import once and save the rest normally.
- A document must carry exactly one identity-provider descriptor; a document carrying none, or several, is refused with
metadata_unsupportedrather than resolved by order. Only HTTP-Redirect sign-on is accepted, and duplicate certificates are collapsed by fingerprint before the two slots are filled.
Import refusals:
reason | What it means |
|---|---|
metadata_unreachable | The address could not be fetched, was not https://, redirected, or was too long. Publish the document at a final address that answers directly. |
metadata_too_large | The document exceeded the size Fastio will read. |
metadata_invalid | The document could not be parsed, a certificate in it was unreadable, or the entity ID was missing. |
metadata_no_signing_cert | It parsed, but carries no signing certificate. A document that offers only an encryption certificate lands here. |
metadata_unsupported | It parsed and is unusable: more than one identity-provider descriptor, no HTTP-Redirect sign-on endpoint, more than two distinct signing certificates, or an over-long entity ID. |
Not every provider publishes a metadata URL, and some publish a document only as a download. Where the vendor offers a URL, prefer it; where it offers a file, paste the file's contents. The per-vendor notes below say which is which.
Service-Provider Values to Paste Into the Identity Provider
The sp object on the read response carries the values the identity provider needs. They are generated for the org — copy them, do not compose them yourself.
| Field | Used for |
|---|---|
| sp.oidc_redirect_uri | The redirect / callback URI to register with an OIDC provider. |
| sp.saml_acs_url | The Assertion Consumer Service URL for a SAML provider. |
| sp.saml_entity_id | The service-provider entity ID for a SAML provider. A name, not a link — it identifies Fastio to the provider and is not meant to be fetched. |
| sp.saml_metadata_url | The address the service-provider metadata document is actually served at, for providers that import it rather than take fields one at a time. Not the same string as sp.saml_entity_id — see below. Empty when no metadata URL can be produced for the organization. |
These URLs are returned pinned to an explicit API version rather than to the current alias, because an identity provider's configuration has to stay valid across API versions. Paste them exactly as returned; do not rewrite them to current, and do not shorten or re-host them.
Connecting an Identity Provider
Every provider is connected the same way. The setup core below is the whole procedure; the per-vendor notes after it say only what that vendor calls each thing and where it differs.
The Setup Core
1. Claim and verify an email domain. POST /current/org/{org_id}/sso/domains/ returns the TXT record; publish it, then POST /current/org/{org_id}/sso/domains/{domain}/verify/. Nothing about enforcement works without this, and it is the step with a wait in it, so start it first.
2. Read the service-provider values. GET /current/org/{org_id}/sso/ and keep the sp object open beside the provider's console. These are the four values the provider needs from Fastio, and they are generated for the organization — copy them, never compose them.
3. Create the application at the provider, choosing one protocol, and paste the sp values in:
| Protocol | Paste this | Into the provider's field for |
|---|---|---|
| OIDC | sp.oidc_redirect_uri | The redirect / callback URI |
| SAML | sp.saml_acs_url | The Assertion Consumer Service URL |
| SAML | sp.saml_entity_id | The service-provider entity ID / audience |
| SAML | sp.saml_metadata_url | Service-provider metadata, where the provider imports it |
4. Copy the provider's values back with POST /current/org/{org_id}/sso/:
- OIDC —
oidc.issuer_url,oidc.client_id,oidc.client_secret. Most providers show the client secret exactly once; copy it before leaving the page. - SAML — either
saml.metadata_urlorsaml.metadata_xmlif the provider publishes a metadata document, orsaml.idp_entity_id,saml.idp_sso_urlandsaml.idp_certificateby hand. Do not send both a metadata field and a manual field in one request; see Importing the Provider's Metadata.
5. Release groups, if you want role mapping. No provider in this list releases group names by default. Configure the claim or attribute at the provider, put its name in oidc.groups_claim or saml.groups_attribute, and set role_mapping. Skip this step entirely if every user should arrive at jit.default_role.
6. Run the structural check — POST /current/org/{org_id}/sso/test/. It proves the values parse and the provider's published material is reachable. It does not prove a user can sign in.
7. Run a real test sign-in — POST /current/org/{org_id}/sso/test/ with mode: "signin", while mode is still off. This is the step that finds the problems: the browser goes to the provider, authenticates, comes back, and Fastio completes the whole protocol and then stops without creating anything. It reports the address the provider asserted, the groups it released and the role those groups map to. See The Test Sign-In.
8. Turn it on — mode: "optional", and mode: "required" when the organization is ready to enforce. Read enforcement_available first.
9. Add SCIM if you need offboarding. Just-in-time creation covers arrivals; SCIM covers departures. See SCIM 2.0 Provisioning.
Per-Vendor Notes
Each note says how its claims were checked:
- live-tested — exercised end to end against a real tenant of that provider.
- documentation-checked — traced to that vendor's current public documentation, not exercised. Vendor consoles are renamed often; if a label below does not match what you see, trust the console and the vendor's own documentation.
Only the terminology and the gotchas differ. The order of operations in the setup core is the same for all four.
JumpCloud — OIDC and SAML — documentation-checked
No end-to-end run against a JumpCloud tenant has been recorded yet, so every JumpCloud step below — sign-in, metadata import and the test sign-in — is documentation-checked. The metadata importer itself was exercised live against a public SAML test identity provider; the JumpCloud-specific field names and menu paths come from JumpCloud's documentation.
- Create: Access → SSO Applications → "+ Add New Application" → "Custom Application" → "Manage Single Sign-On (SSO)", then "Configure SSO with SAML" or "Configure SSO with OIDC".
- OIDC:
sp.oidc_redirect_urigoes in "Redirect URIs". The client ID and secret are shown once, after you activate the application. The issuer is JumpCloud's OAuth issuer host (it has regional variants — use the one for your tenant), and its discovery document sits at the usual well-known path under it. - SAML:
sp.saml_acs_urlgoes in "ACS URLs",sp.saml_entity_idin "SP Entity ID". JumpCloud can import a service-provider metadata file. Copy back "IdP Entity ID" and the IdP URL, and take the certificate from "Export Metadata" — or handsaml.metadata_urlthe "Copy Metadata URL" value. - Groups: set a "Groups Attribute Name" on the application and put the same name in
oidc.groups_claimorsaml.groups_attribute. There is no dedicated OIDCgroupsscope here; groups arrive as an attribute either way. - NameID: "SAMLSubject NameID" defaults to email, which matches the Fastio default. Change it only if you have a reason, and then set
saml.nameid_formatto match. - SCIM: the application's "Provisioning" tab → custom SCIM → base URL and token → Test Connection → Activate.
- The gotcha: users are implicitly denied the application until you bind a user group to it. When a test sign-in fails at the provider before it ever returns, check that the user is assigned to the application.
- Note: the SAML IdP URL is fixed when the application is created and cannot be edited afterwards. If it is wrong, make a new application.
Okta — OIDC and SAML — documentation-checked
- Create: Applications → "Create App Integration" → "OIDC - OpenID Connect" (application type "Web Application") or "SAML 2.0".
- OIDC:
sp.oidc_redirect_urigoes in "Sign-in redirect URIs". Client ID and secret are under "Client Credentials" on the application's General tab. The issuer is your Okta org, or a custom authorization server if you use one; its discovery document is at the usual well-known path. - SAML:
sp.saml_acs_urlgoes in "Single sign-on URL",sp.saml_entity_idin "Audience URI (SP Entity ID)". Okta does not import a service-provider metadata document into a custom application — paste both fields by hand. Copy back "Identity Provider Issuer", "Identity Provider Single Sign-On URL" and the X.509 certificate from the application's Sign On tab, which also publishes a metadata URL you can give tosaml.metadata_url. - Groups: not released by default, on either protocol. For OIDC, where the group claim lives depends on which issuer you gave Fastio, and the two places are not interchangeable. If the issuer is your Okta org, edit the application's Sign On tab, add the group claim to the OpenID Connect ID token, name it
groups, give it a filter, and refresh the application data afterwards. If the issuer is a custom authorization server, the Sign On tab does not reach it: add the claim on that server instead, under Security → API → the authorization server → Claims, with the value type "Groups", the ID token as the token type, and a filter. For SAML, add a "Group Attribute Statements" entry — it has no default name, so whatever you type there is what goes insaml.groups_attribute. - NameID: the "Name ID format" field pairs with "Application username". The default is unspecified, so set it to the email-address format to match Fastio's default.
- SCIM: available on a SAML custom application, under App Settings → Provisioning. It is not available on an OIDC integration created through the classic wizard — if the organization needs SCIM, choose SAML.
- The gotcha: the redirect URI must match exactly, and users must be assigned to the application before any of them can sign in.
Microsoft Entra ID — OIDC and SAML — documentation-checked
- Create (SAML): Enterprise applications → New application → "Create your own application" → integrate a non-gallery application → Single sign-on → SAML.
- Create (OIDC): App registrations → New registration; the redirect URI is added afterwards, under Authentication, as a "Web" platform.
- SAML:
sp.saml_acs_urlgoes in "Reply URL (Assertion Consumer Service URL)",sp.saml_entity_idin "Identifier (Entity ID)". Entra accepts an uploaded service-provider metadata file and fills both from it. Copy back "Microsoft Entra Identifier", the "Login URL" and the Base64 signing certificate — or givesaml.metadata_urlthe "App Federation Metadata Url". - OIDC: the client ID is "Application (client) ID" on the Overview blade. The secret's Value is visible only on the page where you create it. The issuer is the tenant's v2.0 authority, with its discovery document at the usual well-known path.
- Groups: opt-in — "Add a group claim" under User Attributes & Claims for SAML, "Add groups claim" under Token configuration for OIDC. Two things will bite you. First, the claim carries group object IDs, not names, unless you change the source, so either switch it or write the object IDs into
role_mapping. Second, Entra omits the group claim entirely once a user is in more than 150 groups for a SAML assertion or 200 for a token — choose "Groups assigned to the application" rather than "All groups", or role mapping will silently do nothing for exactly the people who are in the most groups. - NameID: "Unique User Identifier (Name ID)" → "Choose name identifier format" offers an email-address option; pick it to match Fastio's default.
- SCIM: Provisioning → new configuration → tenant URL and secret token → Test Connection. Schema discovery is not supported for a custom SCIM application, which is expected rather than an error.
Google Workspace — SAML only — documentation-checked
- SAML only. The Admin console has no generic OpenID Connect application type for third-party service providers; its OpenID Connect surface points the other way, for signing Google users in against somebody else's provider. Configure Fastio with
protocol: "saml". - Create: Apps → Web and mobile apps → Add App → "Add custom SAML app".
- Copy back from the "Google Identity Provider details" step: the SSO URL, the entity ID and the certificate — or download the identity-provider metadata and paste the document into
saml.metadata_xml. Google publishes the document as a download rather than as an address to fetch, so prefermetadata_xmlovermetadata_urlhere. - Paste in on the "Service Provider Details" step:
sp.saml_acs_urlinto "ACS URL" andsp.saml_entity_idinto "Entity ID". Google does not import a service-provider metadata document. - Groups: under attribute mapping, "Group membership (optional)". You search for and list the specific groups to release and choose the attribute name yourself; put that name in
saml.groups_attribute. Only the groups you explicitly listed are ever released — a group you forgot is not a mapping that failed, it is a group the assertion never mentioned. - NameID: the Name ID and its format are set on the Service Provider Details step. The default Name ID is the user's primary email, which matches Fastio's default.
- Provisioning: Google's automated provisioning is documented for its catalogue applications, not for a custom SAML application. Plan on just-in-time creation for arrivals and a deliberate process for departures.
- The gotcha: the application must be turned on for the user under "User access", and changes can take a while to propagate. The entity ID comparison is case-sensitive.
How a User Signs In
This section is the sign-in flow the configuration above switches on: what an administrator is actually enabling, and what to expect when testing it. The endpoint-by-endpoint reference — parameters, response fields and error tables — is in the Auth reference, under Enterprise SSO Sign-In.
The sequence:
1. discover POST /current/user/sso/discover/ (optional) an email address -> which org, if any
2. start GET /current/user/sso/start/?org={org_domain}
-> redirect_url at the org's identity provider
3. provider the user authenticates at the organization's own identity provider
4. callback the provider returns the browser to Fastio (the redirect URI / ACS URL from
the sp values above), which redirects it to {origin}/signin/sso and sets a
short-lived, single-use handoff cookie
5. exchange POST /current/user/sso/exchange/ empty body, cookies included -> JWT
SP-initiated only. Every sign-in must begin at step 2. There is no IdP-initiated path: a user cannot launch Fastio from a tile on the identity provider's app dashboard, and a SAML Response that names no live sign-in record is rejected rather than accepted as unsolicited. Configure the provider's tile, if it has one, to point at the org's own Fastio sign-in page rather than at the ACS URL.
One callback URL per protocol, for the whole platform. sp.oidc_redirect_uri and sp.saml_acs_url are not org-specific. The organization a callback belongs to is resolved from the single-use record created at step 2, never from the assertion's issuer — which is why an administrator registers one stable URL and never has to change it. sp.saml_entity_id is org-specific, and is keyed to the organization's stable numeric ID rather than to its domain (slug), so that renaming the org never moves its entity ID.
sp.saml_entity_id and sp.saml_metadata_url are deliberately different values. They look alike and are not interchangeable. An entity ID is a name: SAML only requires it to be a URI, it never has to be fetchable, and it is keyed to the stable numeric ID so that renaming the organization cannot move it or let somebody else re-register it. The metadata URL is an address: a provider fetches it, so it names the organization the way the metadata route resolves organizations — by domain (slug). Paste each into the field it belongs in, and re-copy the metadata URL at the provider if the organization's domain is ever changed.
The handoff never travels in a URL. On success the callback redirects the browser to {origin}/signin/sso carrying nothing, and sets an HttpOnly, Secure, SameSite=Lax cookie good for 60 seconds and exactly one use. On failure it redirects to {origin}/signin/sso#error={reason}&org={org_domain} and sets no cookie — a reason string carries nothing sensitive, which is why it may appear in a URL where the handoff may not. The org parameter is present only once the organization is known — a refusal that happens before that, such as a sign-in record that has expired or already been used, carries the reason alone.
The sign-in is bound to the browser that started it. Step 2 requires the browser-key credential the platform already issues, and step 5 re-checks it, so a handoff lifted out of one browser fails in another rather than succeeding on possession. A user who starts a sign-in in one browser and finishes it in another gets browser_mismatch and starts again.
The session is an ordinary revocable account session. The token the exchange issues is the same kind a password login issues with revocable=true: sign-out and invalidate-all both reach it, and everything downstream works unchanged. Fastio's own 2FA step is not consulted on a federated sign-in — the identity provider owns multi-factor for these users, and the exchange response carries no 2factor field at all.
There is no single logout. Signing out of Fastio ends the Fastio session only — it does not sign the user out at the identity provider, and there is no RP-initiated logout request sent to the provider's own end-session endpoint. A user who signs out and immediately starts a new sign-in is typically returned without being prompted again, because their session at the provider is still live. Fastio also does not accept an IdP-initiated logout: there is no endpoint for a provider to notify that it ended a session, so ending one there does not end the corresponding Fastio session either. Revoke access from the Fastio side with sign-out or invalidate-all; revoke it at the provider by ending the session or the account there.
Discovery: Finding the Org From an Email Address
A platform-wide sign-in page does not know which organization a user belongs to until they type an address. POST /current/user/sso/discover/ (unauthenticated, IP-throttled) maps an email domain to the organization that federates it, and returns the path to start the sign-in.
Three different situations return a byte-identical negative answer: a domain nobody has claimed, a domain claimed but not yet verified, and a verified domain whose organization has SSO switched off. They cannot be told apart, so the endpoint cannot be used to map which companies are on the platform, and it never reveals whether an account exists. The only thing it ever discloses is that a verified domain federates — already public knowledge to everyone who works there.
A failed lookup is a retryable 503, never a negative answer. Folding an outage into "no SSO" would, during an incident, tell every federated organization's users to sign in with a password.
What an Org's Sign-In Page May Offer
GET /current/org/{org_id}/public/details/ — unauthenticated — carries a login_options block on the org object so an org-scoped sign-in page can draw the right buttons before anybody has authenticated:
"login_options": {
"password": true,
"social": ["google", "microsoft"],
"sso": {
"enabled": true,
"mode": "optional",
"protocol": "oidc",
"display_name": "Acme SSO",
"start_path": "/user/sso/start/?org=acme-corp"
},
"signup": false
}
Every value is already resolved for the organization — a client does not combine mode with the other flags itself. Under required the block reports password: false, social: [] and signup: false, and the server refuses those routes too, so the page never draws a door that is locked.
What is deliberately absent: the organization's verified domain list, and any indication of who is exempt from enforcement. Publishing the first would hand an attacker the exact addresses worth phishing; publishing the second would tell an unauthenticated caller that a particular organization has an administrator who can still use a password. An absent login_options block means the answer could not be determined — fall back to the default sign-in options, never to "this organization has no SSO".
Sign-In Refusal Reasons
Sign-in refusals use their own closed vocabulary, separate from the configuration refusals listed later on this page. The same list covers a #error= fragment on the landing route and a reason on a 4xx from start or exchange. Branch on reason, never on the numeric code or the HTTP status alone.
reason | What happened | Whose problem it is |
|---|---|---|
sso_not_configured | No such organization, or it has no usable configuration. The two are the same answer on purpose. | Administrator. |
sso_disabled | A configuration exists but mode is off. | Administrator. |
domain_not_permitted | The email address is not on a verified domain of this organization. | Administrator (verify the domain) or user (wrong address). |
idp_error | The identity provider refused, or returned something unusable. The provider's own message is never passed through. | Administrator. |
email_unverified | The provider did not assert the address as verified, and the address matches an existing account or a pending invitation to this organization. Linking an unverified address to either is how account takeover is spelled. A brand-new account for an address nobody has invited is still created. | Administrator, at the provider. |
account_conflict | The address belongs to an account this organization may not link to the federated identity. | Support. |
state_expired | The sign-in took too long, its record is gone, or a first SAML sign-in could not be completed safely at that moment. No administrator action is implied. | User — restart the sign-in. |
code_expired | The handoff was missing, already used, or older than 60 seconds. | User — start again. |
browser_mismatch | The sign-in was finished in a different browser than it started in, or no browser key was presented. | User — finish in the browser that started, with cookies enabled. |
deprovisioned | The organization's directory removed this identity; a sign-in must not step over that. | Administrator. |
not_provisioned | The organization is scim_only and its directory has not provisioned this person. Includes a returning member who originally arrived through just-in-time creation. | Administrator — provision them through SCIM. |
plan_required | The organization's plan does not include Enterprise SSO. | Administrator. |
Testing a new configuration. The configuration check (POST .../sso/test/) is structural — it says the values are well-formed and the provider's published material parses. It does not say a user can get in. Prove the protocol and the claims round-trip with a test sign-in (POST .../sso/test/ with mode: "signin", while mode is still off), which runs the real protocol without creating anything, and then confirm with one real sign-in under optional — the test sign-in stops before eligibility, admission and membership; see The Test Sign-In. Read any reason verbatim: every one of the rows above names something specific.
Domain Verification
SSO only applies to email domains the org has proven it controls. A domain is claimed, then verified by DNS.
Claiming (POST /current/org/{org_id}/sso/domains/) returns a TXT record:
| Part | Value |
|---|---|
| Record name | _fastio-verification.{domain} — e.g. _fastio-verification.acme.com |
| Record value | fastio-verification={verification_token} |
Publish that TXT record, then call the verify endpoint. Verification reads DNS at the moment you call it; there is no queue to wait on.
Rules that decide whether a name can be claimed at all:
- The domain is stored lowercase.
ACME.comandacme.comare the same claim. - An internationalized domain must be supplied in its ASCII (punycode) form —
xn--…. A Unicode form is refused; convert it before you send it. - Public suffixes are refused —
com,co.ukand the like. So are bare IP addresses, and Fastio's own domains. These come back asdomain_not_allowed. - A domain may be at most 255 characters.
Two orgs, one name. Two organizations may each hold a pending claim on the same domain at the same time — a pending claim proves nothing, so it blocks nothing. Only one org may hold a verified claim. Whichever org verifies first takes it; a later attempt by the other org is refused with domain_in_use. An unverified claim expires after 7 days, releasing the name.
Staying verified. Verified domains are re-checked daily. A domain is only un-verified after three consecutive checks confirm the TXT record is gone — a resolver timeout, a transient failure, or any other inability to read DNS never counts toward those three and never un-verifies a domain. Administrators are notified when a domain is un-verified.
Releasing. POST /current/org/{org_id}/sso/domains/{domain}/delete/ releases a claim, verified or not, and is never plan-gated. Releasing the last verified domain while the org is enforcing SSO is refused with last_verified_domain — step the mode down first.
Deleting the SSO configuration does not release the domains. Verified domains survive POST /current/org/{org_id}/sso/delete/, so an administrator who switches identity providers does not have to prove domain ownership a second time.
Roles: Just-in-Time Default and Group Mapping
jit.default_role is the org role a user receives when they first arrive through SSO.
role_mapping maps identity-provider groups to org roles — a list of {group, role} pairs, read from oidc.groups_claim or saml.groups_attribute depending on protocol.
Role mapping is promote-only. A mapped group grants a role. It never takes one away:
- A user in a mapped group gets at least that role.
- A user who leaves the group keeps the role they already hold — the identity provider never demotes anyone, and removing a mapping does not strip roles already granted. Remove a role through ordinary org member management.
- The organization owner is never affected by role mapping, in either direction.
This is intentional for a first release: a mis-typed group name can over-grant, which an administrator can see and correct in the member list, but it can never silently lock an organization out of its own account.
role_mapping[].role accepts admin only. Mapping a group to member never did anything — every JIT arrival already gets the org's default role — so the value is no longer offered. A configuration saved before this restriction may still hold a stored member row; it is dropped, with a notice, the next time the configuration is saved (even a no-op resubmission of the same array), and is simply absent from the response after that. It is never refused outright, so an administrator making an unrelated change is not blocked by a row they did not know was there.
Who May Sign In: provisioning_mode
provisioning_mode decides whether a successful authentication is allowed to create the account behind it.
| Value | Meaning |
|---|---|
jit | The default. Somebody on a verified domain who authenticates at the provider gets an account and a membership if they do not already have one. |
scim_only | The directory is the source of truth. Only people the organization has provisioned through SCIM may sign in. Everybody else is refused with not_provisioned, and nothing is created, linked, claimed or bound. |
scim_only locks out returning users as well as new ones, and that is what it means. A member who originally arrived through just-in-time creation, and has been signing in for months, is refused the moment the organization flips to scim_only — exactly like somebody who has never been here. It is not a grandfathering bug. "The directory decides who may sign in" cannot be true and also make an exception for everyone who predates the decision. Say so in the interface next to this control, because the administrator who flips it is usually not the person who gets the support ticket.
The recovery path is to provision them. Creating that person through SCIM adopts the identity they already have: the existing account, their federated subject and their current role are all kept, and they sign in again immediately. It does not make a second account, and it does not reset the role they hold.
"Provisioned by SCIM" means the resource id this service minted when the person was created — not the externalId the provider sent. externalId is optional on the SCIM wire and plenty of providers omit it, so a person created without one is a perfectly ordinary SCIM user and signs in normally under scim_only.
Somebody the directory removed stays removed. A deprovisioned identity is refused with deprovisioned, in either mode, rather than with not_provisioned — the two are different answers to different questions.
Group mapping never raises a SCIM-managed member's role, in either mode. Where SCIM owns a person, SCIM's groups own their role, so a login-time group claim cannot promote them; a just-in-time user in a mapped group still is promoted as documented above. Change a SCIM-managed member's role in the directory, not at sign-in.
provisioning_mode is a policy field: changing it does not bump config_revision and does not invalidate a passing configuration check.
The Configuration Check
POST /current/org/{org_id}/sso/test/ runs a structural configuration check:
- OIDC — fetches the discovery document at
oidc.issuer_urland the key set it advertises, and checks their shape. - SAML — parses the configured certificate (and the rollover certificate, when one is set) and checks it has not expired.
It checks the configuration, not that a user can sign in. A passing check says the values you entered are well-formed and the provider's published material is reachable and parsable. It does not say that a real user's credentials work, that the provider will release the claims you expect, that group names match your role_mapping, or that the provider has been told about the sp URLs. Those are only proven by an actual sign-in, which is documented separately. Do not describe a passing check to an administrator as "SSO works".
One warning a passing check can carry. When role_mapping is set and the OIDC provider's discovery document advertises no groups scope, the check still passes and last_test.message ends with a warning. Fastio asks for groups only where the provider says it offers it, so such a provider releases no group names and every mapped role would silently do nothing. Fix it at the provider — publish the scope, or release the groups in the claim named by oidc.groups_claim — or clear the mapping so the expectation matches reality. When oidc.extra_scopes is also configured, that warning's wording changes: an unadvertised groups scope no longer proves role mapping is dead, because a custom scope the org added may be exactly what releases the claim, so the message says discovery cannot confirm it rather than asserting the mapping will not work. A third, independent warning can appear on its own when an extra scope simply is not in the advertised list. At most one warning sentence is ever appended.
The check's discovery fetch is always live; a real sign-in's is not. POST /sso/test/ re-fetches the discovery document on every call, so a change at the provider is reflected immediately. An actual sign-in reuses a cached copy for up to one hour, keyed to the current config_revision — so a provider-side change (a rotated signing key, an added or removed scope) that leaves the stored configuration untouched can take up to an hour to reach real sign-ins even though a check run right after it will already show it. Editing a credential, an issuer, a certificate, a claim map or a NameID format busts the cache immediately, because the revision it is keyed on changes. Editing anything else — a display name, oidc.extra_scopes, a role mapping — leaves that revision alone, so the cached copy stands until it ages out.
tested tracks the current configuration. The read response carries three related fields, and they answer different questions:
tested— has a successful check been run against the configuration as it stands now?tested_at— when the last recorded check ran, whichever configuration it targeted.nullif no check has ever run.last_test— that same last attempt in full:at,okand amessage.
Changing a credential, an issuer or a certificate invalidates the previous check: tested goes back to false while tested_at and last_test still report the older, passing run. Read tested when you need to know whether the current configuration has been checked; read tested_at or last_test when you want to show what happened last.
tested and tested_at can legitimately disagree — that is a signal, not a bug. They answer different questions, so a recent tested_at sitting beside tested: false is a meaningful state: a credential, issuer or certificate changed after a check passed, and that check no longer speaks for what is configured now. Show it as "checked at {time}, but the configuration has changed since" and re-run the check.
The Test Sign-In
The structural check proves the configuration parses. A test sign-in goes further and proves the protocol and the claims: a real administrator authenticates at the real provider, and Fastio validates the exchange or the signature, reads the claims or attributes, extracts the groups, and reports the role the mapping would produce.
It is a protocol and claims test, not an eligibility test. It stops before anything that decides whether a given person may actually have an account: it does not check that the asserted address sits on a verified domain, does not apply scim_only admission, does not look for a deprovisioned tombstone or an account-link conflict, and creates no membership and no session. A test can therefore pass for somebody whose real sign-in is refused. Prove the rest by setting mode to optional and having a real, non-administrator user sign in before you enforce.
POST /current/org/{org_id}/sso/test/ with mode: "signin" starts one and returns a redirect_url. Send the administrator's browser there. They authenticate at the provider exactly as a user would, the provider returns the browser to Fastio, and Fastio runs the whole protocol — token exchange or signature validation, claims or attributes, group extraction — and then stops. No account is created, no membership changes, no identity is bound, no session is issued, and no handoff cookie is set. The browser lands on:
{your_origin}/signin/sso?sso-test=1&ok=<0|1>&message=<urlencoded>
Treat that landing as a third outcome, not as a failed sign-in. It is neither of the two the sign-in flow has: there is no handoff to exchange, and nothing went wrong with a login, because no login was attempted. Branch on sso-test=1 before your ordinary sign-in error handling runs, or a passing test will be reported to the administrator as a broken one. The message values are a closed vocabulary — render an unrecognised one as a generic failure rather than showing it raw.
A test sign-in is only permitted while mode is off. At optional or required the kickoff refuses with sso_enabled. It is confined to the window before the organization depends on these routes, which is also the window when it is useful.
The summary is in the URL; the result is on the configuration read. Refetch GET /current/org/{org_id}/sso/ when the landing route mounts and read last_signin_test:
| Field | Type | Description |
|---|---|---|
| at | string | When the test completed (Y-m-d H:i:s UTC). |
| ok | boolean | Whether the protocol completed successfully. |
| message | string | The summary for the administrator. It is the redirect's value, plus — where the mapping resolved to admin — a fixed note that a SCIM-managed member is not promoted by group mapping. Do not apply the redirect's closed allowlist to it: match the leading value, or render it as given. Only the redirect's message is closed. |
| string or null | The address the provider asserted. null when none could be read. | |
| groups | array | The group names the provider released. Bounded by count, by the length of each entry and by total size — a provider that releases hundreds of groups will be truncated. |
| mapped_role | string or null | What role_mapping resolves those groups to. admin when an admin mapping matched; otherwise member whenever the groups are known — a matching entry whose role is member still yields member — including a known-empty list, which is what an absent SAML attribute and an absent or empty OIDC claim both produce. null only when the provider withheld the group information (the over-large-group-list indication some OIDC providers send in place of the list) or when the test failed. |
| config_revision | integer | The configuration revision the test ran against. |
| stale | boolean | Whether the configuration has changed since. |
mapped_role is what the mapping resolves to. It is not a prediction of the role a real sign-in would grant. Those are different questions, and they have different answers for anybody the directory manages: group mapping never promotes a SCIM-managed member, so mapped_role: "admin" for such a person means "the mapping matched", not "they will become an administrator". When it resolves to admin, the stored message carries a fixed note saying so. That note is on last_signin_test.message only and never appears in the redirect.
Read stale and believe it. mapped_role depends on role_mapping, and editing a role mapping deliberately does not bump config_revision — so a test that passed before the edit would otherwise look current while describing a mapping that no longer exists. Show a stale result as stale and offer to run another one; never present it as the state of things now.
A failed test is still a result. A provider that refuses the administrator, or a protocol failure after the sign-in record was claimed, records ok: false with a message — that is the test working. A run that never got that far, because its record had expired or had already been used, records nothing: last_signin_test still shows the previous attempt. Compare at against when you started, rather than assuming the newest read reflects the run you just did.
An expired test lands on the ordinary sign-in error route, not on the test one. Without its record nothing can tell that the returning browser was running a test, so it arrives at /signin/sso carrying error=state_expired in the URL fragment — no sso-test=1, no ok, no message. An administrator reporting that their test "said the sign-in expired" has hit exactly this; they should start a new test rather than read it as a broken configuration.
What a test sign-in leaves alone. tested, tested_at, last_test, config_revision and enforcement_available are all untouched, and no configuration-updated event is emitted. Only the structural check — mode: "structural", the default, unchanged — stamps tested. Running a test sign-in therefore never moves an organization closer to being able to enforce, and never further away.
issuer_shared
issuer_shared is true when another organization has configured the same issuer (or SAML entity ID).
It is advisory and informational only. It never blocks a write, never fails a check, and does not mean anything is wrong — a company running several organizations on one identity provider will see it on every one of them, legitimately. Surface it as a note, not as an error; the useful reading is "confirm you meant to point two organizations at one provider."
Endpoints
Read the SSO Configuration
/current/org/{org_id}/sso/
Auth required. Org admin. Allowed on every plan.
curl example
curl -X GET "https://api.fast.io/current/org/1234567890123456789/sso/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK) — an OIDC configuration
{
"result": true,
"sso": {
"mode": "optional",
"enforcement_available": true,
"protocol": "oidc",
"display_name": "Acme Identity",
"oidc": {
"issuer_url": "https://login.example-idp.com/",
"client_id": "example-client-id",
"client_secret_set": true,
"groups_claim": "groups",
"extra_scopes": ["offline_access"],
"extra_scopes_invalid": false
},
"saml": {
"idp_entity_id": null,
"idp_sso_url": null,
"idp_certificate_fingerprint": null,
"idp_certificate_not_after": null,
"idp_certificate_rollover_fingerprint": null,
"idp_certificate_rollover_not_after": null,
"groups_attribute": "groups",
"nameid_format": "emailAddress"
},
"sp": {
"oidc_redirect_uri": "https://api.fast.io/v1.0/user/sso/oidc/callback/",
"saml_acs_url": "https://api.fast.io/v1.0/user/sso/saml/acs/",
"saml_entity_id": "https://api.fast.io/v1.0/user/sso/saml/metadata/?org=1234567890123456789",
"saml_metadata_url": "https://api.fast.io/v1.0/user/sso/saml/metadata/?org=acme-corp"
},
"jit": {
"default_role": "member"
},
"role_mapping": [
{ "group": "acme-platform-admins", "role": "admin" }
],
"provisioning_mode": "jit",
"attribute_map": {
"email": "mail"
},
"attribute_map_invalid": false,
"enforcement": {
"exceptions": ["contractor@acme.com"]
},
"tested": true,
"tested_at": "2026-04-27 16:37:29 UTC",
"last_test": {
"at": "2026-04-27 16:37:29 UTC",
"ok": true,
"message": "Discovery document and key set validated."
},
"last_signin_test": {
"at": "2026-04-27 16:41:08 UTC",
"ok": true,
"message": "Sign-in test completed. The identity provider returned a valid assertion. (group mapping; SCIM-managed users are not promoted)",
"email": "admin@acme.com",
"groups": ["acme-platform-admins"],
"mapped_role": "admin",
"config_revision": 4,
"stale": false
},
"issuer_shared": false,
"certificate_warning": { "level": "none", "days_remaining": null, "rollover_available": false },
"domains": [
{
"domain": "acme.com",
"verified": true,
"verified_at": "2026-04-26 09:14:02 UTC",
"txt_record": {
"name": "_fastio-verification.acme.com",
"value": "fastio-verification={verification_token}"
}
},
{
"domain": "acme-labs.com",
"verified": false,
"verified_at": null,
"txt_record": {
"name": "_fastio-verification.acme-labs.com",
"value": "fastio-verification={verification_token}"
}
}
],
"config_revision": 4,
"updated": "2026-04-27 16:37:29 UTC"
}
}
Response (200 OK) — a SAML configuration (protocol-specific fields only)
{
"result": true,
"sso": {
"mode": "optional",
"protocol": "saml",
"display_name": "Acme Identity",
"saml": {
"idp_entity_id": "https://idp.example-idp.com/entity",
"idp_sso_url": "https://idp.example-idp.com/sso",
"idp_certificate_fingerprint": "11:22:33:44:55:66:77:88:99:00:AA:BB:CC:DD:EE:FF:11:22:33:44",
"idp_certificate_not_after": "2027-04-27 16:37:29 UTC",
"idp_certificate_rollover_fingerprint": "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66:77:88:99:00:AA:BB:CC:DD",
"idp_certificate_rollover_not_after": "2028-04-27 16:37:29 UTC",
"groups_attribute": "groups"
},
"tested": false,
"tested_at": "2026-04-27 11:02:41 UTC",
"last_test": {
"at": "2026-04-27 11:02:41 UTC",
"ok": true,
"message": "Certificate parsed; expires 2027-04-27 16:37:29 UTC."
},
"issuer_shared": false,
"certificate_warning": { "level": "expiring", "days_remaining": 14, "rollover_available": false }
}
}
tested: false alongside a recent tested_at and last_test.ok: true is the normal state right after a credential or certificate change — the last check passed, but it was run against the previous configuration.
Response fields
| Field | Type | Description |
|---|---|---|
| sso.mode | string | off, optional, or required. See Modes. |
| sso.enforcement_available | boolean | Whether the required mode may be selected for this organization right now — Enterprise plan, at least one verified domain, and a successful check for the current configuration. Read this rather than deriving the rule yourself. See Enforcement. |
| sso.protocol | string | oidc or saml. |
| sso.display_name | string | The label a user sees on the sign-in button. |
| sso.oidc | object | OIDC settings. See Protocols. |
| sso.oidc.client_secret_set | boolean | Whether a client secret is stored. The secret itself is never returned. |
| sso.oidc.extra_scopes | array | Extra scope tokens appended to the authorize request, as stored. [] when none are configured. |
| sso.oidc.extra_scopes_invalid | boolean | true when the stored scope list could not be read back. extra_scopes then arrives as [] — the same shape as "none configured" — and login is refused for the organization until it is saved again. Mirrors attribute_map_invalid below; render it the same way. |
| sso.saml | object | SAML settings. See Protocols. |
| sso.sp | object | The service-provider values to paste into the identity provider. |
| sso.jit.default_role | string | Org role assigned to a user arriving through SSO for the first time. |
| sso.role_mapping | array | List of {group, role} pairs. Promote-only. role accepts admin only. |
| sso.saml.nameid_format | string | emailAddress, persistent or unspecified. See Choosing a NameID Format. |
| sso.provisioning_mode | string | jit or scim_only. See Who May Sign In. |
| sso.attribute_map | object or null | Claim and attribute name overrides, or null when none are set. See Claim and Attribute Names. |
| sso.attribute_map_invalid | boolean | true when the stored map could not be read back. attribute_map then arrives as an empty object {} rather than as null — every sign-in for the organization is refused until the map is saved again. Render it as an error, never as "defaults in use". |
| sso.enforcement.exceptions | array | Canonicalised addresses that are exempt exactly as an administrator is, on the three exemptible surfaces and no others. See Enforcement. |
| sso.last_signin_test | object or null | The last test sign-in in full, or null if none has run. See The Test Sign-In. |
| sso.tested | boolean | Whether a successful configuration check exists for the configuration as it stands now. |
| sso.tested_at | string or null | When the last recorded check ran (Y-m-d H:i:s UTC), whichever configuration it targeted. null if none has run. May be recent while tested is false. |
| sso.last_test | object | at (Y-m-d H:i:s UTC), ok (boolean), message (string) — the last attempt, whichever configuration it targeted. |
| sso.issuer_shared | boolean | Advisory: another org has configured the same issuer. Never blocks anything. |
| sso.certificate_warning | object | {level, days_remaining, rollover_available} — the SAML signing certificate's expiry state. Present on every response, including an unconfigured org and an OIDC one, so the front end needs one branch. See below. |
| sso.domains | array | Claimed domains: domain, verified, verified_at, and the txt_record (name, value) to publish. |
| sso.config_revision | integer | Increments on every credential, issuer or certificate change. 0 for an org that has not saved a configuration yet; the first save is revision 1. |
| sso.updated | string | When the configuration last changed (Y-m-d H:i:s UTC). |
certificate_warning. level is none, expiring or expired; days_remaining counts down to the active signing certificate's idp_certificate_not_after, not a rollover's. rollover_available is true only when a second, later-expiring certificate is also configured — it is what turns an expiring level from urgent into informational, because the identity provider still signs with the primary until it is actually rotated out, so the day count never jumps out just because a successor is loaded. The four combinations:
level: "none"→days_remaining: null,rollover_available: false. No certificate configured, or nowhere near expiry.level: "expiring", a rollover configured →days_remainingon the active cert,rollover_available: true. Worth noting, not urgent.level: "expiring", no rollover →rollover_available: false. Needs attention before it lapses.level: "expired"→days_remaining: 0. Every federated sign-in for this org has stopped.
An OIDC configuration and an org with no configuration at all both read {level: "none", days_remaining: null, rollover_available: false}, so the front end can render this key unconditionally rather than branching on protocol. A daily job warns admins by email at 30/14/7 and 1 day before expiry, once per threshold per certificate, and reminds daily once a certificate has actually expired. Render certificate_warning directly; do not derive a warning from idp_certificate_not_after yourself.
Update the SSO Configuration
/current/org/{org_id}/sso/
Auth required. Org admin. Enterprise plan required, except for a request whose only field is mode and whose value is lower than the org's current mode (required→optional, required→off, optional→off).
A partial update — send only the fields you are changing. Omitted fields are left alone.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| mode | string | No | off, optional or required. required additionally needs at least one verified domain and a successful configuration check — see Enforcement. |
| protocol | string | No | oidc or saml. |
| display_name | string | No | Up to 128 characters. The label shown on the sign-in button. |
| oidc.issuer_url | string (URL) | No | The provider's issuer URL. |
| oidc.client_id | string | No | The client ID issued for Fastio. |
| oidc.client_secret | string | No | Write-only. Never returned. |
| oidc.groups_claim | string | No | Claim carrying group names. |
| oidc.extra_scopes | array | No | Up to 5 extra scope tokens, each up to 64 characters, appended to the authorize request as-is. Refused if an entry repeats one of the three scopes Fastio sends on every request (openid, email, profile) or uses a character outside the OAuth scope-token set. groups is accepted: Fastio requests it automatically only where the provider advertises it, so configuring it is how to ask for it from a provider that does not — and the authorize request carries it once either way. Omit the field to leave the stored list unchanged; send [] to clear it. Does not invalidate a passing check — see below. |
| saml.idp_entity_id | string | No | The identity provider's entity ID. |
| saml.idp_sso_url | string (URL) | No | The identity provider's sign-on URL. |
| saml.idp_certificate | string (PEM) | No | Write-only. The signing certificate. |
| saml.idp_certificate_rollover | string (PEM) | No | Write-only. Optional second certificate for a rollover window. |
| saml.groups_attribute | string | No | Assertion attribute carrying group names. |
| saml.nameid_format | string | No | emailAddress (default), persistent or unspecified. Locked once the organization has bound SAML identities. See Choosing a NameID Format. |
| saml.metadata_url | string (URL) | No | Write-only. An https:// address to fetch the provider's metadata document from. Send it alone — see Importing the Provider's Metadata. |
| saml.metadata_xml | string | No | Write-only. The provider's metadata document itself, up to 256 KB. Send it alone. |
| jit.default_role | string | No | Org role for a first-time SSO arrival. |
| provisioning_mode | string | No | jit (the default) or scim_only. See Who May Sign In. |
| attribute_map | object | No | Claim and attribute name overrides — email, given_name, family_name, each up to 64 characters. Send {} to clear; a null does nothing. See Claim and Attribute Names. |
| enforcement.exceptions | array | No | Up to 100 email addresses, replacing the whole list. [] clears it. Canonicalised server-side. See Enforcement. |
| role_mapping | array | No | Replaces the whole list. Each entry {group, role}; role accepts admin only. A stored entry with role: "member" from before this restriction is silently stripped, not refused — a resubmission of the whole array (which the front end always sends) is accepted, and the legacy row is gone from the next read. |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/" \
-H "Authorization: Bearer {jwt_token}" \
--data-urlencode "protocol=oidc" \
--data-urlencode "display_name=Acme Identity" \
--data-urlencode 'oidc={"issuer_url":"https://login.example-idp.com/","client_id":"example-client-id","client_secret":"{client_secret}","groups_claim":"groups"}'
Fields are form-encoded (application/x-www-form-urlencoded), not a JSON body. A nested object (oidc, saml) is one form field whose value is a JSON string, as shown above. Use --data-urlencode rather than -d: a real client secret containing &, + or = is otherwise mangled on the wire and the endpoint stores the wrong value.
Response (200 OK)
The same object as GET /current/org/{org_id}/sso/, reflecting the update.
What a write can invalidate. Changing a credential, an issuer, a certificate, the claim mapping or the NameID format sets tested back to false and bumps config_revision. Re-run the configuration check afterwards. Changing a display name, a role mapping, oidc.extra_scopes, the provisioning mode or the exception list does not — those are policy, not credentials, and they leave a passing check standing. oidc.extra_scopes is deliberately policy rather than a credential: bumping the revision for it would clear enforcement_available on an edit that changes nothing about whether the configuration works, so it instead carries an advisory warning on the next check — see below.
Omit saml.metadata_url and saml.metadata_xml from an ordinary save. They are checked on presence, not on value, so sending either one empty alongside the manual saml.idp_* fields is a refused request rather than a no-op. Send a metadata field only on the request that is actually an import, and send nothing else SAML-related with it.
Telling your save from somebody else's. The same changes increment config_revision. It is 0 for an org that has not saved a configuration yet, and the first save is revision 1. Compare the revision you read back against the one you were holding when two administrators may be editing at once.
Refusals: plan_required, invalid_config, enforcement_unavailable, and — on an attempt to enforce — domains_unverified and test_required. A NameID format change after identities are bound is nameid_format_locked. A metadata import has its own five reasons plus metadata_conflict, metadata_conflicts_with_manual_fields and metadata_requires_saml. See Refusal reasons and Importing the Provider's Metadata.
Run the Configuration Check
/current/org/{org_id}/sso/test/
Auth required. Org admin. Enterprise plan required. Runs one of two checks, chosen by mode.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| mode | string | No | structural (the default) runs the configuration check described in The Configuration Check, and on success records the result against the configuration as it stands, which is what flips tested to true. signin starts a test sign-in instead — see The Test Sign-In. |
mode: "signin" is accepted only while sso.mode is off, and is refused with sso_enabled otherwise. It stamps nothing at kickoff, emits no event, and does not touch tested.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/test/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"sso_test": {
"ok": true,
"message": "Discovery document and key set validated.",
"issuer": "https://login.example-idp.com/"
}
}
Response fields
| Field | Type | Description |
|---|---|---|
| sso_test.ok | boolean | Whether the structural check passed. |
| sso_test.message | string | Human-readable summary, suitable for display to an administrator. |
| sso_test.issuer | string | The issuer the check ran against. |
A failed check is reported as ok: false with a message — it is a result, not a transport error. A refusal to run the check at all (wrong plan, unusable configuration) is a 4xx carrying a reason.
oidc.extra_scopes gets an advisory line in message, never a failure. The discovery document's scopes_supported is optional and often incomplete, so an unadvertised extra scope is not proof the provider will reject it — the check appends a note that discovery cannot confirm it, and ok stays true. If role mapping is configured and the provider advertises no groups scope, the check already carries a warning about that (see The Configuration Check); with extra scopes configured, that warning's wording changes from asserting no role will be applied to saying discovery cannot confirm the extra scopes either — because a scope the org added may be exactly what releases the group claim. At most one warning sentence is appended.
Response (200 OK) — mode: "signin"
{
"result": true,
"sso_test": {
"mode": "signin",
"redirect_url": "https://login.example-idp.com/authorize?{provider_parameters}",
"expires_at": "2026-04-27 16:47:29 UTC"
}
}
| Field | Type | Description |
|---|---|---|
| sso_test.mode | string | Always signin on this response. |
| sso_test.redirect_url | string | Send the administrator's browser here. Use it exactly as returned. |
| sso_test.expires_at | string | When the started test expires (Y-m-d H:i:s UTC). After that the browser comes back to a refusal instead of a result. |
The outcome does not come back on this response — the browser goes to the provider and returns to {your_origin}/signin/sso?sso-test=1&..., and the full result is read from last_signin_test on GET /current/org/{org_id}/sso/. See The Test Sign-In.
Delete the SSO Configuration
/current/org/{org_id}/sso/delete/
Auth required. Org admin. Never plan-gated — this reduces exposure. Clears the configuration and returns the org to mode: off.
Verified domains are kept. An administrator re-configuring a provider — switching vendors, rebuilding a broken configuration — does not have to prove domain ownership again. Release a domain explicitly if you want it gone.
Existing SAML sign-in bindings are released when the configuration is deleted, and re-established on the next sign-in. Each person's SAML binding is let go along with the configuration, and rebuilt the next time they sign in — matched by their verified email address, and bound under whatever the new configuration asks for. A SAML configuration recreated with a different NameID format therefore cannot orphan them. OpenID Connect bindings are kept, because an OpenID Connect account can exist on an address the provider never verified and is recognised on return by its bound subject alone; releasing one would lock that person out rather than re-link them. Accounts, memberships, directory-provisioned records and deprovisioned identities are all left exactly as they were.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/delete/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"sso": {
"mode": "off",
"domains": [
{
"domain": "acme.com",
"verified": true,
"verified_at": "2026-04-26 09:14:02 UTC",
"txt_record": {
"name": "_fastio-verification.acme.com",
"value": "fastio-verification={verification_token}"
}
}
]
}
}
Claim a Domain
/current/org/{org_id}/sso/domains/
Auth required. Org admin. Enterprise plan required.
Request parameters
| Name | Type | Required | Description |
|---|---|---|---|
| domain | string | Yes | The email domain to claim. Stored lowercase; supply an internationalized name in its ASCII (punycode) form. Max 255 characters. |
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/domains/" \
-H "Authorization: Bearer {jwt_token}" \
-d "domain=acme.com"
Response (200 OK)
{
"result": true,
"domain": {
"domain": "acme.com",
"verified": false,
"verified_at": null,
"txt_record": {
"name": "_fastio-verification.acme.com",
"value": "fastio-verification={verification_token}"
}
}
}
Publish txt_record.name as a TXT record with txt_record.value, then call the verify endpoint. The claim expires 7 days after it is created if it has not been verified by then.
A new claim fires the SSO audit event with policy_changes.domains.added carrying the name — see Events and the Audit Log. Re-claiming a domain this org already holds returns the same token unchanged and raises nothing new.
Refusals: plan_required, domain_not_allowed (public suffix, IP address, or a Fastio domain), domain_in_use (another org already holds a verified claim on this name).
Verify a Domain
/current/org/{org_id}/sso/domains/{domain}/verify/
Auth required. Org admin. Enterprise plan required. Reads DNS now and reports what it found; there is no queue and no callback.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/domains/acme.com/verify/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK) — verified
{
"result": true,
"domain": {
"verified": true,
"message": "The verification record was found and the domain is now verified."
}
}
Response (200 OK) — not yet
{
"result": true,
"domain": {
"verified": false,
"message": "No matching verification record was found. DNS changes can take time to publish."
}
}
A record that has not propagated yet — including an authoritative absence, where DNS answered and the record is simply not there — is domain.verified: false with a domain.message, a 200, not an error. Retry the verify call — re-claiming is safe: while the claim is still live it returns the same verification token, so the record you already published stays valid. Once a claim has expired, re-claiming issues a NEW token and the old TXT record no longer proves anything — publish the value the new claim returns.
A transient DNS lookup failure (the resolver did not return an authoritative answer at all) is a different case and is reported as its own retryable error, never as verified: false — telling an admin their record is missing when DNS simply did not answer sends them off editing a configuration that was already correct.
Release a Domain
/current/org/{org_id}/sso/domains/{domain}/delete/
Auth required. Org admin. Never plan-gated — this reduces exposure. Releases the claim, verified or pending; the name becomes claimable by any org again.
curl example
curl -X POST "https://api.fast.io/current/org/1234567890123456789/sso/domains/acme.com/delete/" \
-H "Authorization: Bearer {jwt_token}"
Response (200 OK)
{
"result": true,
"domain": "acme.com"
}
Refusal: last_verified_domain — the org is enforcing SSO and this is its last verified domain. Step the mode down first, then release.
Refusal Reasons
A refused request carries a reason string in error.params. Branch on reason. Do not branch on the numeric error.code (assigned per endpoint, so the same condition reports different numbers from different routes) and do not branch on the HTTP status alone (one status covers several unrelated conditions).
params.reason | What happened | What the caller should do |
|---|---|---|
plan_required | A configuration write was attempted on a plan without Enterprise SSO. | Offer an upgrade. Read capabilities.sso on the org rather than probing with a write. |
domains_unverified | Enforcement was requested with no verified domain. | Claim and verify at least one domain first. |
test_required | Enforcement was requested without a successful configuration check for the current configuration. | Run POST .../sso/test/, then retry. |
domain_in_use | Another organization already holds a verified claim on this domain. | Only one org can own a domain. Two orgs may hold pending claims; only one may verify. |
invalid_config | The submitted configuration is not usable — a malformed URL or certificate, a field the chosen protocol does not accept, or a combination that cannot work. | Fix the values and resubmit. |
domain_not_allowed | The name cannot be claimed: a public suffix (com, co.uk), an IP address, or a Fastio domain. | Claim a domain your organization actually controls, in its ASCII (punycode) form. |
enforcement_unavailable | mode=required was selected for an organization that may not enforce — enforcement_available is false. Where a single prerequisite is the blocker, the refusal names it instead (plan_required, domains_unverified, test_required). | Read enforcement_available and satisfy the prerequisites in Enforcement, rather than probing with a write. |
last_verified_domain | A release would leave an enforcing org with no verified domain. | Step the mode down, then release the domain. |
nameid_format_locked | A SAML NameID format change was attempted after the organization already has federated identities bound to it. | Leave the format as it is. Changing it would orphan every one of those identities, so it is refused rather than allowed to strand them. |
sso_enabled | A test sign-in (mode: "signin") was requested while mode is not off. | Run test sign-ins during setup. Once SSO is offered, a real sign-in is the test. |
metadata_unreachable, metadata_too_large, metadata_invalid, metadata_no_signing_cert, metadata_unsupported, metadata_conflict, metadata_conflicts_with_manual_fields, metadata_requires_saml | A SAML metadata import could not be completed. | Each names something specific — see Importing the Provider's Metadata. |
Error envelope. These follow the standard envelope documented in the API overview — result: false and an error object carrying code, text and params:
{
"result": false,
"error": {
"code": 123456,
"text": "Enterprise Single Sign-On is not available on your current plan.",
"params": {
"reason": "plan_required"
}
}
}
SCIM 2.0 Provisioning
Just-in-time creation covers most organizations: a person signs in and the account appears. SCIM covers what JIT cannot — removal. An identity provider that speaks SCIM 2.0 can create people ahead of their first sign-in, keep their details current, put them in groups that map to an organization role, and deprovision them the moment they leave, without anybody signing in to make it happen.
SCIM is optional. An organization that does not configure it loses nothing that SSO already gives it.
Base URL for the endpoints below: https://api.fast.io/v1.0/scim/v2/
The Token
POST /current/org/{org_id}/scim/token/ mints the bearer the identity provider will use. It is returned once, in that response, and is not recoverable afterwards — only a keyed hash of it is stored. Minting again rotates the token: by default the previous one stops authenticating the moment the new one is written, so re-point the provider at the new value before you press it a second time. An optional overlap_seconds (0..86400, default 0) keeps the outgoing token authenticating for that many seconds instead, so provisioning does not stop while the provider is re-pointed — see Rotating with an Overlap below.
| Endpoint | Who | Plan-gated | Returns |
|---|---|---|---|
GET /current/org/{org_id}/scim/ | Org admin | No | {enabled, token_set, created, last_seen, last_error, last_error_at, previous_token_expires_at, base_url} |
POST /current/org/{org_id}/scim/token/ | Org admin | Yes | {token, base_url}, shown once. Body: overlap_seconds (integer, 0..86400, default 0). |
POST /current/org/{org_id}/scim/token/revoke/ | Org admin | No | revoked |
Rotating with an Overlap
By default, rotating the token is an immediate cutover: the previous token stops working the instant the new one is minted. Sending overlap_seconds above zero keeps the outgoing token authenticating for exactly that many seconds afterwards, so a directory sync mid-flight against the old value does not fail while the identity provider is being re-pointed.
There is one predecessor slot. A second rotation while an overlap is still open drops the first predecessor — only the immediately preceding token can ever authenticate alongside the current one, never two generations back. overlap_seconds: 0 also ends an overlap already in flight, so a rotation can be used to cut one short.
The overlap never resurrects a revoked credential. A token that was revoked is not carried into an overlap on the next rotation, and revoking the current token clears any predecessor overlap that was still open. POST .../revoke/ is always an immediate, total cutoff for whatever it revokes.
previous_token_expires_at on GET /current/org/{org_id}/scim/ is null when no overlap is in flight, a future time while the previous token still authenticates, and a past time once that window has closed — so "still usable" and "expired" are distinguishable from the same field rather than collapsing to one absent value.
A presentation after the window closes is an attributed refusal. last_error gains a fourth value, previous_token_expired: the identity provider presented the token that used to be current, but its overlap window has passed. A presentation of that same token inside the window succeeds silently, exactly like the current one. The predecessor slot itself stays in place for attribution until the next rotation or revocation replaces it, so a later presentation of that same expired token is refused the same way again; last_error is cleared by the next successful call, exactly like every other reason.
enabled reports whether the plan carries the feature; token_set reports whether a usable token exists right now. Revocation is deliberately never plan-gated and is safe to repeat: it is the lever an administrator reaches for when a token has leaked, and a plan check standing between them and it would be a control that fails exactly when it is needed.
last_seen and last_error answer two different questions, and only one of them is "is this working?"
last_seenis when a request last authenticated successfully. This is the health field.last_erroris a short description of the last refusal that could be attributed to this organization's token, withlast_error_atfor when. There are exactly four: the token had been revoked, the presented bearer did not match the stored hash, the token names an organization that cannot be provisioned into, and — see Rotating with an Overlap — a rotation predecessor was presented after its overlap window closed (previous_token_expired).
A null last_error does not mean the integration is healthy. The two refusals an administrator most wants to see — a request that presented no bearer at all, and a bearer that matches no token anywhere — carry no organization, so there is nothing to record them against and they are not recorded. That is deliberate: a field that any anonymous caller could write into would be a field that anyone on the internet could fill with noise for an organization they have never heard of. Read last_seen to answer "is the provider getting in", and last_error only to explain a specific failure. Say as much in the interface; a bare "no errors" badge over this field is a lie an administrator will act on.
last_error never contains the bearer, the Authorization header, or any part of a token — the stored text is a fixed phrase, not something derived from the request. Both fields are cleared by the next successful call, and reset when the token is re-minted, so a fresh token never displays the previous one's failure. Repeated identical refusals are written at most periodically rather than on every request; a change of reason is recorded immediately.
Talking to the SCIM Endpoints
The token goes in the Authorization header, as Bearer {scim_token}, and only there. A token supplied in a request body or a query string is not accepted.
Every response, including every error, is application/scim+json and uses SCIM's own Error document rather than the platform envelope used elsewhere on this page. Status codes are SCIM's: 201 on create, 204 on delete, 409 on a uniqueness conflict, 400 with a scimType on a bad request.
The three discovery documents — ServiceProviderConfig, Schemas and ResourceTypes — are unauthenticated, so a client can read what this service supports before it has been given a token. Read ServiceProviderConfig first: it declares that patch and filter are supported and that bulk, sort and etag are not.
| Endpoint | Methods | Auth | Returns |
|---|---|---|---|
/v1.0/scim/v2/ServiceProviderConfig | GET | none | The RFC 7644 §4 capabilities document — which of patch/filter/bulk/sort/etag are supported, and the bearer-token authentication scheme to use once a token has been minted |
/v1.0/scim/v2/ResourceTypes | GET | none | The two resource types this service exposes (User, Group) — each one's routed endpoint and its schema URN |
/v1.0/scim/v2/Schemas | GET | none | The full attribute schema for User and Group, per RFC 7643 |
Resources
| Endpoint | Methods |
|---|---|
/v1.0/scim/v2/Users | GET (filter, startIndex, count), POST |
/v1.0/scim/v2/Users/{id} | GET, PUT, PATCH, DELETE |
/v1.0/scim/v2/Groups | GET, POST |
/v1.0/scim/v2/Groups/{id} | GET, PUT, PATCH, DELETE |
{id} is the SCIM resource id this service returned when the resource was created. It is not the organization id, the user id, or any other identifier that appears elsewhere on this page. An id belonging to another organization does not resolve: it answers 404, exactly as an id that never existed does.
The user list holds only the people the directory owns. GET /Users — with a filter or without one — returns the identities this service provisioned, and nothing else. Somebody who arrived through just-in-time sign-in and has not been adopted does not appear, because there is no resource id an endpoint could address them by. Read that absence as "not provisioned", never as "not here": POST the person by userName and the service adopts the identity that already exists rather than answering 409. See Creating and Updating a Person.
Filtering supports one form, attribute eq "value", on userName and externalId for Users and on displayName and externalId for Groups. Anything else — a different operator, and/or/not, a value path — is refused with 400 and scimType: invalidFilter. It is refused rather than ignored on purpose: a client that received every user back for a one-user query would provision against the wrong answer.
Pagination is SCIM's: startIndex is 1-based and count defaults to 100, capped at 200. Reading the same startIndex twice returns the same page.
meta.created and meta.lastModified on a SCIM resource use RFC 3339 (2026-04-27T16:37:29Z), because RFC 7643 requires that format of every SCIM service. Every other timestamp on this page, including created and last_seen on the token endpoints above, uses the usual 2026-04-27 16:37:29 UTC form.
Creating and Updating a Person
userName and the primary email must be the same address, and that address must sit inside a domain the organization has verified. Both rules follow from what an organization's claim actually is: proof of control over a domain, not over an individual mailbox. A record whose handle and address disagree is refused rather than resolved in favor of one of them.
After a person is provisioned, name and emails are immutable unless the account belongs to this organization and no other — a 400 with scimType: mutability otherwise. Somebody who is also a member of another organization has an account that organization can see too, and one provider rewriting a shared account would reach across a boundary it never had a claim over. externalId is always writable, and so is active — but only when the record actually states it. A PUT that omits active leaves the person's state alone rather than treating the omission as true, so a routine profile refresh cannot reactivate somebody the organization removed.
A POST carrying active: false for somebody who is already a member of the organization is an offboarding, and runs exactly the steps below. A first synchronization often sends a provider's whole directory, leavers included, and treating those records as quiet creates would leave the people in question with their access intact.
A POST for somebody who has already been signing in through SSO adopts that identity instead of answering 409. Their account, their federated subject and the role they currently hold are all kept, and the response is the ordinary 201 with the resource. This is the repair path for an organization that has moved to provisioning_mode: "scim_only" and needs to bring its existing just-in-time members under the directory — provision each of them, and they can sign in again straight away. The organization's default role is applied only where the person has no membership at all: an adoption fixes the identity record, it never re-levels somebody. Adoption requires an active create. Three cases still answer 409: a person the directory already owns, one who has been deprovisioned, and a create carrying "active": false over a live just-in-time member — that last one is an offboarding written as a create, so it neither adopts nor offboards. To offboard such a person, adopt them first with an active create, then DELETE or PATCH with active: false using the resource id that create returned — until they have been adopted they are not in the user list and have no resource id at all, so DELETE and PATCH have nothing to reach.
Removing a Person
DELETE /Users/{id} and PATCH with active: false are the same operation. Both:
- mark the identity deprovisioned, so a later SSO sign-in is refused with
reason=deprovisioned, an invitation to this organization cannot be accepted, and automatic domain join does not apply; - revoke the person's existing sessions;
- remove their organization membership;
- transfer any workspace they owned to the organization owner, remove them from the workspaces they belong to, and clear the shares they held inside those workspaces.
The first three happen before the response. The workspace cleanup completes shortly afterwards, and is scheduled before the membership is removed so it cannot be lost.
The account itself is not deleted, and personal shares are untouched. Deprovisioning removes the person from this organization — its membership, its workspaces, and the shares held inside those workspaces — but their Fastio account survives, and any share they own directly rather than through an organization workspace is not touched by this cascade at all. Somebody removed from one organization keeps their account and their own shares, and can still be found and re-provisioned into this organization, or any other, later.
Repeating either is a success — identity providers retry, and a retry that failed would look like an outage. A repeat that finds the removal unfinished finishes it, so a removal interrupted partway through is completed by the provider's own retry rather than being reported as already done. PATCH with active: true puts the person back at the organization's default role, provided their address still sits in a verified domain.
The organization owner and its last remaining administrator cannot be deprovisioned. Either attempt is refused with 400. A mis-scoped group in an identity provider must not be able to lock an organization out of itself.
Groups and Roles
A group whose displayName matches an entry in the organization's role mapping raises its members to that role. Membership is edited with PATCH, including the members[value eq "{scim_user_id}"] form used to remove one person without resending the group.
Role mapping through SCIM follows the same rule as sign-in: it promotes and never demotes. Taking somebody out of a mapped group does not lower their role, and neither does deleting the group. Lower a role from the organization's own member list.
Events and the Audit Log
Every SSO write emits two things:
- the ordinary organization-updated event, so a client already watching the org refetches without knowing anything about SSO; and
- a dedicated SSO audit event, so the change is attributable in the organization's audit log.
Configuration changes, deletions, and domain verification and un-verification all appear there. See the Events & Activity reference for reading the audit log; administrative event reads require an admin-capable credential.
The SSO audit event carries a value payload for mode and domains — nothing else. updates names every field a request changed, but only as field names: a credential, a certificate or a claim map never appears there, on purpose. Two fields are different, because "the mode changed" says nothing about which direction and "domains changed" says nothing about which domain: the audit event's policy_changes map carries mode: {before, after} when the mode moved, and domains: {added, removed} when a domain was claimed. Both are derived from the state actually committed under the write lock, not from a separate read taken earlier, and a save that did not move either contributes nothing to policy_changes. Claiming a domain now raises this event on its own — it used to raise only the generic organization-updated event, leaving the claim itself off the audit trail.
A new event, org_sso_certificate_expiring, is raised by the daily certificate-expiry check — once per crossed threshold per certificate (30/14/7/1 days out, and then daily once expired). It carries no acting user: nothing in the organization changed, this is a notification about what is about to.
SCIM writes are audited too — provisioning, deprovisioning and group changes each raise their own event. Those carry no acting user, because there is not one: the actor is an identity provider holding a token rather than a person, and an audit log that named somebody would be naming the wrong somebody. A client rendering the log should show those entries as originating from the identity provider.
A SCIM write also moves the organization's ordinary membership events. Adding somebody raises the usual member-added event, removing them the usual member-removed event, and a role change the usual membership-updated event — so a client that already watches org membership sees directory-driven changes without having to know SCIM exists. Those events carry no acting user either, for the same reason as the dedicated ones: the request came from a token, not from a signed-in person, and there is no session behind it to name. Render them as coming from the identity provider rather than drawing an empty name where an actor would be. They fire only when something actually changed — a role that was already high enough, or a membership that was already gone, raises nothing.
The configuration object does not say who changed it. updated tells you when the configuration last changed; the acting user is recorded on the organization's audit events, which outlive the configuration itself. Read the audit log when you need attribution.
Getting It Wrong — the Mistakes Worth Designing Against
- Treating a passing check — structural or test sign-in — as proof that a user can get in. The structural check reads the configuration. The test sign-in goes further and runs the protocol and the claims, but stops there: it checks neither domain eligibility, nor
scim_onlyadmission, nor tombstones, nor membership. Run both, then setmodetooptionaland have a real non-administrator user sign in before enforcing. - Reading
last_test.okwhen you meanttested. After a credential change,last_test.okcan still betrue— andtested_atrecent — whiletestedisfalse.testedis the one that describes the configuration you are looking at. - Treating
issuer_shared: trueas an error. It is advisory, and it is legitimatelytruefor a company running several organizations on one identity provider. - Assuming a DNS failure un-verified a domain. It takes three consecutive checks that confirm the record is absent; a resolver failure is not one of them.
- Flipping to
scim_onlywithout provisioning the people who are already here. Existing members who arrived through just-in-time creation are refusednot_provisionedat their next sign-in, exactly like strangers. Provision them first, or be ready to provision them the moment the tickets arrive. - Reading
last_erroron the SCIM status as a health check. It records only refusals attributable to the organization's own token.nullmeans "nothing was attributed", not "everything is fine" —last_seenis the field that says the provider is getting in. - Routing the test sign-in's landing into the sign-in error handler.
signin/sso?sso-test=1&...is a third outcome, not a login that failed. A passing test reported to an administrator as a broken one costs an afternoon.