June 15, 2026

Debug OIDC token validation with a disposable public issuer

Use a disposable public OIDC issuer to mint controlled tokens, tune verifier claim rules, and catch issuer, audience, subject, and JWKS problems before production.

Most OIDC debugging starts too late.

You wire a real issuer into a real relying party, the verifier rejects the token, and the error says something like "invalid identity token" or "claim mismatch." That error could mean the verifier could not fetch discovery. It could mean the iss claim has a trailing slash. It could mean the aud is wrong, the sub format changed, the JWKS is stale, or your custom claim matcher is looking at the wrong field.

Worse, a successful test can be misleading. A rule that accepts your intended token might also accept tokens from branches, repositories, projects, or workloads that should not have access.

The better workflow is to make the token contract explicit before production: mint controlled tokens, test the verifier against them, and prove both the accept and reject cases.

Why a disposable public issuer helps

An anonymous oidc.pub service is a short-lived OIDC issuer surface with a public -anon subdomain. It does not require an account, it expires automatically within 24 hours, and the CLI can run a local dev issuer that publishes discovery and JWKS to that temporary URL.

That gives you a small test harness for the part of OIDC federation that usually hurts:

  • The issuer URL is real and public, so cloud verifiers can fetch /.well-known/openid-configuration and JWKS the same way they will in production.
  • You can mint tokens with exact claims instead of waiting for a CI job, cluster, Vault role, or application path to emit one.
  • You can test positive cases: this sub, aud, and set of custom claims should be accepted.
  • You can test negative cases: a different branch, project, audience, or tenant should be rejected.
  • You can separate "the token is wrong" from "the verifier rule is wrong."

This is especially useful when the verifier supports its own matching language: AWS IAM condition keys, Vault JWT roles, GCP Workload Identity Federation attribute mappings, an API gateway policy, or an in-house authorization rule. The anonymous issuer lets you exercise the matcher with known inputs.

Start a disposable issuer

Run the anonymous dev issuer:

$ npx oidc.pub dev issuer --anonymous
• Anonymous service created: Anonymous dev issuer 8198e108 subdomain=wep2u4yq-anon
• Local OIDC issuer running on http://localhost:9229
Issuer URL: https://wep2u4yq-anon.oidc.pub
Temp service: wep2u4yq-anon (deleted on exit)
Dashboard: https://oidc.pub/dashboard/anonymous/wep2u4yq-anon#secret=...
Secret: oidcpub_anon_3u3mEveXsf_...
Discovery: http://localhost:9229/.well-known/openid-configuration
JWKS: http://localhost:9229/.well-known/jwks.json
Mint token:
curl http://localhost:9229/token
Mint token with custom claims:
curl -X POST http://localhost:9229/token \
-H 'Content-Type: application/json' \
-d '{"sub":"alice","groups":["admin"]}'
• Fetching OIDC discovery document url=http://localhost:9229/.well-known/openid-configuration
• Fetching JWKS url=http://localhost:9229/.well-known/jwks.json
• Uploading config to oidc.pub url=https://oidc.pub/api/services/wep2u4yq-anon/config
• Sync complete (config updated) subdomain=wep2u4yq-anon
Config synced to wep2u4yq-anon.oidc.pub
Interactive - press Enter to mint with defaults, or type claims as
JSON ({"sub":"alice"}) or key=value pairs (sub=alice role=admin).
Type :help for commands, :quit to exit.
mint>

The CLI creates a temporary anonymous service, starts a local issuer, publishes the discovery document and JWKS, and prints the public issuer URL. See the oidc.pub CLI reference for every flag the dev issuer command accepts.

Leave it running. Configure the relying party to trust the public issuer URL from the CLI output. Tokens minted by the dev issuer use that same URL in the iss claim, so verifier discovery, JWKS fetches, signature verification, and claim checks all exercise the same path your production setup will use.

Mint tokens with deliberate claims

For quick checks, the interactive minting console is the fastest path. Type the claims you want to test as key=value pairs, mint a token, copy it into the relying party or validator, and watch exactly which rule accepts or rejects it. (For scripted or repeatable runs, curl the dev issuer's token endpoint instead — shown below.)

Start with the smallest token that should be accepted:

mint> sub=repo:acme/api:ref:refs/heads/main aud=deploy-api repository=acme/api ref=refs/heads/main environment=production
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjE2YjI...snip
mint>

Then mint nearby tokens that should be rejected:

mint> sub=repo:acme/api:ref:refs/heads/feature aud=deploy-api repository=acme/api ref=refs/heads/feature environment=production
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjE2YjI...snip
mint>

That second token is often where the useful bugs show up. If the verifier accepts it, the rule is too broad.

To script the same flow — or to capture a token for reuse — call the local token endpoint directly:

$ TOKEN=$(curl -s -X POST http://localhost:9229/token \
  -H "Content-Type: application/json" \
  -d '{
    "sub": "repo:acme/api:ref:refs/heads/main",
    "aud": "deploy-api",
    "repository": "acme/api",
    "ref": "refs/heads/main",
    "environment": "production"
  }' | jq -r .access_token)

To inspect what's inside a token, paste it into jwt.io — it decodes the header and payload and can verify the signature against your JWKS. You should see the exact claims you provided, plus standard JWT claims like iss, iat, and exp (and jti, unless you started the issuer with --omit-jti). Note that aud only appears if you set it. There's no sensible default to pick: the audience names the specific verifier the token is meant for, which only you know — so set it explicitly with --audience or an aud claim.

That claim matters more than its optional treatment suggests. The audience is what scopes a token to a single verifier; without it, a token minted for one relying party can be accepted by any other verifier that trusts the same issuer. That's a token-redirect risk — a token honored by a recipient it was never issued for — and it's how a malicious or compromised intermediary turns a verifier into a confused deputy. The defense is audience restriction: a well-configured verifier rejects tokens whose aud doesn't name it, so set aud deliberately when minting and confirm the verifier enforces it (the "same sub, wrong aud" case below).

Test both sides of the matcher

With tokens in hand, the test itself is simple: hand each one to the system you're actually configuring — the cloud provider, Vault role, API gateway, or custom validator — and watch which rule accepts or rejects it. The most important part is not proving that one happy-path token works. It is proving that the boundary is where you think it is.

For every verifier rule, mint at least these tokens:

TokenExpected resultWhat it proves
Exact production-shaped claimsAcceptedThe issuer and verifier agree on the intended contract.
Same sub, wrong audRejectedAudience binding is enforced.
Same aud, wrong branch/project/workload in subRejectedSubject scoping is not too broad.
Correct sub, missing custom claimRejectedCustom claim requirements are actually active.
Correct custom claim, wrong subRejectedCustom claims are not accidentally replacing identity scoping.
Expired tokenRejectedThe verifier checks time claims.

This is where controlled minting pays off. You do not need to trigger six CI jobs, create temporary Kubernetes service accounts, or change a Vault role repeatedly just to learn how the verifier interprets its own matching rules.

Worked example: assume an AWS role

Here is the loop end to end against a real verifier. Say you've registered https://wep2u4yq-anon.oidc.pub as an IAM OIDC identity provider and created a role whose trust policy federates with it. The part that does the matching is the condition block:

"Condition": {
  "StringEquals": {
    "wep2u4yq-anon.oidc.pub:aud": "deploy-api",
    "wep2u4yq-anon.oidc.pub:sub": "repo:acme/api:ref:refs/heads/main"
  }
}

Mint a token that matches and exchange it. AssumeRoleWithWebIdentity takes no AWS credentials — the token is the credential — so you can run this from anywhere the AWS CLI is installed (a default region is the only setup it needs):

$ TOKEN=$(curl -s -X POST http://localhost:9229/token \
  -H "Content-Type: application/json" \
  -d '{"sub":"repo:acme/api:ref:refs/heads/main","aud":"deploy-api"}' | jq -r .access_token)

$ aws sts assume-role-with-web-identity \
  --role-arn arn:aws:iam::123456789012:role/oidc-pub-deploy \
  --role-session-name oidc-pub-test \
  --web-identity-token "$TOKEN" \
  --query 'Credentials.AccessKeyId' --output text
ASIA...   # temporary credentials returned: the trust policy accepted the token

Now run the negative case — same aud, a sub on a different branch — through the exact same command:

$ TOKEN=$(curl -s -X POST http://localhost:9229/token \
  -H "Content-Type: application/json" \
  -d '{"sub":"repo:acme/api:ref:refs/heads/feature","aud":"deploy-api"}' | jq -r .access_token)

$ aws sts assume-role-with-web-identity \
  --role-arn arn:aws:iam::123456789012:role/oidc-pub-deploy \
  --role-session-name oidc-pub-test \
  --web-identity-token "$TOKEN" \
  --query 'Credentials.AccessKeyId' --output text

An error occurred (AccessDenied) when calling the AssumeRoleWithWebIdentity operation: Not authorized to perform sts:AssumeRoleWithWebIdentity

That AccessDenied is the result you want from the negative case: it proves the sub condition is scoping access to the branch, not just to the repository.

One AWS-specific lesson falls out of this immediately: STS trust policies can only match :aud, :sub, and :amr — not arbitrary custom claims. To scope on a branch, repository, or environment, you have to encode it into sub, which is exactly why GitHub Actions packs repo:<org>/<repo>:ref:<ref> into the subject. Minting tokens by hand surfaces that constraint in minutes instead of after a half-day of trust-policy edits.

Common failure modes

Whatever the surface error, the fastest way to localize the bug is to change one thing at a time: keep the issuer stable, mint one changed token, and observe the verifier result. Then change the verifier rule and re-run the same token.

"Invalid identity token" before any claim matching

The verifier rejected the token before it ever looked at your claims — usually a failed discovery or JWKS fetch. AWS STS returns a generic InvalidIdentityToken; Vault reports error fetching jwks. Run curl "$ISSUER_URL/.well-known/openid-configuration" from outside your network and confirm the jwks_uri it points to is reachable.

Issuer mismatch

Local verification fails with an issuer error because the iss claim does not exactly match the verifier's configured issuer. Compare the token iss, the discovery document's issuer field, and the verifier's issuer URL byte-for-byte — including trailing slashes.

Signature verification failed

The JWKS does not contain the key that signed the token. Compare the JWT header's kid with the keys in the public JWKS. If you restarted the dev issuer, its in-memory keypair changed — re-sync so the public JWKS matches the new kid.

Accepted locally, rejected by the relying party

A normal JWT verifier accepts the token but your relying party does not, which points at relying-party policy rather than the token itself. Check the audience, subject syntax, claim-mapping names, and provider registration.

A token without intended access is accepted

The matcher is too broad. Add negative test tokens for sibling branches, repositories, tenants, environments, or service accounts, and tighten the rule until they are rejected.

A custom claim never matches

The claim path or name does not line up with the verifier's mapping syntax. Decode the token and compare the exact JSON field name to the verifier's configuration.

Works locally but not in a cloud provider

The provider may not expose that claim to its policy conditions. Confirm the provider supports the specific claim — some verifiers only surface a selected subset in policy expressions.

Move to production with confidence

oidc.pub offers anonymous services free for debugging and setup. They are temporary by design — once the claim contract and verifier policy are correct, you switch the verifier over to the real issuer.

The payoff of testing with controlled tokens is that this switch is uneventful. You already proved the verifier accepts exactly the right tokens and rejects everything else, so production is just pointing it at the real source of those tokens. You don't re-derive the matching rules by triggering CI jobs, cycling Kubernetes service accounts, or waiting on whatever slow path your real issuer uses to mint a token — that tedious loop is the thing the disposable issuer let you skip.

What actually changes on the way to production:

  1. Point the relying party at the issuer URL your real tokens carry in iss — there's nothing to invent here; it's whatever the issuer already uses.
  2. Make sure the IdP mints tokens with that exact URL.
  3. Confirm the discovery document and JWKS are reachable from the verifier.
  4. Smoke-test once: confirm a real token from the issuer is accepted. The reject cases you already proved against controlled tokens — you don't have to manufacture real wrong-branch or wrong-audience tokens to trust them.

Most of the time that issuer URL is just your production system. But if the real issuer is private — a GitLab instance, a Kubernetes API server, Vault — the verifier still can't reach it, and you'll want to expose only its discovery and JWKS to public verifiers rather than the whole IdP. A named oidc.pub service does exactly that: a stable public discovery and JWKS endpoint, with the real issuer staying wherever it belongs.

That is the point of the exercise. You are not just checking whether OIDC works. You are checking whether your issuer and verifier agree on exactly who should be trusted — before production, not during an incident.


← All posts · Subscribe via RSS