MastertheMesh
Lab helper · JWT
Reusable helper

Mint your own test JWTs

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

One script that mints a fresh RS256 keypair, serves the matching JWKS, and emits every JWT variant the JWT labs reference. Source it once, then the negative-test sections of every JWT lab have the exact tokens they expect waiting in your shell as $VALID_JWT, $EXPIRED_JWT, $TAMPERED_JWT and so on. The cases in each lab tell you which variants they need; this page is where they come from.

PyJWT RS256 JWKS reproducible negative tests

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:

ProductCRDJWKS fieldIssuer / 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.

LabNegative-test sectionVariants 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
What this does not cover. The script mints RFC 7519 / RFC 7515 tokens against a self-hosted JWKS. It does not mint DPoP proofs (RFC 9449), mTLS-bound tokens with 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.