HackBlue

Tools · Cryptography and passwords in practice · 17 / 20

JSON Web Tokens explained: structure, signing algorithms, and validating them correctly

Decode a JWT’s header, payload, and signature, choose HS256, RS256, or EdDSA, and validate tokens with a library that pins algorithms and checks expiry, audience, and issuer.

11 min read · practice only with authorization

Three parts

A JWT is three base64url-encoded parts separated by dots: a header naming the algorithm (alg) and key ID (kid), a payload with claims such as sub, iss, aud, exp, and iat, and a signature over the first two parts. The payload is encoded, not encrypted: anyone holding the token can read it. Do not put secrets in it.

Algorithms

HS256 uses a shared secret, so every service that verifies can also mint tokens. RS256 and ES256 use a private key to sign and a public key to verify, which suits identity providers that publish keys through a JWKS endpoint. EdDSA (Ed25519) is a modern option where supported. The alg value none means no signature and must always be rejected.

Lab: decode and validate with PyJWT

Create a token with PyJWT, decode the parts by hand to see the plain JSON, and validate it properly by specifying allowed algorithms, audience, and issuer. Then try validation failures: an expired token, a wrong audience, and a token whose payload you edited without re-signing.

Snippet
import jwt, time, base64, json
secret = 'lab-only-secret-with-enough-length-32b'
token = jwt.encode({'sub': 'alice', 'aud': 'orders-api', 'iss': 'https://idp.lab', 'exp': int(time.time()) + 300}, secret, algorithm='HS256')
header, payload, sig = token.split('.')
pad = lambda s: s + '=' * (-len(s) % 4)
print(json.loads(base64.urlsafe_b64decode(pad(header))), json.loads(base64.urlsafe_b64decode(pad(payload))))

claims = jwt.decode(token, secret, algorithms=['HS256'], audience='orders-api', issuer='https://idp.lab')
print(claims['sub'])

for bad in [
    jwt.encode({'sub': 'alice', 'aud': 'orders-api', 'iss': 'https://idp.lab', 'exp': int(time.time()) - 10}, secret, algorithm='HS256'),
    jwt.encode({'sub': 'alice', 'aud': 'billing', 'iss': 'https://idp.lab', 'exp': int(time.time()) + 300}, secret, algorithm='HS256'),
    header + '.' + base64.urlsafe_b64encode(b'{"sub":"admin","aud":"orders-api","iss":"https://idp.lab"}').decode().rstrip('=') + '.' + sig,
]:
    try:
        jwt.decode(bad, secret, algorithms=['HS256'], audience='orders-api', issuer='https://idp.lab')
    except jwt.PyJWTError as e:
        print('rejected:', type(e).__name__)

Validation rules

Pin the expected algorithms instead of trusting the header. Verify signature, exp, nbf, aud, and iss on every request. Fetch public keys from the issuer’s JWKS over HTTPS and match by kid, caching sensibly. Keep tokens short-lived and use refresh tokens for longer sessions. Store tokens in browsers carefully; HttpOnly cookies limit exposure to script injection.

Diagram

A JWT, part by part

Readable by anyone; trustworthy only after validation.

headerpayloadsignature

Header

{"alg":"RS256","kid":"2026-11","typ":"JWT"}
Algorithm and key ID

Payload

{"sub":"alice","aud":"orders-api","iss":"https://idp.lab","exp":1793620000}
Claims, not encrypted

Signature

base64url(sign(header.payload))
Integrity over both parts

Check yourself

1. Is a JWT payload confidential?

2. How should a verifier choose the algorithm?

3. Which alg value must always be rejected?