UptimeHunt Docs
OrganizationsSingle Sign-On

SSO with OIDC

Connect any OpenID Connect provider (Okta, Entra ID, Auth0, Keycloak) as your organization's identity provider for member sign-in.

SSO with OIDC

Overview

Connect any OpenID Connect provider — Okta, Microsoft Entra ID (Azure AD), Auth0, Keycloak, Authentik, and others — as your organization's identity provider.

Prerequisites:

  • Owner role in the organization (sso.edit)
  • Admin access to your IdP to register an application

That's all — the client ID, client secret, and issuer URL below are a complete, working configuration. Verifying your email domain is optional: it makes the login page advertise your IdP to anyone typing an email under that domain, but members can always sign in through the organization sign-in link without it.

Step 1 — Register UptimeHunt in Your IdP

Create an OIDC web application (confidential client, Authorization Code flow) in your identity provider with:

SettingValue
Redirect URI / callback URLShown in Organization → Authentication as soon as you select OIDC — before the configuration is first saved, so you can register it at the IdP up front. It is served from the UptimeHunt authentication host and has the form https://auth.uptimehunt.io/accounts/oidc/{org-slug}/login/callback/ — copy the exact value from the settings page
Grant typeAuthorization Code
Scopesopenid, email, profile

No UI at hand? The same values are available programmatically: GET /api/v1/orgs/{slug}/sso/urls?provider_type=oidc (requires sso.view) computes the SP endpoints from the organization slug alone — no saved configuration required, so automation (Terraform, Ansible, scripts) can register the IdP application before the first PUT:

curl "https://app.uptimehunt.io/api/v1/orgs/acme/sso/urls?provider_type=oidc" \
  -H "Authorization: Bearer {access_token}"
{
  "data": {
    "provider_type": "oidc",
    "urls": {
      "login": "https://auth.uptimehunt.io/accounts/oidc/acme/login/?process=login",
      "callback": "https://auth.uptimehunt.io/accounts/oidc/acme/login/callback/"
    }
  }
}

urls.callback is the redirect URI to register; urls.login is the direct member sign-in URL.

Note the client ID, client secret, and your IdP's issuer / discovery URL (the URL that serves /.well-known/openid-configuration).

Step 2 — Configure the Organization

UI: Organization → Authentication → OIDC — fill in the discovery URL, client ID, and client secret.

API:

curl -X PUT https://app.uptimehunt.io/api/v1/orgs/acme/sso \
  -H "Authorization: Bearer {access_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "provider_type": "oidc",
    "config": {
      "server_url": "https://idp.acme.com",
      "client_id": "uptimehunt-prod",
      "client_secret": "{client_secret}"
    },
    "jit_provisioning": true,
    "jit_requires_invitation": false,
    "default_role": "member",
    "enabled": true
  }'

Provider credentials and endpoints go inside the config object; the top-level fields are the organization-side toggles.

FieldDescription
provider_typeoidc
config.server_urlIssuer URL (must be https://) — UptimeHunt reads {server_url}/.well-known/openid-configuration
config.client_id / config.client_secretFrom step 1; required when first creating the configuration. The secret is write-only: it is never returned by the API
config.workspace_domainsHosted-domain allowlist — required when jit_provisioning is on and server_url is a shared multi-tenant issuer (below); unnecessary for your own single-tenant IdP
config.role_mappingOptional {source, rules, unmapped_policy, overage_action} object mapping groups-claim values to roles (Role Mapping). source is the OIDC claim name (default groups); overage_action (default deny_new) governs an Entra ID group overage; {"rules": []} clears it
config.additional_scopesOptional extra OAuth scopes (list, or a space-separated string) appended to the openid profile email base so the IdP emits the groups claim (below). The base scopes are always requested — these only add to them
jit_provisioningAuto-create accounts on first sign-in (details)
jit_requires_invitationOnly JIT-provision users with a matching pending invitation (default false)
allowed_email_domainsOptional declared email-domain allowlist for JIT: when non-empty, sign-ins asserting an email whose domain is not listed are refused at provisioning. Plain declared list — never DNS-verified. Default [] (no filtering)
default_roleRole granted to JIT-provisioned members (default member)
skip_role_syncWhen true, config.role_mapping runs only at account creation, never on later sign-ins — so a manual role override is not reverted (default false; no effect without a configured role_mapping)
enabledActivates the IdP for login routing
enforced_ssoRequire every member to sign in through this IdP. Enabling it must pass the readiness guards and immediately signs all members out

Shared multi-tenant issuers

If server_url points at an issuer that authenticates every tenant's accountshttps://accounts.google.com, or Microsoft's multi-tenant https://login.microsoftonline.com/common, /organizations, or /consumers endpoints — then "your IdP authenticated the user" no longer means "the user belongs to your organization": anyone with a Google or Microsoft account can complete the flow. A JIT-enabled configuration against such an issuer must therefore declare its own scope:

  • Set config.workspace_domains to your hosted domain(s), e.g. ["acme.com"]. For Google-issued sign-ins, the ID token's hd claim must match one of them (consumer accounts, which carry no hd, are always rejected). This list is declared in the configuration — no DNS verification involved.
  • Alternatively, use a tenant-specific issuer URL (e.g. https://login.microsoftonline.com/{tenant-id}/v2.0) — a single-tenant issuer scopes itself and needs no workspace_domains.

The API refuses (400) to save a JIT-enabled configuration against a known shared issuer without a declared scope. For Google Workspace specifically, prefer the dedicated Google Workspace provider type.

Step 3 — Test

Validate the stored configuration against your IdP:

curl -X POST https://app.uptimehunt.io/api/v1/orgs/acme/sso/test \
  -H "Authorization: Bearer {access_token}"

This checks that the discovery document (derived from server_url, at {server_url}/.well-known/openid-configuration) is reachable and well-formed, and that its issuer matches the configured server_url — response shape and check details in Testing the Configuration.

Then perform a real sign-in from a private browser window: enter a corporate email on the login page and choose the organization route.

Role Mapping (claim → role)

Map the groups your IdP asserts to UptimeHunt roles, instead of giving every member the same default_role. The shared mechanics — ordered rules, first-match-wins, the unmapped-members policy (including Refuse sign-in), the Owner warning, the last-Owner guard, re-sync on every sign-in, and the audit events — are documented once under SSO → Role Mapping. This section covers only the OIDC-specific part: where the group values come from.

  • The source is the OIDC claim name that carries group membership (default groups). UptimeHunt reads it from the userinfo response first, falling back to the ID token — so the claim only has to appear in one of them.
  • Each rule value is matched verbatim (case-sensitive) against the claim's array values.

UI: Organization → Authentication → OIDC → Role mapping — set the Groups claim (source), then add rules pairing a group value with a role, ordered most-privileged first.

`unmapped_policy: deny` blocks new JIT users whose groups aren't mapped

If you set the unmapped policy to Refuse sign-in (deny) and rely on JIT provisioning to onboard people, a brand-new user whose asserted groups match no rule is refused on their first sign-in — JIT does not save them, because deny is evaluated before the account is created. Either keep every legitimate group in a rule, or use a non-deny unmapped policy (a concrete role, or '' for the default role). Full interaction details.

API (inside the OIDC config):

{
  "role_mapping": {
    "source": "groups",
    "rules": [
      {"value": "platform-admins", "role": "owner"},
      {"value": "sre",             "role": "admin"},
      {"value": "engineering",     "role": "member"}
    ],
    "unmapped_policy": "member"
  }
}

Additional OAuth scopes

Some IdPs gate the groups claim behind an extra OAuth scope. config.additional_scopes is appended to the always-requested openid profile email base — it never replaces it, so a typo here can never drop a required base scope. Provide a list (["groups"]) or a space-separated string ("groups").

  • Okta (custom authorization server) needs the groups scope, and that scope must exist on the custom auth server (create it if absent).
  • Microsoft Entra ID, authentik, and GitLab do not need an extra scope — the groups claim rides the default scopes once the IdP-side mapper/claim is configured.
  • Keycloak depends on where the Group Membership mapper is attached:
    • Attached directly to the client (a dedicated client mapper) — no extra scope; the claim rides the default scopes.
    • Attached to a named client scope (a common pattern — e.g. a reusable groups client scope) — that scope is only requested if its name is asked for. If the client scope is Default, Keycloak includes it automatically; but if it is Optional, you must add the client-scope name to config.additional_scopes (e.g. ["groups"]) or the mapper never runs and no groups claim is emitted — a silent no-match with no error. When in doubt, add the scope name; the base openid profile email scopes are never dropped.

Per-IdP claim guidance

Set source to the claim name your IdP emits, and configure that IdP to emit it:

IdPsource (claim)Setup · value format
OktagroupsA custom authorization server claim (value type Groups, filter .*), included in the ID token and/or access token; request the groups scope (above). Values are group names
Microsoft Entra IDgroupsApp registration → ManifestgroupMembershipClaims = SecurityGroup (or All). GUIDs by default; emit names via optionalClaims cloud_displayname (ApplicationGroup mode only). No extra scope. See the overage caveat below
Keycloakgroups (admin-defined, must match the mapper)A Group Membership mapper. Full group path is on by default → values are /parent/child with a leading slash; either include the slash in rule values or disable full path in the mapper
authentikgroupsBuilt into the default profile scope — no extra config. Values are group display names (renaming a group breaks the mapping)
GitLabgroups (userinfo)Returned automatically under openid; userinfo groups = all (incl. inherited) memberships, ID-token groups_direct = direct only. Values are full paths (mygroup/subgroup)
Google(unsupported on native OIDC)Google OIDC tokens carry no groups claim — see Google Workspace

Microsoft Entra ID emits GUIDs, and goes silent past 200 groups

  • Default values are Object-ID GUIDs, not names — your rule values must be GUIDs unless you configure optionalClaims to emit display names (only possible in ApplicationGroup mode, for groups assigned to the app).
  • Group overage at 200 groups (JWT). When a user is in more than 200 groups, Entra omits the groups claim entirely and replaces it with a _claim_names / _claim_sources indirection pointing at the Microsoft Graph API. UptimeHunt treats this as a lookup failure (sso.role_mapping.lookup_failed), not an empty group set, and resolves it with the mapping's 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. Set overage_action: allow_existing to provision such users at default_role instead, or ignore to treat the unreadable claim as an empty group set (so unmapped_policy governs).

How Identity Is Resolved

  • The identity anchor is the OIDC sub claim, scoped to your organization's IdP configuration (identity realm). Email and profile claims are display attributes — changing a user's email at the IdP does not create a new UptimeHunt account.
  • ID tokens are validated per the OIDC spec: issuer, audience, expiry, nonce, and signature against the IdP's published keys.
  • There is no email-domain gate by default: whoever your IdP authenticates is admitted (JIT gating). The one mandatory exception is a shared multi-tenant issuer, where the declared workspace_domains scope applies; optionally, the declared allowed_email_domains allowlist filters JIT provisioning by asserted email domain.

Troubleshooting

SymptomLikely cause
sso/test fails on discovery_documentserver_url wrong or unreachable — it must serve /.well-known/openid-configuration
sso/test fails on issuer_matchThe discovery document's issuer differs from the configured server_url — set server_url to the IdP's exact issuer value
Sign-in fails immediately with sso_error=idp_unreachableUptimeHunt could not reach the IdP — the issuer/discovery URL is wrong or the IdP is down. The user lands back on the login page (no error page, no stack trace) and the failure is audited (sso.login.failure, reason idp_unreachable). Verify config.server_url and that the IdP is reachable, then run sso/test
IdP error redirect_uri_mismatchThe redirect URI registered in the IdP doesn't exactly match the value from the SSO settings page (scheme, trailing slash)
Sign-in succeeds at IdP but UptimeHunt rejects itJIT is disabled / invitation-gated and no account or invitation exists; the asserted email's domain is not in the declared allowed_email_domains; on a shared issuer, the account's hd claim doesn't match the declared workspace_domains; or the organization is at its member limit
Saving the configuration returns 400 mentioning workspace_domainsThe shared-issuer guard: JIT against accounts.google.com or a multi-tenant Microsoft endpoint needs a declared workspace_domains (or a tenant-specific issuer)
Login page never offers the SSO route after entering an emailExpected without an email-routing domain (until the user's SSO account exists), or the IdP configuration is not enabled. Members of an enforcing organization are routed regardless; anyone can use the organization sign-in link or the Use single sign-on option

On this page