The negative-test sections of jwt-claims-to-headers, jwt-oidc-obo and jwt-token-exchange all reference the same set of bad tokens (expired, wrong audience, wrong issuer, tampered signature, missing claim). Rather than repeating the recipe in every lab, this page is the single source of truth: run the script, source the output, then every lab's curl examples have the right token in the right shell variable.
1 · Prereqs
Python 3, plus two libraries. No gateway-specific tooling needed to run the minter itself.
$ pip install pyjwt cryptography
The script writes three files in the current directory:
private.pem (the RS256 signing key, do not commit),
jwks.json (the matching public key in JWKS format,
this is what the gateway will fetch), and tokens.env
(a list of export NAME_JWT=... lines, one per
variant).
2 · The mint script
Save as mint-test-jwts.py. It generates one
keypair, builds a JWKS with kid=test-1, and signs
every variant the labs reference from the same base payload so
the only thing that differs between a good token and a bad one
is the single field the lab is testing.
mint-test-jwts.py Keypair + JWKS + every variant
#!/usr/bin/env python3
# mint-test-jwts.py
#
# Mints a fresh RS256 keypair, writes the matching JWKS, and emits
# every JWT variant referenced by the JWT labs on this site.
#
# Usage:
# $ pip install pyjwt cryptography
# $ python mint-test-jwts.py > tokens.env
# $ source tokens.env
# $ python -m http.server 8080 # serve jwks.json for the gateway
import base64, json, time
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
# 1. Fresh RS256 signing key
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
priv = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption())
open("private.pem", "wb").write(priv)
# 2. Matching JWKS — point the gateway's remoteJwks/jwksUri at this
def b64u_int(i):
return base64.urlsafe_b64encode(
i.to_bytes((i.bit_length() + 7) // 8, "big")).rstrip(b"=").decode()
n = key.public_key().public_numbers()
open("jwks.json", "w").write(json.dumps({"keys": [{
"kty": "RSA", "use": "sig", "alg": "RS256", "kid": "test-1",
"n": b64u_int(n.n), "e": b64u_int(n.e)}]}, indent=2))
# 3. Base payload — every variant below is a one-field mutation of this
now = int(time.time())
base = {
"iss": "https://test-idp.example.com",
"aud": "api.example.com",
"sub": "alice",
"tenantId": "tnt_acme",
"groups": ["orders-readonly"],
"scope": "invoke.orchestrator",
"iat": now,
"exp": now + 3600,
}
sign = lambda p: jwt.encode(p, priv, algorithm="RS256",
headers={"kid": "test-1"})
# 4. Variants — name : payload-mutation
variants = {
"VALID": base,
"VALID_READONLY": base,
"VALID_ADMIN": {**base, "groups": ["orders-readonly", "orders-admin"]},
"EXPIRED": {**base, "iat": now - 7200, "exp": now - 3600},
"WRONG_AUD": {**base, "aud": "api.other.com"},
"WRONG_ISS": {**base, "iss": "https://other-idp.example.com"},
"NO_TENANT": {k: v for k, v in base.items() if k != "tenantId"},
}
out = {name: sign(payload) for name, payload in variants.items()}
# Tampered: take VALID, flip one byte in the signature segment
parts = out["VALID"].split(".")
parts[2] = parts[2][:-2] + ("B" if parts[2][-2] == "A" else "A") + parts[2][-1]
out["TAMPERED"] = ".".join(parts)
for name, tok in out.items():
print(f"export {name}_JWT={tok}")
3 · Run it
Mint, source, sanity-check.
$ python mint-test-jwts.py > tokens.env
$ source tokens.env
$ echo $VALID_JWT | cut -c1-40
eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QtMSJ9...
$ echo $EXPIRED_JWT | cut -c1-40
eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QtMSJ9...
4 · Serve the JWKS to the gateway
The gateway needs to fetch jwks.json over HTTP to
verify any of these tokens. For a kind-cluster lab, the simplest
thing is python -m http.server in the same directory,
bound to a hostname the gateway pods can reach.
$ python -m http.server 8080
$ curl -s http://<your-host>:8080/jwks.json | jq .keys[0].kid
"test-1"
Then point the gateway's JWT validation at it. The exact field depends on the lab:
| Product | CRD | JWKS field | Issuer / audience fields |
|---|---|---|---|
| kgateway | AuthConfig |
jwt.providers.<name>.remoteJwks.url |
issuer, audiences[] |
| Istio | RequestAuthentication |
jwtRules[].jwksUri |
jwtRules[].issuer, jwtRules[].audiences[] |
| agentgateway | Backend / route policy |
jwt.jwks.url (or inline) |
jwt.issuer, jwt.audiences[] |
Set issuer: https://test-idp.example.com and
audiences: ["api.example.com"] to match what the
script signs.
5 · Variant reference
Every variable the labs reference, what changed from
VALID, and which negative case it's there to drive.
| Shell var | Mutation from VALID |
Rejection it drives |
|---|---|---|
$VALID_JWT |
(none) — the happy-path token | Positive baseline. Used as the control for every lab. |
$VALID_READONLY_JWT |
Same as VALID (alias). groups: ["orders-readonly"]. |
Positive baseline for an AuthorizationPolicy that allows readonly group. |
$VALID_ADMIN_JWT |
groups: ["orders-readonly", "orders-admin"] |
Confirms a privileged group reaches the backend where readonly is denied. |
$EXPIRED_JWT |
exp = now - 3600 (one hour in the past) |
JWT filter rejects on the time check. Response body: Jwt is expired. |
$WRONG_AUD_JWT |
aud = "api.other.com" |
Audience check rejects. Response body: Audiences in Jwt are not allowed. |
$WRONG_ISS_JWT |
iss = "https://other-idp.example.com" |
No JWKS configured for that issuer. Response body: Jwt issuer is not configured. |
$NO_TENANT_JWT |
tenantId claim removed |
JWT passes; claimsToHeaders mapping has nothing to copy. |
$TAMPERED_JWT |
One byte flipped in the signature segment of VALID |
Signature check fails. Response body: Jwt verification fails. |
6 · What each lab uses
The labs reference these variables by name. Click into a lab's negative-tests section for the full curl + expected-rejection per case.
| Lab | Negative-test section | Variants used |
|---|---|---|
| jwt-claims-to-headers | §7 | VALID, EXPIRED, WRONG_AUD, WRONG_ISS, NO_TENANT, TAMPERED |
| jwt-oidc-obo | §6 | VALID_READONLY, VALID_ADMIN, EXPIRED, WRONG_AUD, WRONG_ISS, TAMPERED |
| jwt-token-exchange | Negative tests | VALID (as subject token), EXPIRED, WRONG_AUD, TAMPERED |
cnf.x5t#S256 (RFC 8705), or anything that needs the
STS to issue a token (lab 3's $HOP1,
$HOP2, $REPLAYED tokens all come from
the STS itself, not from this script). For those, the lab's own
walkthrough is the source.
Where to go next
Back to the labs that reference this helper: jwt-claims-to-headers, jwt-oidc-obo, jwt-token-exchange.
Upstream references: RFC 7519 (JWT), RFC 7517 (JWKS), PyJWT.