Olympus Docs
CookbookTokens & OAuth2

Validating the `iss` claim

One of OIDC's most-skipped checks, don't skip

OIDC tokens contain iss (issuer). Every consumer MUST verify it. Easy to skip; consequences are severe.

What iss is

{
  "iss": "https://ciam.your-domain.com",
  "sub": "user-uuid",
  ...
}

URL of the issuer. Should match your Olympus instance.

Why verify

Without verification, attacker can present a token from ANY OIDC provider as if it's from yours:

Attacker has account on attacker.com (an OIDC provider).
Gets token with: iss = https://attacker.com, sub = victim's UUID.
Sends to your API.
Your API doesn't check iss → treats token as legit.
Attacker now authorized as victim.

Real attack. Has happened.

Implementation

import { jwtVerify } from "jose";

const { payload } = await jwtVerify(token, jwks, {
  issuer: "https://ciam.your-domain.com",  // ← THIS
  audience: "https://your-api.com",
});

issuer param: jwtVerify throws if claim doesn't match.

What to compare

Exact match. Including:

  • https:// (not http://).
  • Hostname.
  • Path (if any).

https://ciam.your-domain.comhttps://ciam.your-domain.com/ (trailing slash). https://ciam.your-domain.comhttps://CIAM.your-domain.com (case).

Multi-tenant Olympus

If different tenants have different OIDC issuers:

const tenantIssuers = {
  "tenant-A": "https://ciam.tenant-a.com",
  "tenant-B": "https://ciam.tenant-b.com",
};

const { payload } = await jwtVerify(token, getKeysForIssuer(claims.iss));
if (!tenantIssuers[claims.tenant_id] || tenantIssuers[claims.tenant_id] !== payload.iss) {
  throw new Error("issuer_mismatch");
}

Each tenant: their own iss.

Federated IdPs

If your service federates to multiple IdPs (Google, Apple, etc.):

User signs in via Google → token has iss = https://accounts.google.com.

Your service should:

  1. Pre-authorize known issuers (Google, Apple, etc.).
  2. Verify iss matches one of them.
  3. Use issuer-specific JWKS for signature.
const trustedIssuers = new Set([
  "https://accounts.google.com",
  "https://appleid.apple.com",
  "https://login.microsoftonline.com/{tenant}/v2.0",
]);

if (!trustedIssuers.has(payload.iss)) {
  throw new Error("untrusted_issuer");
}

JWKS per issuer

const jwksMap = {
  "https://ciam.your-domain.com": createRemoteJWKSet(new URL("https://ciam.your-domain.com/.well-known/jwks.json")),
  "https://accounts.google.com": createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs")),
};

const claims = decodeJwt(token);  // unverified, just to read iss
const jwks = jwksMap[claims.iss];
if (!jwks) throw new Error("unknown_issuer");

const { payload } = await jwtVerify(token, jwks, { issuer: claims.iss });

For each issuer: keys from their JWKS endpoint.

OIDC discovery

curl https://ciam.your-domain.com/.well-known/openid-configuration | jq

Returns:

{
  "issuer": "https://ciam.your-domain.com",
  "jwks_uri": "https://ciam.your-domain.com/.well-known/jwks.json",
  ...
}

Use this to bootstrap your validation config.

Wrong issuer common cases

Subdomain change

Migrated ciam.Xauth.X. Old tokens still floating around with old issuer.

Old: iss = https://ciam.your-domain.com
New: iss = https://auth.your-domain.com

For backward compat:

const acceptedIssuers = [
  "https://auth.your-domain.com",
  "https://ciam.your-domain.com",  // grandfathered
];
if (!acceptedIssuers.includes(payload.iss)) throw new Error();

After ~30 days, drop the old.

HTTP/HTTPS migration

Switched to HTTPS. Old tokens have http://.... Now mismatched.

// Normalize
const normalizedIss = payload.iss.replace(/^http:/, "https:");

Quick fix; for safety, prefer hard cut.

Cluster move

Different Olympus instance / region. Different issuer URL.

For consistency across regions: configure each region with its own issuer.

OAuth2 access tokens (not OIDC)

If tokens are opaque, you can't read iss directly. Introspect endpoint returns:

{
  "active": true,
  "iss": "https://ciam.your-domain.com",
  ...
}

Verify the introspect response's iss matches expected.

For JWT access tokens (configurable): verify in the JWT.

ID token vs access token

Both have iss. Check both.

ID token: presented at sign-in flow. iss should be your Olympus issuer.

Access token: API calls. Same.

Test

# Generate fake token with wrong iss
node -e "console.log(jwt.sign({ iss: 'attacker.com', sub: 'X' }, 'secret'))"

# Try to use against your API
curl -H "Authorization: Bearer $FAKETOKEN" https://your-api.com/orders

Should return 401.

Common pitfalls

Skipping iss check

// BAD
const { payload } = await jwtVerify(token, jwks);
// No issuer param.

Verify is just signature + expiry. iss is not checked.

// GOOD
const { payload } = await jwtVerify(token, jwks, { issuer });

Trusting iss as a hint

// BAD
const claims = decodeJwt(token);  // not verified!
if (claims.iss === "https://...") {
  return authorize();
}

Anyone can write any iss. Always verify signature first.

On this page