Private betaZynth Auth is currently in private beta testing.New organizations are created by invitation only, and no plan can be purchased yet.Request early access

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:

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

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_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.
  • Grant types — default ["authorization_code"]. Add refresh_token for long-lived sessions (it requires authorization_code); add client_credentials for 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:

  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:

    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 yet. Treat it as a no-op for now.

    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:

    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:

    {
      "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.
{ "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 scopeemail + 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.

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:

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_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):

{
  "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 sidbackchannel_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 your backchannel_logout_uri before 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 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.
  • /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. 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