Skip to content
1Claw Academy
Curriculum/Security Foundations3 minBeginner · Lesson 8 of 19

Lab: read a token, then replay it

Lab

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. 1

    Save a token. This one is a realistic 1Claw agent JWT with a fake signature, so there is nothing sensitive here.

    bash
    cat > /tmp/jwt.txt <<'EOF'
    eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCIsImtpZCI6ImVkZHNhLXYzIn0.eyJpc3MiOiJodHRwczovL2FwaS4xY2xhdy5jbyIsInN1YiI6ImFnZW50XzAxSDhYSyIsImF1ZCI6Imh0dHBzOi8vYXBpLjFjbGF3LmNvIiwiZXhwIjoxNzY3MjI1NjAwLCJzY29wZSI6InNlY3JldHM6cmVhZCJ9.c2lnbmF0dXJlLWJ5dGVz
    EOF
  2. 2

    Decode the payload. Note what you did not need: a key, a network call, or permission.

    bash
    cut -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. 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. 4

    Decode the header too. It names the algorithm and the key id the verifier should use from the JWKS.

    bash
    cut -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. 5

    Now the part that matters. Build two services that both trust the same issuer, and have one of them verify only the signature.

    bash
    cat > /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. 6

    A cryptographically valid token, issued to somebody else, accepted by a service it was never meant for.

    text
    minted 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. 7

    Clean up.

    bash
    rm /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.
Watch out

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.

Tip

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 questions
1

What did decoding the payload require?

2

The deploy service verified the signature and accepted a token minted for billing. What was missing?

3

Why does `base64 -d` often fail on a JWT segment?