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:
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:
| Purpose | Endpoint |
|---|---|
| Discovery | GET /.well-known/openid-configuration |
| Authorization | GET|POST /api/v1/oauth/authorize |
| Token | POST /api/v1/oauth/token |
| UserInfo | GET|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 introspection | POST /api/v1/oauth/introspect — guide |
| Token revocation | POST /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_secretat creation (and on rotation); store it immediately, it is never retrievable again. Authenticates to/tokenwith 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
/authorizewith no wildcards, no prefix or substring matching, no fragments.httpseverywhere; plainhttpis 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"]. Addrefresh_tokenfor long-lived sessions (it requiresauthorization_codeor the device grant); addclient_credentialsfor machine-to-machine (confidential clients only); addurn:ietf:params:oauth:grant-type:device_codeto 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 optionallyroles, 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 theclient_idand secret. - Where your customer installs it: their administrator (with
tenant:manage) goes to Settings → Organization → Installed Apps, pastes theclient_idyou 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,
/authorizereturnsinvalid_scopenaming 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_granton refresh as "we have been uninstalled here", not as a transient error. tidstill 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 happened | delivery | what your callback sees |
|---|---|---|
| Organization has not installed your app | 403, a page on our side | nothing — no redirect |
| Organization withdrew your app | 403, our page | nothing |
| Client belongs to a different organization | 403, our page | nothing |
| Client is disabled | 403, our page | nothing |
| Requested scope is above the installation's ceiling | redirect 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:
-
Generate a PKCE
code_verifierand its S256code_challenge, and a randomstate(and anoncefor the id_token). Redirect the browser to/authorize: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=S256PKCE S256 is mandatory— a request withplainor no challenge is refused, and a code issued with a challenge requires the matching verifier at/token.Optional OIDC interaction parameters.
promptandmax_ageare enforced:Parameter What 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=0behaves likeprompt=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_requiredor?error=consent_required(plus yourstate) at yourredirect_uri— retry withoutprompt=none.login_hintAccepted and passed through; the sign-in screen does not pre-fill from it. Treat it as a no-op. Unknown
promptvalues, andnonecombined with any other value, are refused with?error=invalid_request.max_agemust be a non-negative integer. -
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). -
Zynth redirects the browser back to your
redirect_uriwithcodeand yourstate(verifystatematches). If the user declines, you get?error=access_denied&state=…instead. -
Your server exchanges the code at
/token: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)) orclient_secret_post(client_secretform field). The response:{ "access_token": "…", "id_token": "…", "refresh_token": "…", "token_type": "Bearer", "expires_in": 900, "scope": "openid profile email" }refresh_tokenappears only if the client has therefresh_tokengrant;id_tokenonly whenopenidis 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). issexactly equals your issuer,audequals yourclient_id,expnot passed.nonceequals the value you sent in step 1.at_hashmatches the access token (if your library checks it).
⚠️ Two issuers — never share verifier config between token types. The id_token's
issis the OIDC issuer URL (https://auth.zynthmedia.comon the managed service — theissuerin 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_idvs.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.
{ "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.
{ "sub": "…", "tid": "3f1c…", "roles": ["admin", "invoice_clerk"] }
rolesis 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/checkalready speaks.- It reflects the organization that signed in (
tid) and never any other organization the user belongs to. - There is no
groupsclaim: Zynth has roles, and an identity provider's groups are mapped onto roles when the user signs in through enterprise SSO, sorolesis the result of that mapping. - It is bounded at 64 codes. Past that the list is omitted and
roles_truncated: trueis 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:
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.
profileclaims 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. Readclaims_supportedfrom 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:
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:
{ "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: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_hintis required (it attributes the request to your client; without it an open-redirect can't be formed).post_logout_redirect_urimust 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):
{
"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:
- Signature against
/.well-known/jwks.json(RS256,kid-selected). issis the OIDC issuer — the discoveryissuer, the same value as your id_tokens (never the access-token issuerzynth-auth).audis yourclient_id;iat/expare current (the token lives ~2 minutes).- The
eventsclaim contains thehttp://schemas.openid.net/event/backchannel-logoutmember. - A
nonceclaim is absent — its presence is grounds for rejection (this is what stops an id_token being replayed as a logout token). - Optionally track
jtiin 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:
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:
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 theauthz:checkscope) asks whether the token is live now, and a revoked grant or a killed agent answersactive: 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 unprovenredirect_uriorclient_idare 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 a403page on our side, and your callback is never called. The one admission error that does reach you isinvalid_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_descriptionsays which),invalid_client(401),unauthorized_client(grant not enabled for this client),unsupported_grant_type.- Rate limits —
/authorizeand/tokenare throttled and fail closed; over the cap you get429withRetry-Afterplus theRateLimit-*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
- Verifying tokens — validate the tokens in your own APIs.
- API reference: OpenID Provider — the exact request/response contracts.
- Deployment models — where the issuer comes from on self-host.