Skip to content
Path to Engineer
All lessons
PythonBackend18 min read

JWT from scratch — signing, verifying, and the alg=none attack

Build and verify a JSON Web Token by hand, see the alg=none attack work, and understand why you cannot really log a JWT user out.

Day 116 of the 488-day pathway. Published in full — nothing held back.

JWT from scratch — sign and verify by hand, the alg=none attack, and why you cannot log anyone out

The sentence to own: a JWT is signed, not encrypted, and it is valid until it expires — so anyone can read it, nobody can forge it, and you cannot revoke it, which is the property that decides whether it is the right tool.

You will implement one in twenty lines. Then you will implement the attack that broke a generation of JWT libraries, and the reason it worked will be obvious.


Part 1 — The structure

   THREE BASE64URL SEGMENTS, JOINED BY DOTS:

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsImV4cCI6MTc1NX0 . 3Zm8...
    └──────────── HEADER ──────────────┘   └────────── PAYLOAD ─────────┘   └ SIGNATURE ┘

    header  {"alg": "HS256", "typ": "JWT"}
    payload {"sub": "42", "exp": 1755100000, "role": "admin"}        the CLAIMS
    sig     HMAC-SHA256(base64url(header) + "." + base64url(payload), SECRET)
   BASE64 IS NOT ENCRYPTION. IT IS AN ENCODING. Paste any JWT into jwt.io — or
    just base64-decode the middle segment — and read every claim.
    ⇒    THEREFORE: NEVER PUT ANYTHING SECRET IN A JWT. Not an email if you
         care, not an internal id you consider sensitive,   certainly not a
         password hash or a permissions matrix you would rather not publish.
    ⇒    WHAT THE SIGNATURE BUYS YOU IS INTEGRITY, NOT CONFIDENTIALITY: the
         holder cannot CHANGE `"role": "user"` to `"role": "admin"` without
         invalidating it.   They can always READ it.
    ⇒   base64URL, not standard base64: `-` and `_` instead of `+` and `/`, and
            NO `=` PADDING — a wrong padding implementation is the most common
         bug when hand-rolling this.

Part 2 — Sign and verify, by hand

import base64, hmac, hashlib, json, time, secrets

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).decode().rstrip("=")        #    strip padding

def b64url_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))          #   add it back

def encode(payload: dict, secret: bytes) -> str:
    header = {"alg": "HS256", "typ": "JWT"}
    h = b64url(json.dumps(header, separators=(",", ":")).encode())
    p = b64url(json.dumps(payload, separators=(",", ":")).encode())
    signing_input = f"{h}.{p}".encode()
    sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
    return f"{h}.{p}.{b64url(sig)}"
def decode(token: str, secret: bytes, *, audience: str, issuer: str) -> dict:
    try:
        h, p, s = token.split(".")
    except ValueError:
        raise InvalidToken("malformed")

    #    1. VERIFY THE SIGNATURE WITH *OUR* ALGORITHM — never the header's (Part 3)
    expected = hmac.new(secret, f"{h}.{p}".encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(b64url_decode(s), expected):           #   Day 114
        raise InvalidToken("bad signature")

    #    2. ONLY NOW parse the payload — signature first, always
    claims = json.loads(b64url_decode(p))

    now = time.time()
    if claims.get("exp", 0) <= now:            raise InvalidToken("expired")
    if claims.get("nbf", 0) > now + 60:        raise InvalidToken("not yet valid")  #   clock skew
    if claims.get("aud") != audience:          raise InvalidToken("wrong audience")
    if claims.get("iss") != issuer:            raise InvalidToken("wrong issuer")
    return claims
   FOUR RULES IN THAT FUNCTION, EACH OF WHICH IS A REAL CVE IN SOMEBODY'S CODE:
   1.    VERIFY BEFORE YOU PARSE. Decoding the payload first means you are
        acting on attacker-controlled JSON before you know it is authentic —
          and libraries that expose `decode(verify=False)` for "debugging" get
        used in production every year.
   2.    CHECK `exp`. A token with no expiry check is a permanent credential.
   3.    CHECK `aud` AND `iss`.    Without `aud`, a token your auth server
        issued for SERVICE A is accepted by SERVICE B —   so a low-privilege
        service can hand its token to a high-privilege one. This is a real and
        common cross-service escalation.
   4.   `compare_digest`, because a signature comparison is a secret comparison.
   ⇒   allow ~60s of CLOCK SKEW on `nbf`/`exp`, or a slightly fast client
        clock rejects perfectly good tokens.
Claim Meaning
sub the subject — the user id
exp expiry (unix seconds) — the only revocation you have
iat / nbf issued at / not before
aud who this token is for — check it
iss who issued it — check it
jti a unique id — needed for a denylist (Part 4)

Part 3 — The alg=none attack

⚠️⚠️    THE VULNERABILITY THAT BROKE MOST JWT LIBRARIES IN 2015, AND IT IS A
     DESIGN FLAW IN THE SPEC RATHER THAN A CODING SLIP:

     THE VERIFIER READS THE ALGORITHM FROM THE HEADER —    AND THE HEADER IS
     SUPPLIED BY THE ATTACKER.

     1. attacker takes a valid token, edits the payload to `"role": "admin"`
     2.    sets the header to `{"alg": "none"}`
     3. sends it with an EMPTY signature
     4.    a naive library reads alg=none, concludes "no signature required",
        AND ACCEPTS IT.
     ⇒    TOTAL AUTHENTICATION BYPASS, from a text editor.
⚠️    AND ITS SUBTLER SIBLING — THE HS/RS CONFUSION ATTACK:
     your server verifies RS256 with a PUBLIC key, which is public.
     ⇒    the attacker changes the header to `HS256` and signs the token with
          YOUR PUBLIC KEY AS THE HMAC SECRET.
     ⇒    a library that dispatches on the header calls HMAC-verify with the
          key it was given — the public key — AND THE SIGNATURE MATCHES.
     ⇒   the attacker forged a token using only information you PUBLISHED.

     THE FIX FOR BOTH IS ONE PRINCIPLE: **THE VERIFIER DECIDES THE ALGORITHM.**
         jwt.decode(token, key, algorithms=["RS256"])        ALWAYS pass this
    ⇒   note the shape of the lesson, because it generalises far beyond JWT:
            NEVER LET UNTRUSTED INPUT SELECT THE CODE PATH THAT VALIDATES IT.
           It is the same class of mistake as `pickle` choosing a class to
         instantiate (Day 128) and as a parser resolving an ambiguity in the
         attacker's favour (Day 104).

Modern libraries require algorithms= and reject none outright. Pass it anyway, explicitly, every time — and if you ever see verify=False or a missing algorithms= in a review, that is a blocking comment (Day 100B).


Part 4 — The revocation problem

   AN EMPLOYEE IS DISMISSED AT 10:00. THEIR ACCESS TOKEN EXPIRES AT 10:45.
    ⇒    THEY HAVE FORTY-FIVE MINUTES OF FULL ACCESS, AND THERE IS NOTHING YOU
         CAN DO ABOUT IT — because verification is a pure function of the token
         and the key.   Your server never asks anybody anything.
    ⇒    THAT IS NOT A BUG. IT IS THE DEFINING PROPERTY: the whole point of a
         JWT is that it can be verified WITHOUT A LOOKUP.   Revocation requires
         a lookup. You cannot have both.

     THE THREE HONEST OPTIONS, AND EVERY REAL SYSTEM PICKS ONE:
   1.   SHORT EXPIRY (5-15 min) + a refresh token ⇒    shrinks the window;
        does not close it.   This is what most systems do (Day 117).
   2.    A DENYLIST of `jti`s until their `exp` ⇒    IT WORKS, AND IT MEANS A
        LOOKUP ON EVERY REQUEST —   at which point you have re-invented
        sessions with extra steps and a bigger cookie (Day 115).
   3.   A per-user `token_version` in the token, compared against the database
        ⇒   same trade, one row instead of a set.

  ⇒    SAY THIS OUT LOUD IN AN INTERVIEW, BECAUSE IT IS THE WHOLE POINT:
      "STATELESS AUTH" IS STATEFUL AT THE REFRESH TOKEN, AND ANY SCHEME THAT
      CAN REVOKE HAS STATE SOMEWHERE.   The design question is not "stateless
      or not" but "how long am I willing for a revoked credential to keep
      working."

Part 5 — Where to keep it, and when to use it

Storage Exposure
localStorage any XSS reads it and exfiltrates it permanently — no HttpOnly possible
⚠ In-memory JS variable safer, but lost on refresh ⇒ needs a refresh flow anyway
HttpOnly cookie XSS cannot read it — ⚠ CSRF applies, so SameSite + a token (Day 115)
   THE ARGUMENT, AND IT IS THE OPPOSITE OF WHAT MOST TUTORIALS SAY:
    "JWT IN localStorage AVOIDS CSRF" IS TRUE AND IS THE WRONG TRADE.
    ⇒    YOU TRADED A WELL-UNDERSTOOD, EASILY-MITIGATED PROBLEM (CSRF —
         SameSite plus a token, both cheap and standard) FOR AN UNMITIGATABLE
         ONE (XSS reading your credential and sending it anywhere, forever).
    ⇒   under XSS an `HttpOnly` cookie still lets the attacker ACT as the user
         while the page is open.    With localStorage they TAKE the credential
         away.   That difference is the entire argument.
    ⇒    SO: HttpOnly + Secure + SameSite=Lax, plus CSRF protection.
   WHEN A JWT IS ACTUALLY THE RIGHT TOOL:
    ✔   SERVICE-TO-SERVICE — service A proves who it is to service B without B
        calling the auth server.    This is the case JWTs were designed for.
    ✔   short-lived ACCESS tokens in front of a stateful refresh token (Day 117)
    ✔   genuinely cross-domain or third-party (OIDC id tokens)
    ✔   a signed, expiring, single-purpose link — password reset, an invite,
        a download URL —    where "cannot be revoked" is fine because it is
        one-shot and short-lived
    ✘    A NORMAL WEB APP WITH ONE BACKEND ⇒   use a session (Day 115). You
        already have a datastore, and revocation is worth more than the lookup
        you saved.

Key rotation uses the kid header: the token names which key signed it, the verifier looks it up in a keyset. kid is attacker-controlled, so treat it as a lookup key into a fixed set — never as a filename or a database query fragment (Days 127, 128).


Common mistakes

Mistake Correction
"The JWT is encrypted" It is signed. Anyone can read the payload.
Putting anything secret in the payload It is public. Integrity, not confidentiality.
Trusting alg from the header alg=none and HS/RS confusion. The verifier decides.
Parsing the payload before verifying Acting on unauthenticated attacker JSON.
Not checking exp A permanent credential.
Not checking aud/iss Service A's token accepted by service B — cross-service escalation.
== on the signature compare_digest.
No clock-skew allowance A slightly fast client rejects valid tokens.
Base64 padding mishandled urlsafe, strip = when encoding, re-add when decoding.
Storing it in localStorage Traded CSRF (mitigable) for XSS exfiltration (not).
Expecting logout to work It cannot, without a lookup — and then you have a session.
Long-lived access tokens Every minute is a minute a fired employee still has access.
verify=False anywhere A blocking review comment.

Interview questions

Q: What is a JWT?

Three base64url segments — header, payload, signature — where the signature is an HMAC or an asymmetric signature over the first two. The critical property is that it's signed, not encrypted: anyone can read the claims by base64-decoding the middle segment, and the signature only guarantees nobody has changed them. So it gives you integrity, not confidentiality, and nothing secret should ever go in a payload.

Q: What is the alg=none attack?

The spec has the verifier read the algorithm from the header — and the header is attacker-supplied. So you take a valid token, edit the payload to make yourself an admin, set the header to {"alg":"none"}, send an empty signature, and a naive library concludes no signature is required and accepts it. Total auth bypass from a text editor. Its sibling is HS/RS confusion: if the server verifies RS256 with a public key, the attacker switches the header to HS256 and signs with that public key as the HMAC secret — forging a token from information you published. The fix for both is one principle: the verifier decides the algorithm and passes it explicitly. And the general form is worth stating — never let untrusted input select the code path that validates it.

Q: How do you log someone out of a JWT-based system?

You can't, and that's the defining property rather than an oversight. Verification is a pure function of the token and the key — the server never asks anyone anything — so a token stays valid until it expires. A dismissed employee keeps access for whatever the remaining lifetime is. The three real options are short expiry with a refresh token, which shrinks the window without closing it; a denylist of jtis until expiry, which works but means a lookup on every request; or a per-user token version compared against the database, which is the same trade. Once you add any of the last two, you've re-invented sessions with a bigger cookie. So "stateless auth" is stateful at the refresh token, and the real question isn't stateless or not — it's how long you'll tolerate a revoked credential still working.

Q: localStorage or a cookie?

HttpOnly cookie, with Secure, SameSite=Lax and CSRF protection. The usual argument for localStorage is that it avoids CSRF, which is true and is the wrong trade: you've swapped a well-understood, cheaply-mitigated problem for an unmitigatable one. Under XSS, an HttpOnly cookie lets the attacker act as the user while the page is open; with localStorage they take the credential away and use it anywhere, forever. That difference is the whole argument.

Q: When would you actually choose a JWT?

Service-to-service, where B can verify A's identity without calling the auth server — that's the case it was designed for. Short-lived access tokens in front of a stateful refresh token. Genuinely cross-domain or third-party flows like OIDC. And single-purpose signed links — a password reset or an invite — where "can't be revoked" is fine because it's one-shot and short-lived. For a normal web app with one backend I'd use a session, because I already have a datastore and revocation is worth more than the lookup I saved.


Mini task

  1. Implement encode and decode by hand. Verify against PyJWT on the same input.
  2. Base64-decode the payload of any JWT you have. Read the claims. Note that you needed no key.
  3. Get the padding wrong on purpose and observe the failure. Fix it with the -len(s) % 4 trick.
  4. Implement the alg=none attack against your own verifier: edit the payload, set alg: none, empty signature. Make it succeed, then fix the verifier.
  5. Implement HS/RS confusion: verify with RS256, then re-sign the token using the public key as an HMAC secret. Make it verify. Then pin the algorithm.
  6. Remove the exp check and confirm a year-old token still works.
  7. Issue a token with aud: "service-a" and accept it in a verifier expecting service-b. Then add the check.
  8. Implement a jti denylist in Redis with TTL = remaining lifetime. Measure the added latency per request and compare it with a session lookup (Day 115).
  9. Set the clock forward 30 seconds on a client and confirm a valid token is rejected. Add skew tolerance.
  10. Store a token in localStorage, then run document.cookie and localStorage.getItem from the console with an HttpOnly cookie alongside. Compare what a script can reach.
  11. Implement kid-based key rotation with two keys, and rotate without invalidating live tokens.

Exit questions

Answer aloud, no notes.

  1. What are the three segments, and what does the signature guarantee?
  2. Why must nothing secret go in a JWT?
  3. Give the four verification rules, and the failure each prevents.
  4. What does aud prevent, concretely?
  5. Describe alg=none and HS/RS confusion, and the one principle that fixes both.
  6. State the general form of that principle.
  7. Why can you not log someone out?
  8. Give the three revocation options and what each costs.
  9. Why is "stateless auth" a misleading phrase?
  10. localStorage vs HttpOnly cookie — state the trade in one sentence.
  11. Name four cases where a JWT is genuinely right.
  12. What must you never do with kid?

Articulation drill

Record two minutes: "Would you use JWTs for your app's authentication?"

Answer with a decision and its criterion: "for a normal web app with one backend, no — I'd use server-side sessions. For service-to-service, or short-lived access tokens in front of a refresh token, yes. The criterion is revocation."

Then the property, stated as a design consequence rather than a complaint: "a JWT is verified as a pure function of the token and the key — the server never asks anyone anything, which is exactly the point and exactly the cost. It's valid until it expires. So when someone is dismissed at ten o'clock and their token expires at quarter to eleven, they have forty-five minutes of access and there's nothing I can do. Short expiry shrinks that window; it doesn't close it. And if I add a denylist to close it, I'm doing a lookup on every request — at which point I've re-invented sessions with a bigger cookie."

Then the attack, because it shows you have looked at the spec rather than the tutorial: "the thing I'd always check in review is that the algorithm is pinned. The spec has the verifier read alg from the header, which is attacker-supplied — so alg: none with an empty signature was a total auth bypass in most libraries, and HS/RS confusion lets someone forge a token using the public key you published. Both are fixed by the verifier deciding the algorithm. The general form is worth carrying: never let untrusted input select the code path that validates it."

Close on storage and the trade: "and I'd keep it in an HttpOnly cookie rather than localStorage. The usual argument is that localStorage avoids CSRF, which is true and is the wrong trade — CSRF is cheap to mitigate with SameSite and a token, whereas with localStorage any XSS takes the credential away and uses it anywhere, forever."


Previous: Day 115 · Tomorrow: Day 117 — refresh-token rotation, and detecting a stolen token by its reuse