Lab: read a token, then replay it
Decode a JWT with nothing but base64, then mint one for another service and watch a naive verifier accept it.
The previous lesson said a signed JWT is readable by anyone holding it. That is worth proving rather than believing, because the belief that a token is opaque is behind a lot of accidental disclosure.
- 1
Save a token. This one is a realistic 1Claw agent JWT with a fake signature, so there is nothing sensitive here.
bashcat > /tmp/jwt.txt <<'EOF' eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImVkZHNhLXYzIn0.eyJpc3MiOiJodHRwczovL2FwaS4xY2xhdy5jbyIsInN1YiI6ImFnZW50XzAxSDhYSyIsImF1ZCI6Imh0dHBzOi8vYXBpLjFjbGF3LmNvIiwiZXhwIjoxNzY3MjI1NjAwLCJzY29wZSI6InNlY3JldHM6cmVhZCJ9.c2lnbmF0dXJlLWJ5dGVz EOF - 2
Decode the payload. Note what you did not need: a key, a network call, or permission.
bashcut -d. -f2 /tmp/jwt.txt | python3 -c " import sys, base64, json s = sys.stdin.read().strip() print(json.dumps(json.loads(base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))), indent=2)) " - 3
You should see the claims in the clear.
json{ "iss": "https://api.1claw.co", "sub": "agent_01H8XK", "aud": "https://api.1claw.co", "exp": 1767225600, "scope": "secrets:read" } - 4
Decode the header too. It names the algorithm and the key id the verifier should use from the JWKS.
bashcut -d. -f1 /tmp/jwt.txt | python3 -c " import sys, base64, json s = sys.stdin.read().strip() print(json.loads(base64.urlsafe_b64decode(s + '=' * (-len(s) % 4)))) " - 5
Now the part that matters. Build two services that both trust the same issuer, and have one of them verify only the signature.
bashcat > /tmp/aud.py <<'EOF' import hmac, hashlib, base64, json KEY = b"issuer-signing-key" # stands in for the issuer's key b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode() unb64 = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def mint(payload): h = b64(json.dumps({"alg":"HS256","typ":"JWT"}).encode()) p = b64(json.dumps(payload).encode()) sig = b64(hmac.new(KEY, f"{h}.{p}".encode(), hashlib.sha256).digest()) return f"{h}.{p}.{sig}" def signature_valid(tok): h, p, s = tok.split(".") return hmac.compare_digest( s, b64(hmac.new(KEY, f"{h}.{p}".encode(), hashlib.sha256).digest())) claims = lambda tok: json.loads(unb64(tok.split(".")[1])) # A token the billing service legitimately holds. tok = mint({"iss": "https://api.1claw.co", "sub": "agent_01H8XK", "aud": "https://billing.example.com", "scope": "invoices:read"}) print("minted for:", claims(tok)["aud"]) print("\n=== deploy service, checking only the signature ===") print(" ->", "ACCEPTED" if signature_valid(tok) else "REJECTED") print("\n=== the same check, with the audience ===") def verify(tok, me): if not signature_valid(tok): return "REJECTED (bad signature)" if claims(tok).get("aud") != me: return f"REJECTED (aud is {claims(tok)['aud']})" return "ACCEPTED" print(" deploy ->", verify(tok, "https://deploy.example.com")) print(" billing ->", verify(tok, "https://billing.example.com")) EOF python3 /tmp/aud.py - 6
A cryptographically valid token, issued to somebody else, accepted by a service it was never meant for.
textminted for: https://billing.example.com === deploy service, checking only the signature === -> ACCEPTED === the same check, with the audience === deploy -> REJECTED (aud is https://billing.example.com) billing -> ACCEPTED - 7
Clean up.
bashrm /tmp/jwt.txt /tmp/aud.py
The signature proved the issuer and nothing else. Every customer of that issuer can mint a validly signed token, so a service that stops at signature verification will accept tokens minted for anybody. This is the confused deputy in its smallest form, and it is one missing line of code.
- iss tells you who minted it. Check it, or you will accept tokens from any issuer whose key you happen to have.
- aud tells you who it is for. Check it, or you accept tokens meant for someone else.
- exp tells you when it dies. Check it, with a little clock skew tolerance, because short TTLs make tight expiry checks brittle.
Most JWT libraries verify the signature by default and leave the claim checks to you. A call that returns the decoded payload without throwing is not a call that has authorised anything.
This is exactly why 1Claw's federation tokens carry an audience and why an agent's federation_audiences allowlist is empty, meaning deny, until you fill it. The issuer constrains what may be requested; your service still has to check what it received.
You also could not alter the token. Editing any claim invalidates the signature, which is the split at the centre of the previous lesson: signing gives integrity and authenticity, never confidentiality.
Check your understanding
3 questionsWhat did decoding the payload require?
The deploy service verified the signature and accepted a token minted for billing. What was missing?
Why does `base64 -d` often fail on a JWT segment?