Log in with Zynth Auth (OpenID Connect)
Zynth Auth is a standards OpenID Provider (OAuth 2.1 + OIDC Core, ADR-0052). 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 |
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"],
id_token_signing_alg_values_supported: ["RS256"], scopes_supported: ["openid", "profile", "email"]. 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. - Grant types — default
["authorization_code"]. Addrefresh_tokenfor long-lived sessions (it requiresauthorization_code); addclient_credentialsfor machine-to-machine (confidential clients only). 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).
You receive a client_id (public identifier) and, for confidential clients, the
show-once client_secret.
Integrating with more than one organization
A client is bound to its organization — one client can never authenticate another
organization's users, and there is no cross-org client. 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. Design for this from
the start; retrofitting per-org registration into a single-client integration is the
painful version.
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 yet. Treat it as a no-op for now. 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.
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. 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)
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 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; there is no introspection endpoint to substitute. The revocation story for resource-bound tokens is the short access-token TTL plus back-channel logout — wire up yourbackchannel_logout_uribefore adopting resource indicators if you need fast revocation. 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./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. Device authorization grant, CIBA, dynamic client registration (RFC 7591), PAR, and JAR/JARM are out of scope for this profile. Resource indicators (RFC 8707) are supported — see audience-bound tokens. Zynth Auth supports OIDC; formal OpenID Foundation conformance certification is a later milestone. If your integration needs one of the deferred pieces, 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.