Private betaRequest early access →

Sign in

Log in with Zynth Auth (OpenID Connect)

Zynth Auth (Perihelium's authentication layer) is a standards OpenID Provider (OAuth 2.1 + OIDC Core). Any compliant OIDC library or SaaS that accepts an issuer URL — Grafana, Kubernetes, Backstage, openid-client, next-auth, Spring Security, mod_auth_openidc, your own app — signs users in against Zynth Auth with no bespoke code.

This guide is the integrator's path: register a client, run the login flow, verify the token, and wire up logout. If you only need to verify tokens Zynth Auth already issues, see Verifying tokens; if you want the batteries-included wrapper, see the TypeScript SDK — it composes on top of everything here.

The one thing your library needs: the issuer

Everything is discovered from a single URL:

Text
https://<your-issuer>/.well-known/openid-configuration

On Zynth's managed service the issuer is https://auth.zynthmedia.com; on a self-hosted install it is your own domain (the discovery document and every URL in it derive from that install's configured issuer — never a shared constant). Point your library at the issuer and it reads back the endpoints:

PurposeEndpoint
DiscoveryGET /.well-known/openid-configuration
AuthorizationGET|POST /api/v1/oauth/authorize
TokenPOST /api/v1/oauth/token
UserInfoGET|POST /api/v1/oauth/userinfo
JWKS (verify keys)GET /.well-known/jwks.json
End session (logout)GET /api/v1/oauth/logout
Device authorization (CLI login)POST /api/v1/oauth/device_authorization — guide
Token introspectionPOST /api/v1/oauth/introspect — guide
Token revocationPOST /api/v1/oauth/revoke — guide

The bounded profile it advertises — and enforces — is: response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], grant_types_supported: ["authorization_code", "refresh_token", "client_credentials", "urn:ietf:params:oauth:grant-type:device_code"], id_token_signing_alg_values_supported: ["RS256"], scopes_supported: ["openid", "profile", "email", "roles"]. What we publish is exactly what we accept.

Step 1 — Register a client

A tenant admin registers your app under Organization → OAuth Clients (or via the OAuth clients API). A client belongs to exactly one organization and can only sign in that organization's users.

Choose a client type:

  • Confidential — a server-side app that can keep a secret. Gets a show-once client_secret at creation (and on rotation); store it immediately, it is never retrievable again. Authenticates to /token with the secret.
  • Public — a SPA, mobile, or native app that cannot hold a secret. No secret; security comes from PKCE (mandatory for every client anyway) plus exact-match redirect URIs.

Configure:

  • Redirect URIs — exact strings, validated at /authorize with no wildcards, no prefix or substring matching, no fragments. https everywhere; plain http is allowed only for loopback (localhost, 127.0.0.1, [::1]) for local/native flows. Custom schemes (native app callbacks) are matched exactly. Register every callback URL you will actually use. One qualification, for a public client only: a loopback IP literal registered without a port (http://127.0.0.1/callback, http://[::1]/callback) accepts any port at run time, as RFC 8252 §7.3 describes — see Signing in from a CLI.
  • Grant types — default ["authorization_code"]. Add refresh_token for long-lived sessions (it requires authorization_code or the device grant); add client_credentials for machine-to-machine (confidential clients only); add urn:ietf:params:oauth:grant-type:device_code to sign a person in from a CLI or headless session (guide). Implicit and ROPC cannot even be expressed — OAuth 2.1 removes them and so do we.
  • Scopes — the OIDC scopes your app requests (openid, profile, email), and optionally roles, which releases the user's role codes — see Role claims. A client may request only scopes its administrator allowed here.

You receive a client_id (public identifier) and, for confidential clients, the show-once client_secret.

Integrating with more than one organization

There are two shapes, and which one you can use depends on how your application is registered.

By default, a client is bound to its organization — it can never authenticate another organization's users. If your product serves several Zynth organizations, the pattern is one client registration per organization: each organization's admin registers your app in their own console, you store that org's client_id (and secret) alongside the org, and you route sign-ins by the id_token's tid claim. The issuer, endpoints and JWKS are identical for every organization on an install — only the client registration is per-org. This works today and needs nothing from us.

A platform-level client is registered once and installed by each organization that wants it — the shape you know from Slack, GitHub and Atlassian apps. One client_id, one secret, and an organization's admin decides whether your application may authenticate their people:

  • Registration is operator-only — ask us. It is a reviewed step, because a platform client can be authenticated by any organization that installs it. There is no self-service registration. When you ask, send: the client name as it should appear on the consent screen, your redirect_uris, the scope ceiling you expect organizations to install with, and whether an existing per-organization client should be retired at the same time or run in parallel while you migrate. We send back the client_id and secret.
  • Where your customer installs it: their administrator (with tenant:manage) goes to Settings → Organization → Installed Apps, pastes the client_id you gave them, and picks the scopes they will allow. Point them there rather than at the API — and expect the ceiling they choose to be narrower than what you asked for, which is the design working.
  • Until an organization installs your app, sign-in for its members is refused — the user is told their organization has not installed the application and that an administrator can. Absence of an installation is a refusal, never a default yes.
  • The installing admin sets a scope ceiling. If you request a scope their organization did not admit, /authorize returns invalid_scope naming it — we refuse rather than quietly issuing a narrower token, so you find out at integration time instead of at runtime.
  • An organization can withdraw your app at any time. Outstanding access dies at the next token refresh, so handle invalid_grant on refresh as "we have been uninstalled here", not as a transient error.
  • tid still tells you which organization signed in, and it remains the only safe key (see below).

Which to choose. Per-organization registration if you serve a handful of organizations or want no dependency on us. Platform-level if you are distributing one application to many — it is the difference between onboarding being a support conversation and being a button their admin presses.

Which organization signs in

The one the user's current session is in. /authorize takes the organization from the authenticated session — there is no parameter you can pass to choose one, and no organization chooser. The tid claim then tells you which organization it was.

For a user who belongs to several organizations, the consequence is worth stating plainly: if their session is in an organization that has not installed your application, sign-in is refused — even when another of their organizations has installed it. Getting them into the right session first is the lever available from your side. If that blocks a real use case, tell us.

How a refusal reaches you — and it is not symmetric

The distinction that decides how you write your callback: most admission refusals never reach your redirect_uri at all.

what happeneddeliverywhat your callback sees
Organization has not installed your app403, a page on our sidenothing — no redirect
Organization withdrew your app403, our pagenothing
Client belongs to a different organization403, our pagenothing
Client is disabled403, our pagenothing
Requested scope is above the installation's ceilingredirect to you?error=invalid_scope&error_description=… naming the refused scopes
Ordinary protocol errors (proven redirect_uri)redirect to you?error=…&error_description=…&state=…

So the only admission-related error you can map in your callback is invalid_scope. For the rest the user stays with us and is told why — the not-installed page says their organization has not installed the application and that an administrator can install it before they sign in.

Do not write callback handling for "not installed": it cannot arrive there. Do handle invalid_scope, because a ceiling change on the organization's side can start producing it at any time.

Step 2 — Authorization-code + PKCE login

This is the standard OIDC redirect flow. Any library drives it for you; the mechanics:

  1. Generate a PKCE code_verifier and its S256 code_challenge, and a random state (and a nonce for the id_token). Redirect the browser to /authorize:

    Text
    GET https://<issuer>/api/v1/oauth/authorize
      ?response_type=code
      &client_id=<client_id>
      &redirect_uri=<one of your registered URIs, exact>
      &scope=openid%20profile%20email
      &state=<opaque>
      &nonce=<opaque>
      &code_challenge=<S256 challenge>
      &code_challenge_method=S256
    

    PKCE S256 is mandatory — a request with plain or no challenge is refused, and a code issued with a challenge requires the matching verifier at /token.

    Optional OIDC interaction parameters. prompt and max_age are enforced:

    ParameterWhat Zynth does
    prompt=loginAlways re-authenticates. The user signs in again before a code is issued, even if a live session exists.
    max_age=<seconds>Re-authenticates unless the existing session signed in within that many seconds. max_age=0 behaves like prompt=login.
    prompt=consentRe-shows the consent screen even when a standing consent covers the scopes (also inside a combination, e.g. prompt=login consent).
    prompt=noneNever shows any UI. If it cannot complete silently you get ?error=login_required or ?error=consent_required (plus your state) at your redirect_uri — retry without prompt=none.
    login_hintAccepted and passed through; the sign-in screen does not pre-fill from it. Treat it as a no-op.

    Unknown prompt values, and none combined with any other value, are refused with ?error=invalid_request. max_age must be a non-negative integer.

  2. The user signs in and consents on Zynth's own hosted screens (password, passkey, MFA, social, or the org's enterprise SSO — whatever the user has). The consent screen names your app and the scopes in plain language; it is an anti-phishing surface, so it is always shown when a covering consent doesn't already exist (and re-shown when you ask for broader scopes or send prompt=consent).

  3. Zynth redirects the browser back to your redirect_uri with code and your state (verify state matches). If the user declines, you get ?error=access_denied&state=… instead.

  4. Your server exchanges the code at /token:

    Text
    POST https://<issuer>/api/v1/oauth/token
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=authorization_code
    &code=<code>
    &redirect_uri=<the same URI, exact>
    &code_verifier=<PKCE verifier>
    &client_id=<client_id>
    

    Confidential clients authenticate with the secret via HTTP Basic (Authorization: Basic base64(client_id:client_secret)) or client_secret_post (client_secret form field). The response:

    JSON
    {
      "access_token": "…",
      "id_token": "…",
      "refresh_token": "…",
      "token_type": "Bearer",
      "expires_in": 900,
      "scope": "openid profile email"
    }
    

    refresh_token appears only if the client has the refresh_token grant; id_token only when openid is in the granted scopes.

Authorization codes are single-use. Replaying a code doesn't just fail — it revokes the session that code minted and raises a security detection (the front-channel analog of refresh-token reuse). Exchange each code exactly once.

Step 3 — Verify the id_token

The id_token is a distinct RS256 JWT (never the access token). Your OIDC library verifies it against the published JWKS; if you verify by hand, check:

  • Signature against GET /.well-known/jwks.json (RS256, kid-selected — keys rotate with zero downtime).
  • iss exactly equals your issuer, aud equals your client_id, exp not passed.
  • nonce equals the value you sent in step 1.
  • at_hash matches the access token (if your library checks it).

⚠️ Two issuers — never share verifier config between token types. The id_token's iss is the OIDC issuer URL (https://auth.zynthmedia.com on the managed service — the issuer in discovery). The access token is a different document with a different issuer: iss = zynth-auth, the platform's access-token issuer, which never appears in an id_token (and vice versa). Their audiences differ too (client_id vs. zynth-services/resource). If one verifier — or one shared config value — validates both token types, one of them will always be rejected. Pin them separately. See Verifying tokens.

Useful claims: sub (stable user id), auth_time, and amr — how the user actually authenticated this session (pwd, mfa, webauthn, social, sso, mlink), so you can enforce your own step-up policy. sid is the session id (used by back-channel logout).

Which organization signed in? Use tid

Zynth Auth is multi-tenant, so a user always authenticates into an organization. Two claims carry that, on both the id_token and UserInfo, and neither is scope-gated:

  • tid — the organization's immutable UUID. This is the key. Scope your data, your authorization checks, and your routing to it.
  • tenant_slug — the readable slug (acme-inc). Display only, and mutable — an admin can change it. Never store or key on it.
JSON
{ "sub": "…", "tid": "3f1c…", "tenant_slug": "acme-inc", "amr": ["pwd", "mfa"] }

⛔ Do not decode the access token to get tid. Under OAuth 2.0 (RFC 6749 §1.4) the access token is opaque to clients — a credential you pass through, not a document you read. Its claims are not part of our contract and can change in any release, including a patch. Take tenancy from the id_token or UserInfo, which are contract. This is the single most common way an OIDC integration breaks silently months after it was written.

Role claims

An application that maps a role claim at sign-in (Open WebUI is the usual example) can read the user's roles from the id_token and UserInfo. It is opt-in twice: a tenant administrator adds the roles scope to the client's allowed scopes (Organization → OAuth Clients → Role claims; off by default), and your application requests scope=openid roles, so the user sees it on the consent screen. Without both, no role claim appears anywhere.

JSON
{ "sub": "…", "tid": "3f1c…", "roles": ["admin", "invoice_clerk"] }
  • roles is one array of role codes — the user's membership role, their active and unexpired system-role assignments, and their active custom roles. It is the vocabulary /authz/check already speaks.
  • It reflects the organization that signed in (tid) and never any other organization the user belongs to.
  • There is no groups claim: Zynth has roles, and an identity provider's groups are mapped onto roles when the user signs in through enterprise SSO, so roles is the result of that mapping.
  • It is bounded at 64 codes. Past that the list is omitted and roles_truncated: true is sent instead — never a silently shortened list, which would be a wrong authorization statement. Ask the check API when you see the flag.
  • It is a statement as of the moment the token was minted: a role removed afterwards is still in an id_token you already hold until it expires. A refresh re-reads it; UserInfo reads it live. For a decision that must be current, ask the check API.

Step 4 — Call UserInfo (optional)

For profile claims beyond the id_token, call UserInfo with the access token:

Text
GET https://<issuer>/api/v1/oauth/userinfo
Authorization: Bearer <access_token>

Claims: always sub + the tenancy claims (tid, tenant_slug); then per granted scope — email + email_verified with email; name, given_name, family_name, picture and updated_at with profile; roles with roles (below). UserInfo honors revocation — a signed-out or password-changed session returns 401 even if the token hasn't expired.

profile claims are released when they exist, omitted when they don't (OIDC Core §5.3.2 — never emitted empty or invented). Profile attributes are sourced authoritatively: SCIM provisioning and enterprise SSO overwrite what the directory asserts, social login fills blanks, and users without an IdP can edit their own names in the console. A user with no stored name simply yields fewer claims. Read claims_supported from discovery and build against that; it always states exactly what the deployment emits.

Step 5 — Refresh (long-lived sessions)

If your client has the refresh_token grant, exchange the refresh token for a new pair:

Text
POST /api/v1/oauth/token
grant_type=refresh_token&refresh_token=<token>&client_id=<client_id>   (+ client auth)

Token lifetimes (defaults; a self-hosted operator can change them). The access token lives 15 minutes (expires_in: 900) — short by design, so revocation lands fast (see the back-channel logout note). The refresh token lives 30 days for a customer / CIAM end-user — the usual population behind an OIDC relying party — and 7 days for a workforce user; that is the window in which a returning user can be refreshed silently before they must sign in again.

Refresh tokens rotate: each refresh returns a new refresh token and invalidates the old one. Reusing a rotated token is treated as theft — it revokes the whole grant session. Always store the newest refresh token and discard the previous one. You may narrow scope on refresh (scope= a subset); you can never widen it.

A refresh re-checks the user, not just the token. Every refresh (and every /userinfo call) re-validates the person behind the grant against the directory: they must still be an active member of the organization the token names, their account must still be active, and the token must pre-date neither their last password change nor an administrative "sign out everywhere". A grant that fails any of these returns 400 invalid_grant, with the reason in error_description — for example:

JSON
{ "error": "invalid_grant",
  "error_description": "the authorizing user's password changed after this grant was issued" }

This is not an error to retry: the user's session with Zynth ended. Send them back through /authorize to sign in again. Password changes, password resets and "sign out everywhere" also end the grant immediately and fire back-channel logout to your backchannel_logout_uri — so a deprovisioned or recovered account stops working in your app at that moment, not when its access token happens to expire.

Step 6 — Logout

Two complementary mechanisms, both wired to real session revocation (not cosmetic). Note that RP-initiated logout is not silent: the end-session endpoint shows a one-click interstitial ("Sign out of Zynth Auth?") before the session is revoked and the user is redirected — a cross-site link cannot sign someone out without their confirmation.

  • RP-initiated logout — send the user to the end_session_endpoint:

    Text
    GET https://<issuer>/api/v1/oauth/logout
      ?id_token_hint=<the id_token>
      &post_logout_redirect_uri=<a registered post-logout URI, exact>
      &state=<opaque>
    

    id_token_hint is required (it attributes the request to your client; without it an open-redirect can't be formed). post_logout_redirect_uri must be pre-registered on the client (exact match). Zynth ends the session and returns the browser to your URI.

  • Back-channel logout — Zynth proactively tells your server when a session ends elsewhere. Full contract below.

Back-channel logout

Register a backchannel_logout_uri on your client and Zynth POSTs a signed logout token to it whenever a session ends anywhere — user logout, "sign out everywhere", an admin ending the session, or the user revoking your app's consent. One sign-out fans out to every RP holding a grant for that user, so your server-side session dies without waiting for the user to come back.

The delivery (OIDC Back-Channel Logout 1.0 §2.5): an HTTP POST to your registered URI with Content-Type: application/x-www-form-urlencoded and body logout_token=<JWT>. Respond 200 promptly; the response body is ignored.

The logout token is an RS256 JWT signed with the same keys as the id_token (kid-selected from the published JWKS):

JSON
{
  "iss": "https://auth.zynthmedia.com",
  "aud": "<your client_id>",
  "iat": 1722772800,
  "exp": 1722772920,
  "jti": "<unique token id>",
  "sub": "<user id>",
  "sid": "<the session id — same value as the id_token's sid>",
  "events": { "http://schemas.openid.net/event/backchannel-logout": {} }
}

Validate it per §2.6 — like an id_token, with the spec's deliberate differences:

  1. Signature against /.well-known/jwks.json (RS256, kid-selected).
  2. iss is the OIDC issuer — the discovery issuer, the same value as your id_tokens (never the access-token issuer zynth-auth).
  3. aud is your client_id; iat/exp are current (the token lives ~2 minutes).
  4. The events claim contains the http://schemas.openid.net/event/backchannel-logout member.
  5. A nonce claim is absent — its presence is grounds for rejection (this is what stops an id_token being replayed as a logout token).
  6. Optionally track jti in a short replay cache.

Then end the local session whose sid matches (Zynth always sends sid — backchannel_logout_session_supported: true in discovery); if you keyed sessions some other way, fall back to ending all sessions for sub.

Operational notes:

  • Delivery is best-effort by contract — a dead or slow RP endpoint never blocks the logout itself, and there is no retry queue. Treat the back-channel as the fast path and keep honoring the short access-token TTL (15 minutes by default) as the backstop.
  • Your URI must resolve to a public address. Delivery goes through the same egress policy as webhooks: a URI resolving to a private or internal address is refused without a connection ever being opened. Use a publicly resolvable HTTPS endpoint.
  • If you adopt audience-bound tokens, implementing this handler is what preserves fast revocation — see the trade-off note there.

Machine-to-machine (client_credentials)

For service-to-service calls with no user, use a confidential client with the client_credentials grant:

Text
POST /api/v1/oauth/token
grant_type=client_credentials&scope=<space-separated>   (+ client auth)

You get an access token only — no id_token, no refresh token, no UserInfo (there is no user behind it; sub is the client_id). Re-request with the secret when it expires. Scopes are capped to the client's registered set.

Connected apps (what your users see)

Every user grant is recorded as a consent. Users review and revoke their connected apps under Account → Connected apps. Revoking disconnects your app immediately: live grant sessions for it are killed, a back-channel logout_token is sent, and /token refuses further refreshes for that user — access ends now, not at the next token expiry.

Each grant is also a session of its own, so it appears under Account security → Active sessions labelled with your app's registered name (the name you gave at client registration). Signing that one row out ends that single grant — the equivalent of your app losing one refresh token; disconnecting under Connected apps ends every grant your app holds.

Audience-bound tokens for your own APIs (RFC 8707)

By default an access token carries the platform audience and is accepted by the Zynth API. If you'd rather each of your APIs only ever accept tokens minted for it, register the API as an OAuth resource and have your client name it:

Text
GET /api/v1/oauth/authorize?…&resource=https://api.example.com/orders
POST /api/v1/oauth/token    …&resource=https://api.example.com/orders

The issued access token's aud becomes that identifier. It then verifies only at that API — rejected by every other resource and by the Zynth API itself — so a token stolen from one service is useless everywhere else. The binding survives refresh automatically, and a request naming a different resource than the grant carries is refused with invalid_target; a resource cannot be introduced or swapped mid-grant.

Resources are admin-curated: a client may name one, never create one. An unregistered or disabled identifier is refused with invalid_target, never quietly defaulted. This works for any API — MCP servers are the case with a dedicated guide, not the only case. Verifying such a token: Verifying tokens.

⚠️ The trade: a resource-bound token cannot call UserInfo. "Rejected by the Zynth API itself" includes /userinfo — it accepts only platform-audience tokens. If your app makes a per-request UserInfo call as a revocation check, that pattern stops working the moment you bind tokens to your resource. What replaces it is token introspection: your resource server (a client holding the authz:check scope) asks whether the token is live now, and a revoked grant or a killed agent answers active: false. Between introspection calls the revocation story is the short access-token TTL (15 minutes by default) plus back-channel logout. Identity claims are unaffected: keep taking them from the id_token, which stays exactly as it was.

Sender-constrained tokens (DPoP)

OAuth tokens can be DPoP-bound (RFC 9449) so a stolen token is useless without your key — an advanced feature most providers lack. Send a DPoP proof on the token request and the issued tokens bind to your key (token_type: DPoP). See Sender-constrained tokens.

Error handling

  • /authorize — errors on an unproven redirect_uri or client_id are shown directly (never redirected — RFC 6749 §4.1.2.1); once the redirect target is proven, protocol errors come back as ?error=…&error_description=…&state=… on your redirect URI. Admission refusals are the exception and do not redirect even with a proven target: a platform-level client whose organization has not installed it — or has withdrawn it — gets a 403 page on our side, and your callback is never called. The one admission error that does reach you is invalid_scope. See the refusal table before writing callback error handling, because the intuitive reading of the sentence above is wrong for these cases.
  • /token — RFC 6749 §5.2 JSON errors: invalid_grant (bad/expired/replayed code, PKCE mismatch, revoked consent, or a user who no longer stands — inactive, no longer a member, password changed, or signed out everywhere; error_description says which), invalid_client (401), unauthorized_client (grant not enabled for this client), unsupported_grant_type.
  • Rate limits — /authorize and /token are throttled and fail closed; over the cap you get 429 with Retry-After plus the RateLimit-* trio. Back off and retry. Your per-client allowance comes from your plan (see the API reference for the numbers) and only your own authenticated traffic spends it; a per-IP floor sits in front. Repeated failed client authentications are refused early (429) before credential verification — if you see that during setup, check the secret rather than retrying.

Bounded profile (know the edges)

Implicit and ROPC grants are never implemented. CIBA, dynamic client registration (RFC 7591), PAR, and JAR/JARM are out of scope for this profile. Redirect URIs match exactly, port included. If you are building a CLI login, read Integrating an agent platform §1 first. Resource indicators (RFC 8707) are supported — see audience-bound tokens. Zynth Auth supports OIDC; it is not an OpenID-certified implementation. If your integration needs a piece outside the profile, tell us — the profile extends on demand.

Next