warming up your workspace

Understand JWTs by forging one

If you have ever logged into an API, you have probably carried a JSON Web Token, a JWT, around in your requests. It is a little signed packet that says "I am this user, with this role, until this time", and the server trusts it because it is signed. JWTs are everywhere, and so are the ways to abuse them. The most notorious, the alg: none forgery, let attackers mint tokens claiming to be anyone, with no secret key, by exploiting a verifier that trusted the attacker to say how the token should be checked. Build a JWT from scratch, then forge one, and the whole thing, format and flaw, becomes concrete.

The one idea

A JWT is three base64 pieces joined by dots: a header saying which algorithm signs it, a payload of claims (who you are, your role, an expiry), and a signature over the first two. The signature is the only thing that matters. It is an HMAC: a hash of the header-plus-payload mixed with a secret key only the server knows. Change one character of the payload and the signature no longer matches, so the server rejects it. That is the security model, and it is sound. The vulnerabilities almost never come from breaking the cryptography. They come from a verifier that checks the signature wrong.

Build and sign one

import hmac, hashlib, base64, json
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=")
SECRET = b"server-signing-key"

def sign(payload, secret=SECRET):
    header = b64(json.dumps({"alg":"HS256","typ":"JWT"}).encode())
    body   = b64(json.dumps(payload).encode())
    msg = header + b"." + body
    sig = b64(hmac.new(secret, msg, hashlib.sha256).digest())   # HMAC over header.body
    return (msg + b"." + sig).decode()

tok = sign({"user":"alice","role":"user"})

That produces the familiar xxxxx.yyyyy.zzzzz. The first two parts are readable, base64 is not encryption, anyone can decode the payload and see your role. The signature is what stops them from changing it.

Attack 1: tamper the payload

Try to promote yourself to admin by editing the payload and keeping the old signature:

forged_body = b64(json.dumps({"user":"alice","role":"admin"}).encode()).decode()
tampered = f"{header}.{forged_body}.{original_signature}"
verify(tampered)      # -> None, rejected
tampered payload, safe verifier: None

Rejected, correctly. The signature was computed over the original body. Change the body and it no longer matches, and you cannot compute a new matching signature without the secret. The HMAC did its job. So far JWTs look bulletproof.

Attack 2: delete the signature entirely

Here is the famous flaw. The JWT spec allows alg: none, a token with no signature, intended for cases where security is handled elsewhere. A naive verifier reads the algorithm from the header, sees none, and concludes "no signature to check, so it must be valid". But the header is attacker-controlled. So the attacker forges a token that declares its own algorithm as none:

header_none = b64(json.dumps({"alg":"none","typ":"JWT"}).encode()).decode()
body_admin  = b64(json.dumps({"user":"attacker","role":"admin"}).encode()).decode()
none_token  = f"{header_none}.{body_admin}."      # empty signature

Against a verifier that trusts the header's alg:

alg:none vs NAIVE verifier: {'user': 'attacker', 'role': 'admin'}  <- forged admin!
alg:none vs SAFE  verifier: None

The attacker just became an admin with no key, no cracking, no secret. They only had to tell the server not to check the signature, and a verifier that took instructions from the token obeyed. This is a real bug class that hit multiple JWT libraries.

The fix is one line

Never let the token choose how it is verified. The server decides the algorithm in advance and rejects anything else.

def verify_safe(token, secret=SECRET):
    h, b, s = token.encode().split(b".")
    if json.loads(b64d(h))["alg"] != "HS256":     # pin the algorithm WE chose
        return None
    expected = b64(hmac.new(secret, h+b"."+b, hashlib.sha256).digest())
    return json.loads(b64d(b)) if hmac.compare_digest(expected, s) else None

Three details that matter:

  • Pin the algorithm on the server. The token's header may say HS256, but you verify against your own fixed choice, never the header's. This closes both alg: none and the related trick of swapping a signing algorithm for one the server misuses.
  • Use hmac.compare_digest, not ==. A plain comparison returns as soon as two bytes differ, and that timing leak can let an attacker recover a valid signature one byte at a time. Constant-time comparison is mandatory for anything secret.
  • Anything in the payload is public. Never put a password or sensitive data in a JWT, only signed claims. If you need secrecy, encrypt; signing alone just proves the claims were not altered.

Where this shows up

JWTs authenticate a huge fraction of the web's APIs and single-sign-on flows, and every one of these lessons is a real advisory: the alg: none bypass, the RS256-to-HS256 key-confusion attack, tokens that never expire, secrets weak enough to brute-force offline. They all share the shape you just built: the cryptography is fine, the verification is where it breaks. Reading a JWT library's verify function with these attacks in mind is how you audit it.

If you want to build the signing, the attacks, and the hardened verifier, then move on to session security and OAuth, that is the path the cybersecurity track on IWTLP takes, credentials you break before you trust.