Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Web Security (OWASP Top 10:2025)  Module 8 – A07:2025 Authentication Failures

JWT validation and token storage

Validate every JWT the way RFC 8725 asks, close the algorithm, key and claim bugs with tests, and choose where a browser app keeps its tokens.

  • Advanced
  • 25 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free intro, Pro: 4 of 25 minutes free
Free: A token can be read by anyone, so a service must check it before it believes it. With a pass: the other 9 sections and 3 interview answers, about 21 minutes.

What you will learn

  • Validate a JWT by checking its algorithm, key, signature, type, issuer, audience and time window, in that order
  • Recognise alg none, algorithm confusion, key ID and header-supplied key bugs in code review, and name the fixture that fails on each
  • Design key rotation, token lifetimes and revocation with numbers for the window that each leaves open
  • Choose where a browser app keeps its tokens, given what injected script can read and what a browser sends on its own
  • Decide when a JWT suits a session and when an opaque session ID does

Before you start

What you’ll learn · Before you start

What you will learn

  • Validate a JWT by checking its algorithm, key, signature, type, issuer, audience and time window, in that order
  • Recognise alg none, algorithm confusion, key ID and header-supplied key bugs in code review, and name the fixture that fails on each
  • Design key rotation, token lifetimes and revocation with numbers for the window that each leaves open
  • Choose where a browser app keeps its tokens, given what injected script can read and what a browser sends on its own
  • Decide when a JWT suits a session and when an opaque session ID does

Before you start

On this page

A service receives a token that claims "role": "admin". Whether to believe it is the whole question of JWT validation. A JWT is three base64url strings that anyone can read and anyone can write, so in this lesson’s first program a service that only decodes believes all four tokens it is shown, two of them forged, and a service that verifies accepts one.

In short: verify the signature with a key you chose and an algorithm you allow, then check the issuer, the audience, the time window and the type. Where the token lives afterwards is a second question, token storage, because each place a browser app can keep a token has different readers. This lesson covers both, and the bugs that skip the checks.

A token can be read by anyone, so a service must check it before it believes it

A JWT: JSON Web Token (RFC 7519): three base64url parts joined by dots, a header, a payload of claims and a signature. The payload is encoded and not encrypted, so anyone who holds a token can read it. carries a header, a payload of claims and a signature. The payload is encoded and not encrypted, so anyone who holds a token can read its claims, and anyone can write a token with claims of their choice. JWT validation is what tells the two apart: it proves that the issuer signed these exact claims, and that this service is the one meant to read them. Step through the order in which a service makes its checks:

Interactive · stage by stage What a service checks before it believes a token Step through one token and see what each check decides, and why the signature comes before the claims.
What a service checks before it believes a token, step 6 of 6. Showing Split and read, Allowed algorithm?, Pick the key, Verify the signature, Check what it says.Split and readthree partsAllowed algorithm?from your own listPick the keyby nameVerify the signaturewith that key and algorithmCheck what it saystype

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Split and read. A JWT is three base64url parts, a header, a payload and a signature. Splitting and decoding reads them and proves nothing, because anyone can write any header and any payload.
  2. Is the algorithm allowed?. The header names an algorithm. The name is a question for the service, not an instruction. If it is not on the service's own list, the check ends there, whatever the rest of the token looks like.
  3. Which key?. The header may name a key. The name is looked up in the service's own list of keys. A name that no key has ends the check, and a key that arrives inside the token is never used.
  4. Verify the signature. Only now is the signature computed with the chosen key and algorithm and compared with the token's. A signature that does not match ends the check, and nothing in the payload has been believed.
  5. Believe the claims. With a valid signature the claims are statements by the issuer. Each one is compared with what this service expects, namely the type of the token, the issuer, the audience and the time window.
  6. Accept or refuse. A token that passes every check is accepted and its claims are used. A token that fails any one of them is refused with a 401, and the reason goes to the log and not to the caller.

JWT validation has seven parts, and a service that skips any one of them has a bug that a test can show:

  • Signature. The service recomputes it with the key it trusts and refuses a token whose signature does not match.
  • Algorithm. The token’s alg must be on the service’s own list, and none is never on it.
  • Key. The key comes from the service’s own list, found by the token’s kid, and a key inside the token is never used.
  • Issuer. The iss claim must name the issuer that the service trusts.
  • Audience. The aud claim must name this service, so a token made for another API is refused.
  • Time. The token must be inside its nbf and exp window, allowing a small leeway for clocks.
  • Type. The typ header must say what kind of token it is, so an ID token cannot pass as an access token.

The first program makes four tokens, one genuine and three that are not, and prints what a decoder and a verifier say about each:

Four tokens, read by a decoder and checked by a verifier Python · start/decode_vs_verify.py
"""Decoding a JWT only reads it. Verifying it with the key is what makes its claims worth believing."""

import base64
import hashlib
import hmac
import json

KEY = b"lab key of the orders service: never use a key like this"
OTHER_KEY = b"a key that the sender made up"
NOW = 1_000_000  # seconds on the lab's own clock


def b64u(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def unb64u(text):
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


def encode(obj):
    return b64u(json.dumps(obj, separators=(",", ":"), sort_keys=True).encode())


def make(payload, key):
    signing_input = f"{encode({'alg': 'HS256', 'typ': 'JWT'})}.{encode(payload)}"
    return f"{signing_input}.{b64u(hmac.new(key, signing_input.encode(), hashlib.sha256).digest())}"


def decode_only(token):
    """Reads the payload. Anyone can do this: it is base64url text, not encrypted."""
    return json.loads(unb64u(token.split(".")[1]))


def verify(token, key, now):
    """Checks the signature with the key first, and only then looks at the claims."""
    head, body, signature = token.split(".")
    expected = hmac.new(key, f"{head}.{body}".encode(), hashlib.sha256).digest()
    if not hmac.compare_digest(expected, unb64u(signature)):
        return None, "bad signature"
    claims = json.loads(unb64u(body))
    if now > claims["exp"]:
        return None, "expired"
    return claims, None


genuine = make({"sub": "asha", "role": "member", "exp": NOW + 900}, KEY)
head, _, signature = genuine.split(".")
edited = f"{head}.{encode({'sub': 'asha', 'role': 'admin', 'exp': NOW + 900})}.{signature}"
expired = make({"sub": "asha", "role": "member", "exp": NOW - 60}, KEY)
other_key = make({"sub": "asha", "role": "admin", "exp": NOW + 900}, OTHER_KEY)

print("what anyone can read in the genuine token:", decode_only(genuine))
print()
print(f"{'token':<30}{'decode only says':<20}verify says")
for name, token in [("genuine", genuine), ("payload edited, old signature", edited), ("expired", expired), ("signed with another key", other_key)]:
    claims = decode_only(token)
    said = f"{claims['sub']}, {claims['role']}"
    checked, reason = verify(token, KEY, NOW)
    answer = f"accepted: {checked['sub']}, {checked['role']}" if checked else f"refused: {reason}"
    print(f"{name:<30}{said:<20}{answer}")

Output

what anyone can read in the genuine token: {'exp': 1000900, 'role': 'member', 'sub': 'asha'}

token                         decode only says    verify says
genuine                       asha, member        accepted: asha, member
payload edited, old signature asha, admin         refused: bad signature
expired                       asha, member        refused: expired
signed with another key       asha, admin         refused: bad signature

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 decode_vs_verify.py

The decoder reported admin for the two forged tokens, because it believes what it reads. The verifier accepted only the genuine one: the edited payload and the token signed with another key failed the signature, and the expired one failed its exp.

Token storage is the other half. localStorage and sessionStorage are open to every script of the page. A variable in memory is lost at every reload. An HttpOnly cookie is hidden from scripts, and the browser sends it with requests on its own. A backend for frontend (BFF): A server component between a browser app and the APIs it calls. It runs the sign-in flow and keeps the app's tokens itself, so that the browser holds only a session cookie and no token. More in “OAuth 2.0 and OpenID Connect: done right” keeps the token on the server, so there is nothing in the browser to read. Each place has its own readers.

Checkpoint

Check: Decoding reads, verifying believes · Where a browser keeps a token

  1. Question 1 of 2 In the first program the decoder reported "asha, admin" for the token with an edited payload. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It reads the payload and believes it, without asking whether the signature matches

    The verifier accepted only the genuine token. The edited payload and the token signed with another key failed the signature check, and the expired one failed the time check.

  2. Question 2 of 2 Which scripts of a page can read a token kept in localStorage?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Every script that runs in the page, including injected ones

    Which place a token lives in is a question of who can read it. An injected script is a script of the page.

Flashcards · 4 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why must a service verify a JWT and not only decode it?

    A JWT is three base64url strings; the payload is encoded, not encrypted, so anyone can read the claims, and anyone can write a token with claims of their choice. In the lesson a decoder believed all four tokens, two of them forged, while a verifier accepted only the genuine one.

  2. Which seven parts of JWT validation can a test show are skipped?

    Signature (recomputed with a key you trust); algorithm (the token's alg must be on your own list, never none); key (found by kid in your own list, never a key inside the token); issuer (iss); audience (aud names this service); time (nbf and exp, with a small leeway for clocks); and type (typ, so an ID token cannot pass as an access token).

  3. Who can read a token kept in localStorage, in memory, in an HttpOnly cookie or in a backend for frontend?

    localStorage and sessionStorage: every script of the page. A variable in memory: lost at every reload. An HttpOnly cookie: hidden from scripts, and the browser sends it with requests on its own. A backend for frontend keeps the token on the server, so there is nothing in the browser to read. Each place has its own readers.

  4. Which kinds of bad token did the lesson's verifier refuse?

    An edited payload and a token signed with another key failed the signature check, and an expired token failed its exp; only the genuine token passed.

Seven checks against fifteen tokens: a first draft and a fixed verifier Pro: opens with a Pro, Premium or Ultimate pass

In this part:

  • Four bugs to know on sight in code review
  • Keys rotate in three steps: publish, switch after the caches, remove last
  • Lifetimes decide how long a revoked token keeps working, and shortening them has a price
  • Where a browser keeps a token: five places, and what a script in the page can do
  • When a JWT is the wrong token for a session
  • Practise: a verifier that checks before it believes
    • A JWT verifier that checks before it believes
  • Interview questions
    • What is the difference between decoding and verifying a JWT?
    • A pull request verifies tokens with a library call that gets only the token and a key. Another route decodes a token without verifying it, to read the role. What do you ask for in each, and which tests do you require?
    • After a signing-key rotation, about 3 % of requests to your API return 401 for roughly ten minutes and then recover. Explain what happened, and write the rotation plan that loses nothing.
    • A single-page app keeps its access and refresh tokens in localStorage, and a review flags it. What do you change, and what does your change not fix?
  • Key takeaways

1 diagram · 3 runnable examples (JavaScript, Python) · 1 exercise with sample tests · 3 interview answers · about 43 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Terms in this lesson

algorithm confusion
A bug in which a verifier takes the algorithm from the token and uses one key for every algorithm, so that a public key can serve as an HMAC secret. A key that is for one algorithm, and a list of allowed algorithms, prevent it.
JWKS
JSON Web Key Set: a list of public keys, each with a key ID, that an issuer publishes at a URL so that services can check its signatures. Services keep a copy for a while, and a rotation adds and removes keys in it.
JWT
JSON Web Token (RFC 7519): three base64url parts joined by dots, a header, a payload of claims and a signature. The payload is encoded and not encrypted, so anyone who holds a token can read it.
token introspection
A call in which a service asks the issuer whether a token is still active and what it allows (RFC 7662). It makes revocation immediate at the cost of a call, which services often cache for a short time.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.