This is the hands-on companion to SSO (the architecture overview). It walks through adding a real provider, signing in through it, and approving the resulting user — using real screenshots captured against a Generic OIDC provider configured against Google, plus the JWT claim trace that applies to every provider identically.
- You need the Organizations administrator role for the target organization (or system admin).
- The host running the SSO Gateway needs outbound HTTPS to the IdP’s discovery, authorization, token, and JWKS endpoints.
- Register the CYPEX callback URL on the IdP side before you save the provider — the default is
{app-origin}/auth/{providerType}/callback(e.g.http://localhost:4000/auth/oidc/callbackin dev), and the gateway rejects a login whose storedcallbackUrldoesn’t match the IdP’s registered redirect URI byte-for-byte.
Under Authentication → SSO Providers → Add provider:
New SSO provider — Identity and OIDC discovery
Organization, Provider type, and Display name under Identity; Issuer URL and Discover endpoints under OIDC discovery.

Three fields matter most:
- Organization — which tenant this provider belongs to. A provider is always scoped to exactly one organization.
- Provider type — the dropdown offers five:
Generic OIDC,Generic OAuth2,Google,GitHub,Microsoft. Google and Microsoft are first-class shortcuts; every other spec-compliant IdP — Azure AD (if you don’t use the Microsoft shortcut), Keycloak, JumpCloud, Auth0 — goes throughGeneric OIDC. Each type has its own strategy in the SSO Gateway, so the five shipped types cover their vendors’ quirks; adding a further spec-compliant IdP is configuration only, throughGeneric OIDC, with every endpoint read from the row you’re creating — no new code to write or deploy. - Issuer URL — paste this and click Discover endpoints to auto-fill Authorization URL, Token URL, User info URL, JWKS URL, and End session URL from the IdP’s
.well-known/openid-configurationdocument. All fields it fills stay editable, so you can override any single endpoint without losing the rest.
Known-good issuer URLs for Generic OIDC — this is the exact form used for the screenshots below, pointed at Google instead of the Google shortcut, to keep the walkthrough provider-agnostic:
| Provider | Issuer URL |
|---|---|
https://accounts.google.com | |
| Microsoft Entra ID (Azure AD) | https://login.microsoftonline.com/{tenant-id}/v2.0 |
| Keycloak | https://{host}/realms/{realm} |
| JumpCloud | https://oauth.id.jumpcloud.com/ |
| Auth0 | https://{tenant}.auth0.com/ |
The login button’s icon is derived independently from the Issuer URL’s hostname (accounts.google.com, *.microsoftonline.com, *.jumpcloud.com all pattern-match to a known brand), not from which of the five dropdown options was picked — a Generic OIDC provider pointed at Google’s issuer still renders the Google “G” icon, exactly as in the screenshots below.
Scroll down for the endpoints, scope, callback URL, and identity-linking policy:
New SSO provider — Endpoints and Advanced
Callback URL defaults to the app-origin pattern above; Scope defaults to `openid profile email`. Auto-link by email is off by default.

Auto-link by email decides what happens on a brand-new identity’s first successful login:
- On — if the IdP asserts a verified email that matches an existing CYPEX user, the SSO identity is linked to that user silently, no admin action needed.
- Off (the default) — every new identity lands in the pending-approval queue regardless of email match, until an admin explicitly approves it (Step 4 below).
A real filled-in example — Generic OIDC against Google, organization smi_org_admin:
New SSO provider — filled example
Client ID and Client secret are OAuth2 credentials issued by the IdP's own console; the secret is AES-256-GCM encrypted at rest and never shown again after Create provider.

Clicking Create provider sends:
POST /sso/providers
{
"organizationId": "1",
"providerType": "oidc",
"providerName": "SSO SMI",
"issuerUrl": "https://accounts.google.com",
"clientId": "<client-id>",
"clientSecret": "<client-secret>"
}
→ 201 Created
{ "success": true, "provider": { "id": "<id>", "providerName": "SSO SMI", "isActive": true, ... } }
The provider now appears in the list, Active:
SSO Providers — list
Status, Last used, and row actions (test, view, edit, delete) per provider.

Click the row’s test action any time to dry-run the configuration end to end — it re-fetches discovery and reports per-endpoint status (authorization_url, token_url, userinfo_url, jwks_url, client_credentials, oidc_discovery) without touching a real user session.
On the CYPEX login page, local username/password stays available. Selecting the organization reveals its provider button:
Login — pick the organization
The SSO button only appears once an organization with an active provider is selected.

Login — provider button
One button per enabled provider for that organization.

Clicking it hands off to the IdP’s own login/consent screens — CYPEX has no control over this part of the flow, and neither the login form nor the consent screen ever sees the other’s credentials:
IdP — choose account
The identity provider's own account chooser, shown here for Google. Nothing on this screen is CYPEX UI.

IdP — consent
Standard OAuth2 consent. Continue redirects back to CYPEX's callback URL with an authorization code.

Under the hood this is a PKCE-bound exchange, not a bare authorization-code redirect:
- The SPA generates a 32-byte random verifier, derives
challenge = base64url(SHA-256(verifier)), and callsGET /auth/oidc?provider_id=<id>&code_challenge=<challenge>. The gateway stores{state, code_challenge}in Redis (10-minute TTL by default — configurable via thesso_oauth_state_ttl_secondsplatform setting) and redirects to the IdP. - The IdP redirects back to
/auth/oidc/callback?code=…&state=…. The gateway exchanges the code for the user’s profile, stashes it in Redis under a one-shot opaqueexchange_code(60-second TTL), and redirects the browser to/sso-callback?code=<exchange_code>. No database write and no JWT happen at this point — the callback URL is publicly reachable, so provisioning can’t be triggered by a forged callback alone. /sso-callbackposts{ code, code_verifier }toPOST /auth/sso/exchange. The gateway atomically consumes the Redis blob, constant-time-comparesSHA-256(verifier)against the stored challenge, and only then runs the user-provisioning step and mints tokens.
InfoPKCE is enforced per-provider (requirePkce, defaulttrue) — the steps above are what a new provider gets out of the box. A provider explicitly configured withrequirePkce=false(a legacy-compatibility escape hatch, not the default path) skips step 3’s verifier check entirely.
This provider has no t_sso_role_mapping rule yet, so the brand-new identity is parked, not signed in:
Login — pending approval
"Your identity was verified. Your account is pending administrator approval" — the IdP round-trip succeeded; CYPEX access did not.

An admin sees it under Users → Pending SSO:
Users — Pending SSO tab
Email, external ID, provider, organization, a suggested role (when one can be derived), and how long ago the request came in.

Rejecting instead of approving, re-opening a rejected identity, and the audit trail each action leaves are covered in full on Pending and rejected users.
PATCH /sso/user-mappings/:id/approve
{ "cypexRole": "smi_org_admin" }
Approve SSO user?
Role is pre-filled from a matching t_sso_role_mapping rule, or the provider's default_role as a fallback hint — always editable before approving.

Approving always provisions a fresh CYPEX user from the captured external profile — this identity has never existed in CYPEX before. The user signs in via the SSO provider only; no password is ever set for it. On the next attempt, the same identity signs straight in and lands on the dashboard:
Dashboard — after SSO sign-in
Signed in as the newly-provisioned user, role badge in the top-right matching the role chosen at approval.

An SSO-issued token is not a special or reduced token — the exchange step uses the same JWT minting path as the rest of the platform, so it carries the identical claim set already documented in Organizations — The JWT tenant claim:
| Claim | Source at SSO exchange time | RLS use |
|---|---|---|
sub, user_id | The CYPEX user id provisioned (first login) or matched (returning login) by findOrCreateUser | Identifies the caller; not itself read by RLS policies |
role | The PostgreSQL role chosen at approval time (or inherited on subsequent logins) | Grants/denies via normal PostgreSQL role privileges, same as local-auth users |
org_id | The organization the provider belongs to | Read by cypex.current_organization_id() — the single claim RLS row-filters on |
organization_ids | Every organization the user can access (empty for system admins, who bypass org filtering entirely) | Read by cypex.current_user_organization_ids() |
isSuperAdmin / isOrganizationAdmin | Computed from the user’s role via computePlatformClaims | Read by cypex.is_admin() / cypex.is_organization_admin() |
WarningThere is no separatetenant_idclaim in CYPEX.org_idis the tenant identifier — it carries the organization’sbigintid, not a UUID, and it is the only claim RLS policies filter rows on. The gateway API transports the id as a JSON string; PostgreSQL casts it back tobigintinside the policy helpers. A provider’s own IdP may call its tenant-scoping claim something else (Entra’stid, for instance) — that claim is read during provisioning to help resolve group/role mappings, but it is never itself forwarded into the CYPEX JWT or read by an RLS policy.
Nothing about this propagation path is SSO-specific: once the token is minted, PostgREST and every RLS policy treat it exactly like a token from local username/password login. SSO’s entire job is producing a token with the right org_id/role, once, at exchange time — see Organizations — How the JWT reaches PostgreSQL for the full request-time trace (SET LOCAL request.jwt.claims → the four RLS helper functions → row-filtered policies).
Preview a provider’s endpoints before saving (no persistence):
POST /sso/providers/discover
{ "issuerUrl": "https://accounts.google.com" }
→ 200 OK
{
"success": true,
"endpoints": {
"issuerUrl": "https://accounts.google.com",
"authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth",
"tokenUrl": "https://oauth2.googleapis.com/token",
"userinfoUrl": "https://openidconnect.googleapis.com/v1/userinfo",
"jwksUrl": "https://www.googleapis.com/oauth2/v3/certs",
"endSessionUrl": null
}
}
Dry-run an existing provider’s configuration:
POST /sso/providers/<id>/test
→ 200 OK
{
"success": true,
"provider_id": "<id>",
"test_results": {
"authorization_url": "OK",
"token_url": "OK",
"userinfo_url": "OK",
"jwks_url": "OK",
"client_credentials": "OK",
"oidc_discovery": "OK"
}
}
client_secret is never present in any of these responses, including to the admin who owns the provider — after POST /sso/providers returns 201, the plaintext value is gone from every subsequent read.
| Symptom | Cause | Fix |
|---|---|---|
sso_error=missing_pkce on /login | The callback ran but the OAuth state carried no code_challenge — login was initiated by a stale SPA build or bypassed /login | Re-trigger sign-in from /login, not a bookmarked/direct IdP URL |
sso_error=invalid_verifier on /login | /sso-callback posted a verifier whose SHA-256 didn’t match the stored challenge — usually a different browser/tab than the one that started login | Restart sign-in in the same tab; check for privacy extensions clearing sessionStorage |
sso_error=invalid_or_expired_code | The one-shot exchange code was already consumed or its 60-second TTL elapsed | Non-retryable — restart sign-in |
sso_error=missing_code | /sso-callback was opened without an opaque code, or sessionStorage had no verifier | Restart sign-in from /login; don’t open /sso-callback manually |
| Discovery request times out | The gateway host can’t reach the IdP | Check egress firewall and DNS; in Docker, the sso_gateway container needs outbound HTTPS |
| Issuer mismatch | The IdP returned a different issuer than the one supplied | Copy the exact string from the IdP’s own discovery document, watching for a trailing slash |
invalid_redirect_uri on login | The stored callbackUrl doesn’t byte-for-byte match a redirect URI registered with the IdP | Fix either side — scheme, host, port, and path all matter |
401 Unauthorized calling the admin API directly | The caller’s JWT role isn’t cypex_admin | cypex_user is refused on create / update / delete / test / discover — sign in as an org or system admin |
| User stuck on “pending administrator approval” | No t_sso_role_mapping rule matched, and Auto-link by email is off (or the email isn’t verified by the IdP) | Approve the user under Users → Pending SSO, or add a role mapping rule so future logins from that group/role resolve automatically |
- Glossary — the one-line OIDC and SSO definitions.
- SSO (architecture) — coexistence with local auth, multi-provider-per-org, and the pending-approval model this guide walks through.
- SSO Providers (admin GUI) — field-by-field reference for the form used in Step 1.
- Organizations — What is an Organization? — the full JWT claim table and RLS request-time trace this guide’s claim mapping is grounded in.
- Users — the Pending SSO / Rejected SSO tabs.
- LDAP — the other federated login path.