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

Verifying access tokens

Your services verify Zynth Auth tokens offline against the published JWKS — no per-request call to Zynth Auth.

First: which side are you on?

The same access token is two different things depending on where you stand, and the rules are opposite. Get this wrong and you build on a surface that can change under you.

You are…The access token is…What to do
A resource server — your own API, receiving the token and deciding whether to serve the requestA document you verify. This page is for you.Follow the recipe below.
An OAuth/OIDC client — the app that obtained the token and passes it to an APIOpaque (RFC 6749 §1.4). Its claims are not part of our API contract and may change in any release, including a patch.Do not decode it. Take sub/tid from the id_token or /userinfo — those are contract. See Log in with Zynth Auth.

The most common way an integration breaks silently months later is a client decoding the access token to read tid. If you did not receive the token from a caller, you are the client.

Using TypeScript or JavaScript? Use the SDK instead. @zynth/auth-client implements everything on this page — JWKS caching with rotation handling, RS256 allowlisting, issuer/audience pinning, type=access enforcement — behind createVerifier(), with typed claims and typed errors. Every mistake listed at the bottom of this page is one the SDK does not let you make. This page remains the reference for other languages, and for understanding what the SDK does on your behalf.

The recipe

  1. Fetch and cache the JWKS from GET /.well-known/jwks.json. Cache it; refresh on a miss (see step 3).
  2. Read the token header's kid and select the matching key from the JWKS.
  3. If no key matches (e.g. after a rotation), refetch the JWKS once and retry. The endpoint publishes up to three keys — the retiring previous key (verify-only), the active signing key, and a staged next key — so tokens signed before, during and after a rotation all keep verifying. Select by kid; never assume a key count, and never take keys[0] as the active key (during a rotation it is the retiring one).
  4. Verify the signature with algorithm pinned to RS256 — never accept alg values from the token, and never allow none.
  5. Validate claims: iss = zynth-auth, exp not passed, and aud = the audience your service was issued for (see below). Confirm type = access.
  6. Use sub (user) and tid (tenant) as the identity. For authorization beyond identity, resolve permissions server-side — don't infer them from the token.

Which iss should you expect?

zynth-auth — always, for access tokens, on every deployment. Do not confuse it with the OIDC issuer URL (https://auth.zynthmedia.com on the managed service; your own domain on self-host): that value is the iss of id_tokens and the issuer in the discovery document, and it never appears in an access token. The two token types are different documents with different issuers and different audiences — validate them with separate verifier configurations, never one shared config. In particular, if your resource-server library derives its expected issuer from the discovery document, it will compute the OIDC issuer and reject every access token — configure zynth-auth explicitly instead. id_token validation lives in Log in with Zynth Auth.

Which aud should you expect?

Two cases, and you must know which one you are:

  • zynth-services — the default platform audience, carried by tokens minted without a resource parameter (the SDK/REST path, and OP grants that didn't name a resource).
  • Your own resource identifier — when the token was minted for a registered OAuth resource (RFC 8707). Its aud is that identifier, and the token is then rejected by the Zynth API and by every other resource. This is the stronger posture: register your API as a resource, have clients name it, and pin audience= to your identifier.

Pin whichever one applies — never accept both, and never skip the check. A validly-signed token for a different audience is not a valid token for yours; that check is the whole confused-deputy defence. Registering a resource and verifying its tokens end to end is walked through in Securing MCP servers — the procedure is not MCP-specific.

Note the flip side of the binding: a resource-bound token verifies only at its named resource — including at Zynth itself, so it cannot call /userinfo. If your client-side pattern uses a per-request UserInfo call as a revocation check, read the trade-off before adopting resource-bound tokens.

Example (Python, PyJWT)

import jwt
from jwt import PyJWKClient

jwks = PyJWKClient("https://your-zynth-auth-host/.well-known/jwks.json")

def verify(token: str) -> dict:
    signing_key = jwks.get_signing_key_from_jwt(token)  # matches by kid
    claims = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],           # pin the algorithm
        issuer="zynth-auth",            # the ACCESS-token issuer — not the discovery issuer
        audience="zynth-services",      # or YOUR resource identifier — see above
    )
    if claims.get("type") != "access":
        raise ValueError("not an access token")
    return claims

Authorization claims for your resource (28.5)

If your API is a registered resource with a bound permission namespace (see Resources in the console), an access token minted for your audience additionally carries the caller's authority for it:

ClaimMeaning
rolesThe principal's role codes in the issuing tenant.
permissionsEffective permissions filtered to your namespace (e.g. invoices:approve).
permissions_truncatedtrue when the list exceeded the in-token cap and was omitted — resolve via the check API; never treat this as "allowed".

Two rules to build against:

  • Staleness bound = the access token's lifetime. The claims are re-assembled at every refresh, so a role change lands at the next rotation. For real-time answers, use the check API below.
  • A missing claim means "no vocabulary bound", not "everything allowed". Absence and emptiness both deny.

The TypeScript SDK ships hasPermission(claims, code) (which answers "unknown" on a truncated list) and createCheckClient(...).

The check API

  • POST /api/v1/authz/check — a signed-in principal checks itself (batch up to 50).
  • POST /api/v1/authz/service-check — your service asks about any principal in your tenant, authenticated with a client-credentials access token whose scope includes authz:check.
{ "checks": [ { "permission": "invoices:approve", "principal_id": "<user id>" } ] }

Each result carries allowed, via (rbac / policy / owner / defaultdefault is the one you see on every denial, whether the principal lacks the permission or has no active membership at all), a human reason, and obligations — answered by the same engine that authorizes every platform route. Treat any transport or HTTP error as a deny: a refusal to answer is never an allow.

A non-empty obligations list means "not yet", not "yes". When a require_approval policy governs the permission, the check answers allowed: true with obligations: ["require_approval"] — the action may proceed only after a human approval is granted and spent through the approvals ledger. A caller that reads only allowed will treat a gated permission as open; always check both fields.

Common mistakes

  • Not pinning the algorithm → algorithm-confusion attacks. Always pass algorithms=["RS256"].
  • Trusting a role/permission claim — access tokens don't carry permissions by design.
  • Not handling rotation — if kid isn't found, refetch the JWKS once before failing.
  • Skipping aud/iss checks — a validly-signed token for a different audience is not a valid token for yours.
  • Validating id_tokens and access tokens with one shared config — they carry different issuers (https://… vs zynth-auth) and different audiences (client_id vs zynth-services/resource), so a shared verifier always rejects one of them. See which iss.
  • Accepting either audience — configuring your verifier to allow zynth-services and your resource identifier throws away the binding you registered the resource to get.
  • Decoding the access token from the client side — see which side are you on. Use the id_token or /userinfo.

Next