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 request | A 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 API | Opaque (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-clientimplements everything on this page — JWKS caching with rotation handling, RS256 allowlisting, issuer/audience pinning,type=accessenforcement — behindcreateVerifier(), 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
- Fetch and cache the JWKS from
GET /.well-known/jwks.json. Cache it; refresh on a miss (see step 3). - Read the token header's
kidand select the matching key from the JWKS. - 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 takekeys[0]as the active key (during a rotation it is the retiring one). - Verify the signature with algorithm pinned to
RS256— never acceptalgvalues from the token, and never allownone. - Validate claims:
iss=zynth-auth,expnot passed, andaud= the audience your service was issued for (see below). Confirmtype=access. - Use
sub(user) andtid(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 aresourceparameter (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
audis 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 pinaudience=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:
| Claim | Meaning |
|---|---|
roles | The principal's role codes in the issuing tenant. |
permissions | Effective permissions filtered to your namespace (e.g. invoices:approve). |
permissions_truncated | true 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 includesauthz:check.
{ "checks": [ { "permission": "invoices:approve", "principal_id": "<user id>" } ] }
Each result carries allowed, via (rbac / policy / owner / default — default 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
kidisn't found, refetch the JWKS once before failing. - Skipping
aud/isschecks — 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://…vszynth-auth) and different audiences (client_idvszynth-services/resource), so a shared verifier always rejects one of them. See whichiss. - Accepting either audience — configuring your verifier to allow
zynth-servicesand 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
- TypeScript SDK — the preferred path for Node and edge runtimes.
- Securing MCP servers — registering a resource so your API gets its own token audience.
- Troubleshooting — verification failures and fixes.