Error message
unexpected "iss" claim value
jose (JavaScript) — JWTClaimValidationFailed, code ERR_JWT_CLAIM_VALIDATION_FAILED
What it means
The token verified cryptographically, but its `iss` claim is not equal to the `issuer` option you passed to `jwtVerify`. jose compares the strings exactly (an array of allowed issuers is also accepted), so any difference — a trailing slash, a different tenant path, another environment — fails the check. Pinning the issuer is what stops a token from another identity provider, or another tenant of the same provider, from being accepted.
Why it happens
Trailing slash or scheme differs
commonProviders are inconsistent: Auth0 issues `https://tenant.auth0.com/` with a slash, Keycloak and Entra ID v2.0 (`…/v2.0`) issue URLs without one, while Entra ID v1.0 tokens carry `https://sts.windows.net/<tenant>/` with one. `https://login.example.com` and `https://login.example.com/` are different issuers to an exact comparison.
Wrong tenant, realm or authorization server
commonMulti-tenant providers put the tenant into the issuer: Entra ID `https://login.microsoftonline.com/<tenant-id>/v2.0`, Keycloak `https://host/realms/<realm>`, Okta `https://<org>.okta.com/oauth2/<server-id>`. Verifying against the default server while tokens come from a custom one (or the v1.0 vs v2.0 Entra endpoint) produces this error.
Staging token on a production verifier
occasionalTokens from a staging or developer tenant carry that tenant's issuer. When configuration is copied between environments the verifier and the token source drift apart.
Several legitimate issuers, one configured
occasionalServices that accept tokens from more than one identity provider sometimes configure only the first. The fix is a deliberate allow-list with one key set per issuer, not dropping the `issuer` option.
How to fix it
- 1.
Take the issuer string from the discovery document
The `issuer` value in `/.well-known/openid-configuration` is, by specification, exactly what the provider writes into `iss`. Copy it byte for byte into your configuration.
bash # The issuer string is defined by the provider's discovery document. curl -s https://login.example.com/.well-known/openid-configuration | jq -r .issuer - 2.
Pin that exact value in jwtVerify
Keep the `issuer` option — without it jose does not check `iss` at all — and keep it in one constant shared by the JWKS URL and the verifier.
js import { createRemoteJWKSet, jwtVerify } from "jose"; const ISSUER = "https://login.example.com/"; // exactly as in "issuer" from discovery const jwks = createRemoteJWKSet(new URL("https://login.example.com/.well-known/jwks.json")); const { payload } = await jwtVerify(token, jwks, { algorithms: ["ES256"], issuer: ISSUER }); - 3.
Accept several issuers with one key set each
If your service really trusts more than one issuer, select the key set by the token's (unverified) `iss` from an explicit allow-list and then verify with that issuer pinned. Never let one issuer's keys verify another issuer's tokens.
js import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose"; // Each trusted issuer gets its own key set; an unknown iss is rejected before any key is used. const trusted = new Map([ ["https://login.example.com/", createRemoteJWKSet(new URL("https://login.example.com/.well-known/jwks.json"))], ["https://partner.example.org", createRemoteJWKSet(new URL("https://partner.example.org/jwks"))], ]); const { iss } = decodeJwt(token); const keys = trusted.get(iss); if (!keys) throw new Error("untrusted issuer"); await jwtVerify(token, keys, { algorithms: ["ES256", "RS256"], issuer: iss });