Skip to content

Validate JWTs in your backend

When your app receives an id_token from the OIDC callback (or an access_token in an Authorization: Bearer header), your backend has to validate it before trusting it. Validating means four things:

  1. Signature correct against the issuer’s public key (JWKS).
  2. iss = https://auth.<your_slug>.prysmid.com exactly.
  3. aud = your client_id.
  4. exp > now.

Miss any of the four and you reject. There is no “validate ish”.

import { createRemoteJWKSet, jwtVerify } from 'jose';
const ISSUER = 'https://auth.acme.prysmid.com';
const AUDIENCE = process.env.PRYSMID_CLIENT_ID;
// JWKS auto-caches with sensible TTL and refreshes on key rotation.
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/oauth/v2/keys`));
export async function verifyIdToken(token) {
const { payload } = await jwtVerify(token, jwks, {
issuer: ISSUER,
audience: AUDIENCE,
});
return payload; // { sub, email, name, exp, iat, ... }
}

Cache JWKS, but not forever. Keys rotate. If your library caches JWKS for days without honoring Cache-Control or kid mismatch, you’ll start rejecting valid tokens when we rotate. The libraries above (jose, PyJWKClient, go-oidc) handle this correctly by default.

Clock. If your server has minute-level clock skew, you’ll reject freshly issued tokens by exp. Sync NTP.

access_token vs id_token. The id_token is for your backend (proves user identity). The access_token is for talking to APIs. Don’t mix them.

Multi-tenant: validate resourceowner. If you serve many tenants in one instance, iss/aud/exp/sig isn’t enough — also check that urn:zitadel:iam:user:resourceowner:id (canonical claim URN that arrives in the JWT) matches the tenant the user is trying to access. Otherwise Bob from Acme could use his Acme token to access Globex resources in your app.

ErrorTypical cause
Invalid signatureYou cached a stale JWKS, or your library doesn’t support RS256 (check flags).
Invalid audienceYour client_id changed, or the token came from another app in your workspace.
Token expiredThe id_token lasts ~1h. Refresh with refresh_token.
Issuer mismatchYou changed the workspace slug. The iss reflects it: auth.<slug>.prysmid.com.