Single Sign-On (SSO)
Team organizations can connect their own identity provider (IdP) so members sign in with corporate credentials. UptimeHunt supports OIDC, SAML 2.0, Google Workspace, and GitHub.
Single Sign-On (SSO)
Overview
Team organizations can connect their own identity provider (IdP) so members sign in with corporate credentials. UptimeHunt supports:
- Generic OIDC — any OpenID Connect provider (Okta, Entra ID / Azure AD, Auth0, Keycloak, Authentik, …)
- SAML 2.0 — any SAML identity provider
- Google Workspace — Google sign-in restricted to your Workspace domain
- GitHub — sign-in with GitHub accounts, gated by membership of your GitHub organization
Setting up SSO is just registering an application at your IdP: client ID + client secret + the callback URL UptimeHunt shows you (for SAML: entity ID, SSO URL, and signing certificate) — save it, sign in once, done. There is no DNS step: domain verification is a separate, optional convenience that makes the login page advertise your IdP to anyone typing a work email; it is never a prerequisite for the connection, for JIT provisioning, or for enforcement.
Each organization configures one identity provider. Configuration requires the Owner role (sso.view / sso.edit) and is managed in Organization → Workspace settings → Authentication or via the API:
| Endpoint | Purpose |
|---|---|
GET /api/v1/orgs/{slug}/sso | Current IdP configuration (secrets are write-only — never returned) |
PUT /api/v1/orgs/{slug}/sso | Create or replace the IdP configuration. Switching provider_type is refused with 409 Conflict while JIT-provisioned realm accounts still anchor to the current provider (details) |
GET /api/v1/orgs/{slug}/sso/urls?provider_type={type} | The computed SP endpoints (callback / ACS / metadata) for a provider type — works with no saved configuration, so the URLs can be registered at the IdP first (examples in each setup guide) |
DELETE /api/v1/orgs/{slug}/sso | Remove the IdP configuration — refused with 409 Conflict while JIT-provisioned realm accounts still anchor to it |
POST /api/v1/orgs/{slug}/sso/test | Validate the stored IdP configuration against the live IdP (details) |
Plan requirement
Creating, changing, or deleting an IdP configuration (and adding, verifying, or deleting a
routing domain) requires the Pro plan or above — below Pro
these calls are refused with 403 and {"code": "SSO_NOT_IN_PLAN"}. An already-configured IdP
keeps working, and reading the configuration, sso/urls, and sso/test stay open on every
plan — only changes require Pro, so a grandfathered configuration is never disrupted by a
downgrade.
All SSO configuration changes, sign-ins (success and failure), and JIT provisioning events are recorded in the audit log.
The Authentication tab layout
Organization → Workspace settings → Authentication presents one identity provider, top to bottom, in the order you work through a setup:
- Provider — pick the IdP type (OIDC, SAML, Google Workspace, GitHub) or Disabled. Each type keeps its own draft, so switching the dropdown never carries one type's credentials into another.
- Service Provider (SP) endpoints — the read-only callback / ACS / metadata URLs to register at your IdP, with copy buttons. They are computed from the organization slug and shown before the first save, so you can configure the IdP first.
- Credentials — the IdP-specific fields (client ID / secret + issuer for OIDC, entity ID / SSO URL / certificate for SAML, and so on).
- Role mapping (OIDC / SAML / Google Workspace) — map the groups your IdP asserts to UptimeHunt roles (Role Mapping). GitHub maps GitHub teams instead.
- Provisioning — the JIT toggles, default role, email-domain allowlist, and the Apply role mapping only at account creation (skip sync on login) toggle (
skip_role_sync). - Enforcement — the Enforced SSO toggle and its lockout-preview.
- Email routing — the optional, DNS-verified routing domains that advertise this IdP on the login page (a separate card, never a prerequisite for the connection).
How SSO Login Works
UptimeHunt login is email-first:
- The user enters their email address on the login page.
- UptimeHunt looks up every sign-in route available for that email — a password account and/or organization IdPs. A route is advertised when the email's domain is verified for email routing by an organization with an enabled IdP, when an SSO account with that email already exists, or when the email's password account is a member of an organization with enforced SSO — membership grants the route even without a verified domain.
- If the only route is an organization that enforces SSO and no password route exists, the browser is redirected straight to that IdP — no chooser, no password prompt.
- Otherwise the user picks a route from an account chooser (e.g. Personal, acme, umbrella-corp); routes an organization requires are badged accordingly.
- Choosing an SSO route redirects the browser to the organization's IdP; after authentication the user lands back in UptimeHunt, signed in to that organization account.
API clients implementing this flow: see API Authentication — SSO endpoints.
Signing In Without Email Routing ("Use single sign-on")
Email routing only works when UptimeHunt can map the email to your IdP. Three entry points cover everything else:
-
Organization sign-in link (recommended) — share
https://app.uptimehunt.io/login?org={your-org-slug}with your members. The login page opens an organization-scoped panel showing your organization's name and only its configured sign-in method (e.g. a single Sign in with GitHub button). This is the canonical first-sign-in path for organizations whose IdP is never email-routed (such as GitHub) — no DNS, no email lookup, the link itself carries the organization context. An invitation token can ride along (
&invite={token}): the invitation is then accepted by the account that completes the SSO sign-in. An unknown slug shows the same neutral "no single sign-on available" message as the slug-entry path below. -
"Use single sign-on" on the login page — the email step offers a Use single sign-on link that asks for the organization slug instead and sends the browser straight to that organization's IdP. This is the fallback whenever the email lookup does not offer your organization and you don't have the sign-in link at hand. Entering a slug with no live SSO — an unknown organization, no identity provider, or a disabled one — shows the same neutral "no single sign-on available" message; the login page does not reveal whether an organization exists. API clients implement this with the
org_slugform of the lookup endpoint. -
Member sign-in URL — Organization → Authentication shows a direct IdP sign-in link once the configuration is saved and
enabled(theurls.loginvalue in the API response). It skips the login page entirely and starts the IdP flow immediately.
Identity Realms: One Email, Multiple Accounts
UptimeHunt treats email as a display attribute, not an identity key. Each organization IdP is a separate identity realm, and SSO identities are keyed by the IdP's stable subject identifier (OIDC sub / SAML persistent NameID) within that organization's IdP — never by email address.
Consequences:
- The same email address can exist on several separate accounts: at most one password account, plus one account per organization IdP that provisioned it. For example,
alex@example.commay simultaneously be a personal password account, an account JIT-provisioned by acme's IdP, and an account JIT-provisioned by umbrella-corp's IdP — three distinct accounts with separate resources, sessions, and tokens. - These accounts are never merged or linked — not automatically, not manually. An IdP asserting an email that matches an existing password account does not gain access to that account; it gets (or creates) its own realm account.
- At login, the account chooser lists all routes for the email; each signs you into its own account. The in-app switcher can remember multiple accounts on the same browser and swap between them (re-authenticating through the right realm when a session has expired).
- A changed email at the IdP does not create a new account — identity follows the IdP subject, the email is just updated for display.
GET /api/v1/auth/mereports which realm the current account belongs to (password,socialwith the provider, orssowith the organization), and audit-log actor labels include the realm so same-email accounts stay distinguishable.- The same model covers platform social sign-in: a Google- or GitHub-signed-in account is its own realm, separate from any password account with the same email.
Why no account linking?
Automatic email-based linking is the classic SSO account-takeover vector — an IdP (or a user who controls one) could assert any email and capture the matching account. Keeping realms strictly separate removes that class of attack entirely, at the cost of the same human possibly holding more than one account. This matches how OAuth providers expose emails (often only a primary, user-changeable email).
Just-in-Time (JIT) Provisioning
With jit_provisioning enabled (the default), a successful SSO sign-in by a user with no existing realm account creates one automatically and adds it to the organization with the IdP's configured default_role (Member unless changed). JIT accounts:
- are organization accounts only — they get no separate personal workspace;
- skip email verification (the IdP already verified the identity);
- emit an
sso.user.provisionedaudit event.
The gate is your IdP itself: whoever your identity provider authenticates is whom your organization admits. There is no email-domain gate by default — asserted emails are display attributes (identity realms), and a verified domain is never required for JIT (an optional declared domain allowlist exists — below). Two situations need an explicit scope on top, because there the "IdP" is shared infrastructure that authenticates everyone:
- Shared issuers (Google Workspace, and generic OIDC pointed at
accounts.google.comor Microsoft's multi-tenantlogin.microsoftonline.com/{common,organizations,consumers}endpoints): every Google/Microsoft account in the world can authenticate against the shared issuer, so a JIT-enabled configuration must declareworkspace_domains— the list of hosted domains whose accounts belong to your organization (for Google, validated against the ID token'shdclaim). The API refuses (400) to save a JIT-enabled configuration without it. This is a declared list in the IdP config — no DNS verification involved. - GitHub sign-in (platform App, GitHub membership mapping): any GitHub account can sign in with the platform button, so JIT requires the
github_orgmembership restriction; the API refuses a GitHub IdP configuration wheregithub_orgis not set.
Three organization-side policies apply to all IdP types:
jit_requires_invitation— JIT only provisions users holding a matching pending invitation (defaultfalse).allowed_email_domains— an optional email-domain allowlist for JIT (default empty — no filtering).- The plan's member limit applies to JIT exactly as it does to invitations — provisioning is refused once the organization is at its member cap.
With JIT disabled, only users who already have a realm account (e.g. created via invitation) can sign in through the IdP.
What about a hostile or misconfigured IdP?
An organization's IdP can only mint accounts inside that organization — JIT accounts are realm-scoped, never linked to anyone else's account, and bounded by the member cap, with every provisioning audited. That isolation is why no domain proof is needed: a DNS record would prove control of DNS, not who the IdP authenticates.
Optional Email-Domain Allowlist
allowed_email_domains (a top-level field in the PUT /orgs/{slug}/sso body, available on every provider type) restricts which asserted emails JIT will provision. Empty or unset — the default — means no domain filtering. When the list is non-empty, a sign-in asserting an email whose domain is not in it is refused at provisioning: the user lands back on the login page with the SSO error email_domain_not_allowed (/login?sso_error=email_domain_not_allowed&org={slug}) and no account is created.
{"allowed_email_domains": ["acme.com", "acme-contractors.com"]}- The list is declared, never DNS-verified — if the organization admin limits domains, the domains are limited; no proof of ownership is involved. It is entirely separate from the DNS-verified email-routing domains (which only drive the login page's route advertisement) and orthogonal to the shared-issuer
workspace_domainsscope (which constrains who can authenticate at a shared issuer, not which asserted emails JIT accepts). - It applies at provisioning time only: members whose realm accounts already exist keep signing in regardless of later list changes, and the list never affects email routing or enforcement.
- Sending
[](ornull) in aPUTclears the list; omitting the field keeps the stored value.
Role Mapping
By default every member who signs in through the IdP gets the same blanket role — the IdP's default_role (Member unless changed). Role mapping refines that: it reads the groups your IdP asserts at sign-in and grants a per-group role, so (for example) your platform-admins group becomes Admins while everyone else stays Member.
Role mapping is configured per provider and works the same way for every type — only where the group values come from differs:
| Provider | Mechanism | source is… | Per-IdP guidance |
|---|---|---|---|
| OIDC | groups claim → role | an OIDC claim name (default groups) | OIDC claim names |
| SAML 2.0 | SAML attribute → role | a SAML assertion attribute Name | SAML attribute names |
| Google Workspace | groups claim → role | a custom Workspace claim (native Google tokens carry no groups — caveat) | use a custom claim, or Google Workspace SAML |
| GitHub | GitHub team → role | a GitHub team slug | Team-role mapping guide |
For OIDC / SAML / Google the mapping is stored in the IdP config as a single role_mapping object; for GitHub it is two organization properties (team-role mapping). Both share the same evaluation rules described below.
How rules are evaluated
A role_mapping is {source, rules, unmapped_policy, overage_action}:
{
"source": "groups",
"rules": [
{"value": "platform-admins", "role": "owner"},
{"value": "sre", "role": "admin"},
{"value": "engineering", "role": "member"}
],
"unmapped_policy": "deny",
"overage_action": "deny_new"
}-
source— the claim/attribute name to read the member's groups from. Leave it blank to disable role mapping (the blanket default role applies). Emptyrulesalso disables it. -
rules— an ordered list of{value, role}pairs. First match wins: the rules are scanned top-to-bottom and the first rule whosevalueis one of the member's group values decides the role — so order rules most-privileged first. Reordering changes the outcome.valueis matched verbatim and case-sensitively against each asserted group value — IdPs do not normalize case, and leading/trailing characters are significant (a Keycloak full-path group is/parent/childwith the leading slash; a Google group is its full email address).- At most 50 rules; each
valueis non-empty, ≤ 255 characters, and unique (a duplicatevaluecould never win under first-match, so it is rejected as almost certainly a mistake).
-
unmapped_policy— what happens to a member matched by no rule. Only consulted while at least one rule exists:Policy (UI label) Value Effect Use default role ''No role change — the policy makes no decision, so the member keeps whatever role they currently hold. A brand-new JIT member has no role yet and therefore gets the IdP's default_role; an existing member keeps their current role, and a manual promotion is not reverted todefault_role. Choose this to combine rule-based mapping with manual role managementMember memberGrant Member Admin adminGrant Admin Refuse sign-in denyDeny the organization entirely — the member cannot sign in. Use only when every member who should have access belongs to a mapped group `unmapped_policy: deny` + JIT provisioning = new users are refused at first login
These two features pull in opposite directions, and the docs of each don't make the collision obvious on their own. JIT provisioning is meant to auto-onboard anyone your IdP authenticates — but
unmapped_policy: denyrefuses anyone matched by no rule, and that check runs on the very first sign-in, before the account is created. So a brand-new user whose asserted groups are not in any rule is denied at first login even though JIT is on — no account is provisioned and they see a sign-in refusal (sso.role_mapping.denied) with no obvious cause. Only enabledenywhen every member who should have access belongs to a mapped group; if you want auto-onboarding for unmapped users instead, giveunmapped_policya concrete role (e.g.member) or leave it''(default role). (This is independent ofoverage_action, which governs the unreadable-claim case, not a clean no-match.) -
overage_action— what to do when the IdP cannot deliver the groups claim at sign-in (a lookup failure, distinct from an empty group set, which is a clean no-match). The only provider that triggers this today is Microsoft Entra ID, which omits the groups claim and replaces it with a_claim_names/_claim_sourcesindirection once a user is in more than 150 groups (SAML) / 200 groups (OIDC) — see SAML / OIDC. Unset, blank, or unknown values fall back todeny_new:Value Effect on overage deny_new(default)An existing member keeps their current role and signs in; a brand-new just-in-time sign-in is refused — its group membership cannot be verified — regardless of unmapped_policyallow_existingNever deny on overage: an existing member keeps their role, and a new account is JIT-provisioned at the invitation / default_role(no role decision is taken from the unreadable claim)ignoreTreat the unreadable claim as an empty group set and run the normal rule / unmapped_policyresolution (sounmapped_policygoverns both new and existing accounts)`allow_existing` admits brand-new users, not just existing ones
Despite the name,
allow_existingis not the conservative "only let my current team through" option —deny_newis. On overage,allow_existingJIT-provisions a brand-new account for anyone your IdP authenticates: because the groups claim is unreadable, their group membership is never verified and role mapping is skipped entirely, so they land at the invitation /default_role. If your tenant has heavy-group-membership users (the only ones who trigger overage) and any of them could be unwanted, preferdeny_new(the default) so an unverifiable new sign-in is refused, and admit individuals by invitation or manual role assignment instead.allow_existingonly makes a security-relevant difference for new accounts — existing members keep their role under every value.
Roles, and the Owner warning
Rule roles are Member, Admin, or Owner. Owner is permitted — an organization may intentionally map a privileged group (e.g. a small founders team) to Owner — but the editor flags the Owner option as powerful: granting Owner via group mapping hands full control to whoever your IdP puts in that group, so verify exactly who is in it. (The blanket default role still cannot be Owner; only an explicit rule can grant it.)
The sole Owner is never locked out by role mapping (last-Owner guard)
A mapping result that would demote the organization's only Owner is refused and skipped, and a deny result for the only Owner does not refuse their sign-in either — they keep their role and sign in normally. Re-syncing can never leave an organization with no Owner, nor lock its last Owner out of it. (The same last-Owner invariant the member-role API enforces.)
Re-synced on every sign-in
By default the mapping is re-evaluated every time a member signs in, not just at their first provisioning — so moving someone between IdP groups updates their UptimeHunt role at their next sign-in (a role change emits sso.role_mapping.applied; for GitHub teams, github.team_role.applied). The skip_role_sync toggle below changes this to create-time only. Decisions are audited in the organization audit log:
| Event | When |
|---|---|
sso.role_mapping.applied | A sign-in granted or changed a member's role from a rule (or the unmapped policy) |
sso.role_mapping.denied | A deny outcome refused the sign-in |
sso.role_mapping.lookup_failed | The IdP's groups could not be read (e.g. a Microsoft Entra ID group overage — see SAML / OIDC — where the groups claim is replaced by an indirection). Treated as a lookup failure, not an empty group set, and resolved by overage_action (default deny_new): existing members keep their role, and a brand-new just-in-time sign-in is refused — lookup failures block new provisioning regardless of unmapped_policy (unless overage_action is allow_existing, which provisions at default_role, or ignore, which runs the normal unmapped_policy against an empty group set) |
Re-sync overwrites manual role changes for mapped members (unless `skip_role_sync`)
While re-sync is on (the default), the role is recomputed on every sign-in, so manually changing a member's role does not stick if a rule maps one of their groups — their next sign-in re-applies the rule's role (audited sso.role_mapping.applied). Rule-based mapping and manual role management do not mix for the same member unless you enable skip_role_sync, which is the documented escape hatch: with it on, rules run only at account creation and manual overrides are never reverted. Manual roles are also preserved when the mapping reaches no decision for that member: re-sync only runs when a source and at least one rule are configured (with no role mapping at all, manual roles are never touched), and a member matched by no rule under unmapped_policy: '' keeps whatever role they currently hold — including a manual promotion. The last-Owner guard additionally protects the sole Owner from any demotion.
Provisioning-only mapping (skip_role_sync)
skip_role_sync (a top-level field on the PUT /orgs/{slug}/sso body, every provider type, default false) turns role mapping into a create-time-only operation. The UI exposes it on the Provisioning card as Apply role mapping only at account creation (skip sync on login).
- Default (
false) — rules are re-evaluated on every sign-in, as described above. true— rules run only when the account is first JIT-provisioned; subsequent sign-ins make no role decision at all (nosso.role_mapping.applied, nodeny, nolookup_failed). This lets admins manually override a mapped member's role without the next sign-in reverting it — the trade-off is that later IdP group changes no longer flow through automatically; adjust roles in UptimeHunt directly.- It has no effect unless a
role_mappingis configured (nosource/ emptyrulesalready means no sync), and it does not change first-provisioning behavior: the initial role is still decided by the rules /unmapped_policy/overage_action. - The last-Owner guard and the
unmapped_policy: deny+ JIT first-login interaction still apply at create time regardless of this flag.
Switching provider types
PUT /api/v1/orgs/{slug}/sso with a different provider_type is refused with 409 Conflict — "Cannot change the provider type while SSO realm accounts are anchored to this identity provider" — while any JIT-provisioned realm account still exists under the current provider. Realm accounts are keyed to the provider that minted them (identity realms), so changing the type out from under them would orphan their identities. Remove those realm members and their accounts first, then change the provider type. (Editing fields within the same provider type is always allowed.)
Testing the Configuration
POST /api/v1/orgs/{slug}/sso/test validates the stored IdP configuration against the live IdP — save the configuration first (PUT), then test it. It requires sso.edit, takes no request body, and changes no state.
curl -X POST https://app.uptimehunt.io/api/v1/orgs/acme/sso/test \
-H "Authorization: Bearer {access_token}"Response (200 OK):
{
"data": {
"ok": true,
"provider_type": "oidc",
"checks": [
{
"name": "discovery_document",
"ok": true,
"detail": "GET https://idp.acme.com/.well-known/openid-configuration returned a well-formed discovery document"
},
{
"name": "issuer_match",
"ok": true,
"detail": "discovery 'issuer' matches the configured server_url (https://idp.acme.com)"
}
]
}
}| Field | Description |
|---|---|
ok | true only if every check passed |
provider_type | The configured provider type: oidc, saml, google, or github |
checks[] | One entry per check: name, ok (boolean), and a human-readable detail explaining the result |
The endpoint always answers 200 with per-check results — a failing check is reported as ok: false with the reason in its detail, not as an HTTP error. Error statuses are reserved for authentication/permission failures and 404 when no identity provider is configured for the organization.
What is checked depends on the provider type:
| Provider | Checks |
|---|---|
| OIDC / Google Workspace | discovery_document — the OIDC discovery document derived from the issuer is reachable and carries the required fields (issuer, authorization_endpoint, token_endpoint, jwks_uri); issuer_match — the document's issuer matches the configured issuer (a mismatch means every later ID-token validation would reject sign-ins) |
| SAML | idp_sso_url_reachable — the configured IdP SSO URL answers HTTP at all (any status code counts: IdPs routinely reject a bare GET without a SAMLRequest); idp_x509cert_valid — the stored signing certificate parses as X.509 and is inside its validity window |
| GitHub | github_app_installed — the UptimeHunt platform GitHub App is installed on the configured GitHub organization |
Additionally, when the organization uses GitHub (github_org set in the organization settings) and the platform GitHub App is available, a github_app_installed check is appended — and runs alone for organizations that use only membership mapping, with no identity provider configured (the one case where the endpoint answers without an IdP row).
Outbound requests to the IdP time out after 5 seconds each.
Test before enforcing
Run the test (and a real sign-in from a private browser window) before enabling Enforced SSO. The enforcement readiness guards make the real sign-in mandatory anyway — a completed round-trip proves more than any metadata check can.
Enforced SSO
Setting Enforced SSO on the organization requires every member to authenticate through the organization IdP. It is toggled in Organization → Authentication, or via the API with the enforced_sso boolean in the PUT /api/v1/orgs/{slug}/sso body. When enforced:
- Password login is refused for all members — the login endpoint returns
403withcode: "SSO_REQUIRED", the organization slug, the IdP redirect URL, and a ready-to-render SSO route inidps[](see the full payload), and the login UI sends the user straight to SSO. - Password reset is refused for those accounts with the same code.
- Password change is refused for those accounts with the same code (
POST /api/v1/auth/change-password) — a member holding a still-valid session cannot set or keep a local password while enforcement is on. - Existing sessions are cut immediately: enabling enforcement blacklists members' outstanding JWT refresh tokens, so browser sessions cannot quietly outlive the policy — everyone re-authenticates through the IdP.
Enforced SSO does NOT disable API tokens or OAuth grants
API tokens and previously authorized OAuth applications continue to work when SSO is enforced. This is deliberate: automation (Terraform, Ansible, the Kubernetes operator, MCP connectors, CI pipelines) authenticates with tokens and must not break when the policy flips. Enforced SSO governs interactive sign-in; machine credentials are governed by Token Oversight — review the inventory and revoke deliberately. Offboarding a member still severs all of their access regardless of token type (see Members — Offboarding).
Enabling/disabling enforcement is audited (sso.enforced.enabled / sso.enforced.disabled), and refused attempts to enable it are audited as sso.enforced.refused with the blocker codes.
Readiness Guards (Enabling Enforcement)
Enforcing against a configuration that doesn't actually work is a one-click lockout, so enabling enforced_sso: true must pass a guard chain. An unmet chain refuses the request with a structured 400 (code: "SSO_ENFORCE_BLOCKED") whose reasons array names every unmet guard:
| Code | Requirement |
|---|---|
SSO_ENFORCE_IDP_DISABLED | The identity provider must be enabled (already, or in the same request) |
SSO_ENFORCE_CONFIG_CHANGED | The same request must not also change the IdP configuration (provider type, config fields, or credentials) — a configuration modified in this very request has never been proven by a sign-in. Save the change, sign in through the IdP once, then enforce in a separate request. The enabled flag is not part of this scope: flipping enabled: true together with enforced_sso: true is allowed (see SSO_ENFORCE_IDP_DISABLED above) — turning a proven configuration on changes nothing about it |
SSO_ENFORCE_NO_OWNER_ROUNDTRIP | The requesting Owner must have personally completed a successful SSO sign-in through this IdP — evidenced by an SSO account carrying the Owner's email under this IdP, or a recorded successful SSO sign-in for it (at any time — there is no recency requirement) |
That is the whole chain: an enabled IdP plus one proven sign-in. A verified domain is not required — a DNS TXT record proves control of DNS, not that your members exist in the IdP's directory. The "every member keeps a sign-in route" property holds without it: once enforcement is on, the login lookup advertises the enforcing organization's route to every member by membership alone, and the owner round-trip proves the IdP actually completes a login. (The enforcement preview still names members who would have no advertised route before the flip.)
Disabling enforcement is never guarded (see the break-glass section).
The round-trip guard has no recency check — sign in again after every configuration change
The round-trip guard proves the IdP configuration has worked at some point, not that the current configuration works. Only a configuration change made in the same request as enforced_sso: true is refused (SSO_ENFORCE_CONFIG_CHANGED); a change saved in an earlier request is not re-proven — the guard passes on the strength of a sign-in completed against the old configuration, so enforcement can land on a broken one. After any configuration change, sign in through the IdP once more (and re-run the test) before enforcing.
The owner round-trip creates a second account for you — that is expected
Accounts are never linked across identity realms, so your verification sign-in through the IdP creates (or reuses) a separate realm account with your email, joined with the IdP's default_role. Your password-realm Owner account remains the organization Owner — the round-trip exists to prove the configuration works end-to-end.
Dry Run: Enforcement Preview
PUT /api/v1/orgs/{slug}/sso?dry_run=true (requires sso.edit) turns the request into a read-only preview: it runs the same validation and guard chain the real PUT runs and computes who would lose access, without applying anything — nothing is written, no sessions are revoked, no audit event is emitted. Include "enabled": true in the dry-run body to preview a request that would also enable the IdP in the same step.
The preview comes back as 202 Accepted — deliberately distinct from the 200/201 of a real PUT, which always carries the configuration payload, so the status code alone tells you which body shape you received.
curl -X PUT "https://app.uptimehunt.io/api/v1/orgs/acme/sso?dry_run=true" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{"enforced_sso": true}'{
"data": {
"can_enforce": false,
"reasons": [
{
"code": "SSO_ENFORCE_NO_OWNER_ROUNDTRIP",
"message": "Sign in through this identity provider once (a separate member account is created for you — expected) before enforcing SSO"
}
],
"members_without_sso_route": [
{"id": 7, "email": "pat@acme.com", "role": "member", "realm": {"type": "password"}}
],
"verified_domains": [],
"api_tokens_unaffected": true
}
}A member counts as having an SSO route when an SSO account with their email already exists under this IdP, or their email domain is one of the organization's routing domains with JIT provisioning on (the IdP can provision them on first sign-in — domain-routed provider types only: OIDC, SAML, and Google Workspace. A GitHub IdP is never email-routed, so verified domains never shrink its stranded-members list). The preview deliberately counts only routes advertised before the flip — once enforcement is on, every member additionally gets the enforcing-membership route by virtue of belonging to the organization, but their IdP-side account may still be missing, which is exactly what members_without_sso_route surfaces. Password routes never count — enforcement removes them. The UI shows this preview as a blocking confirmation before the toggle. verified_domains is informational (verified domains improve route advertisement; they are not a requirement). api_tokens_unaffected: true restates that API tokens keep working under enforcement (the warning above).
The same preview is also available as an idempotent read: GET /api/v1/orgs/{slug}/sso?include=enforcement_preview (requires sso.edit; a plain GET needs only sso.view) embeds the identical object as an enforcement_preview key inside the configuration payload. The GET evaluates the guard chain against the stored configuration — it carries no body, so there is no would-be change to consider. Use it for pre-flight checks such as page load; use the PUT dry-run to preview a specific request body.
While Enforcement Is On
- Disabling the identity provider alone is refused (
400, codeSSO_ENFORCED_IDP_DISABLED) — disable enforcement first, or in the same request. - Deleting the whole SSO configuration (
DELETE /orgs/{slug}/sso) switches enforcement off automatically. The delete itself is refused with409 Conflictwhile JIT-provisioned realm accounts still anchor to the identity provider — "This identity provider still anchors SSO realm accounts — remove those members/accounts first". Remove those realm members and their accounts first, then delete the configuration. (This applies whether or not enforcement is on — realm accounts are keyed to the IdP, so deleting it out from under them would orphan their identities.) - Routing domains can be added or removed freely — they only affect the login page's route advertisement, never whether members can sign in.
- Editing the IdP configuration is allowed while enforcement stays on — the
SSO_ENFORCE_CONFIG_CHANGEDguard only blocks enabling enforcement in the same request that changes config; a laterPUTthat changes config (rotating an expired signing certificate, updating the SSO URL, swapping a client secret) whileenforced_ssois alreadytrueis not refused. Enforcement is not auto-paused and the new configuration is not re-proven by the earlier owner round-trip — see the warning below.
Rotating a certificate or URL while enforcement is ON can lock everyone out — re-test immediately
Credential rotation (an expiring IdP signing certificate, a changed SSO/issuer URL, a new client secret) is routine maintenance, but doing it while Enforced SSO is already on is the most dangerous moment to get it wrong: enforcement keeps running across the change (it is not paused), and the round-trip guard has no recency check — it proved the old configuration once and is never re-evaluated, so a broken new certificate/URL means every member's interactive sign-in fails while password login stays refused. The whole organization is locked out.
After any configuration change under active enforcement: re-run POST /sso/test and complete one real SSO sign-in from a private window straight away. If sign-in is already broken, recover with the break-glass procedure (an Owner's API token disables enforcement; tokens keep working under enforcement by design). The safest sequence for a planned rotation is: disable enforcement → change the credential → sso/test + sign in → re-enable enforcement (which re-runs the guard chain against the new configuration).
Locked Out by Enforced SSO (Break-Glass)
Disabling enforcement is never guarded, and API tokens deliberately keep working under enforcement (the warning above). That combination is the supported self-rescue: an Owner locked out of interactive sign-in switches enforcement off with an API token —
curl -X PUT https://app.uptimehunt.io/api/v1/orgs/acme/sso \
-H "Authorization: Bearer uh_..." \
-H "Content-Type: application/json" \
-d '{"enforced_sso": false}'The token must belong to an organization Owner (sso.edit is Owner-only). A request body whose only key is enforced_sso: false performs the toggle and nothing else — it depends on no other part of the SSO configuration, so it works even when the IdP configuration is broken. Password sign-in for members works again immediately after the 200.
Create an API token before you enforce
Mint an API token and store it like a recovery code before enabling enforcement — the enforcement confirmation reminds you. If no Owner holds a usable API token, contact support.