Sessions and Tokens — From the Browser to the Mesh
JWT signature verification, alg confusion and kid injection defense
Goal
Stand up your own JWT verifier at 127.0.0.1:8305 with an RS256 key pair and an HS256 secret made with cryptography, make and throw alg:none, alg confusion, and kid injection forged tokens yourself, and confirm that the defenses block them with status codes. You look at both manual verification and PyJWT verification.
Why it matters
A JWT carries its own evidence, so you only have to verify the signature without asking the server. At the price of that convenience, if you write the verification even a little loosely, the verifier takes the token header's alg and kid at face value and a forgery gets through. alg:none allows an unsigned token, alg confusion misuses an RS256 public key as an HS256 secret to forge a signature from the published key alone, and kid injection is the attacker specifying the very key to be used for verification. The defenses of RFC 8725 (the JWT BCP) sum up into three — keep an allowlist of permitted algorithms, pin the algorithm to the key type rather than to the header, and do not interpret kid as a file path or a URL. On top of that you add exp/nbf/iss/aud validation. This lab makes you prove for yourself that those rules hold against real forged requests.
Steps
- Bring up
/root/st/jwt/app.pyat 127.0.0.1:8305 and leave in/root/st/jwt/self.outthat your own issued RS256 token is 200 and also passes PyJWT. - Make and throw an alg:none token and write into
/root/st/jwt/none.outwhether it is a 401. - Write into
/root/st/jwt/confusion.outwhether the confusion token that misuses the rs-1 public key as an HS256 secret is a 401 and a legitimate hs-1 HS256 token is a 200. - Write into
/root/st/jwt/kid.outwhether an rs-1 token is 200, swapping only the header kid is 401, and a nonexistent kid is also 401. - Write into
/root/st/jwt/rotation.outwhether the JWKS has both rs-1 and rs-2 and tokens of both kids are 200. - Write into
/root/st/jwt/inject.outwhether an injection token that puts a file path in kid is a 401. - Write into
/root/st/jwt/claims.outwhether tokens with exp, nbf, iss, and aud each off are 401 and only the normal one is 200. - Throw valid, none, confusion, and badkid all at once with
/root/st/jwt/e2e.shand leave four lines in/root/st/jwt/e2e.out.
Notes
- Do the manual verification with cryptography — RS256 is padding.PKCS1v15() + SHA256, and HS256 is hmac + hashlib.sha256. With PyJWT you must pass algorithms=["RS256"] to jwt.decode.
- Do not put the symmetric secret (hs-1) in the JWKS — if you publish it, it itself becomes a means of forging signatures.
- Common mistake 1: verifying by trusting the header's alg — none and confusion get through as they are. Pin alg to the type of the key that kid points to.
- Common mistake 2: interpreting kid with open(kid) or fetch(kid) — it lets the attacker specify the key. Use kid only as a lookup key into a fixed list.
Bring up the verifier, verify your own issued RS256 token
Bring up /root/st/jwt/app.py at 127.0.0.1:8305. When you throw your own issued RS256 token received from POST /sign at POST /verify, it is 200, and /root/st/jwt/verify_both.py leaves in /root/st/jwt/self.out, as verify_code=200 and pyjwt=valid ..., that it also passes PyJWT.
The server makes the keys with cryptography when it starts and keeps them as files. Start the server in the background and poll until it is ready. With PyJWT you must pass algorithms=["RS256"] to jwt.decode.
Reject an alg:none token
Make a token with alg set to none and an empty signature and throw it at POST /verify, and write into /root/st/jwt/none.out, as none_code=401, whether it is rejected with a 401.
It is the Unsecured JWS of RFC 7519 §6. The defense ends with a single allowlist of permitted algs — if none is not in the list, it is caught before the signature is even looked at.
Reject alg confusion (an RS public key as an HS secret)
Write into /root/st/jwt/confusion.out, as confusion_code=401 and legit_hs256_code=200, whether a token signed using the rs-1 public key (the SPKI PEM restored from the JWKS n,e) as an HS256 secret is rejected with a 401 and a legitimate hs-1 HS256 token passes with a 200.
What blocks confusion is not forbidding HS256 but pinning alg to the key type. rs-1 is an RSA key and so is RS256 only, so if the header demands HS256, it is rejected before the signature is looked at.
Pick the key by kid, reject an unknown kid
Write into /root/st/jwt/kid.out, as good_kid_code=200, wrong_kid_code=401, and unknown_kid_code=401, whether a token signed with rs-1 is 200, swapping only that token's header kid to rs-2 (leaving the signature as is) is 401, and a nonexistent kid (rs-9) is also 401.
kid is a handle for choosing which key to verify with. If you change only the header kid, the actual signature belongs to the rs-1 key, so verification with rs-2 fails.
JWKS key rollover
Write into /root/st/jwt/rotation.out, as jwks_kids=rs-1,rs-2, new_kid_code=200, and old_kid_code=200, whether the JWKS has both rs-1 and rs-2 (jwks_kids=rs-1,rs-2) and both a token signed with the new kid (rs-2) and a token of the old kid (rs-1) pass with 200.
During a rollover, tokens signed with the old key are still in circulation. You keep both keys in the JWKS and pick with kid to verify — if you delete the old key too early, valid tokens get rejected all at once.
Reject kid path and URL injection
Write into /root/st/jwt/inject.out, as badkid_code=401, whether a token signed with a key planted at the path, with the kid set to a file path (/root/st/jwt/evil_pub.pem), is rejected with a 401 when thrown.
kid can be any string (RFC 7515 §4.1.4). If the verifier opens a file or pulls a URL with kid, the attacker specifies the key to be used for verification. Use kid only as a lookup key into the list of keys I know.
exp/nbf/iss/aud validation
Write into /root/st/jwt/claims.out, as valid_code=200, expired_code=401, notyet_code=401, badiss_code=401, and badaud_code=401, whether tokens whose signatures are all fine but whose claims alone are off are each 401 and only the normal token is 200.
A correct signature is not the end. Reject it if exp has passed, nbf is in the future, iss is not an issuer I know, or aud is not my name. Keep the leeway for clock skew to within a few minutes.
Combined: valid, none, confusion, badkid
Throw the four at once with /root/st/jwt/e2e.sh and leave valid=200, none=401, confusion=401, and badkid=401 in /root/st/jwt/e2e.out.
Chain the earlier steps together in one script. Only the normal token must pass, and all three forgeries must be 401.