JSON Web Tokens
39.0 What this chapter gives you#
- You will be able to take any JSON Web Token, split it correctly, decode both halves by hand without a tool, and say what every field in it means.
- You will be able to explain base64url encoding well enough to convert three bytes into four characters on paper, and to explain why the padding is stripped.
- You will be able to name each of the seven registered claims in RFC 7519, give its section number, and say which specific attack the absence of each one enables.
- You will be able to choose correctly between HS256, RS256, PS256, ES256 and EdDSA for a given deployment, and defend the choice on key distribution grounds rather than taste.
- You will be able to describe the alg:none flaw of 2015 precisely, construct a forged unsecured token, and say why the specification permitted it in the first place.
- You will be able to carry out an algorithm confusion attack on paper, using a published RSA public key as an HMAC secret, and say exactly which line of library code makes it possible.
- You will be able to spot a
kid,jku,x5uorjwkheader being used as an attack surface, and write the handling rule that closes each one. - You will be able to say when a token must be encrypted rather than merely signed, what JWE gives you that JWS does not, and why the answer is usually still not JWE.
- You will be able to write out a validation sequence in the correct order and name, for each step, whether a typical library performs it for you or silently leaves it to you.
A JSON Web Token, almost always written JWT and usually pronounced “jot”, is a small piece of text that carries a statement about somebody and a signature over that statement. It is the format that most modern login systems hand out. If you have signed in with a “Sign in with” button in the last decade, a JWT was almost certainly created on your behalf, passed between two or three companies, and thrown away fifteen minutes later. It is specified in RFC 7519, published in May 2015 by Michael Jones, John Bradley and Nat Sakimura, and it rests on four sibling specifications published on the same day: RFC 7515 for signing, RFC 7516 for encryption, RFC 7517 for key formats and RFC 7518 for the algorithms themselves.
The format succeeded because it solved a real problem cheaply. A statement about a user could be made once, by one service, and then checked by fifty other services without any of them calling back to ask. That property is called self-validation, and it is the whole commercial case for the JWT. The chapter before this one dealt with tokens in general, including the opaque kind where the receiving service must call the issuer to ask what the token means; that is chapter 38, and this chapter does not repeat it. What matters here is that a JWT is the other kind. It carries its own meaning and its own proof, and the receiving service reads both off the token itself.
That same property is why the JWT is the most misused identity format in software. When a token carries its own instructions for checking, an attacker who can rewrite the token can rewrite the instructions. Every serious JWT vulnerability of the last eleven years is a variation on that one sentence. The token says which algorithm to use, and a library obeys it. The token says which key to use, and a library fetches it. The token says where to fetch the key from, and a library goes there. None of those behaviours is a bug in the specification exactly, and all of them have produced complete authentication bypasses in shipped, popular, widely audited software.
The good news, and it is genuinely good news, is that the failure modes are finite, old and written down. The Internet Engineering Task Force published RFC 8725, “JSON Web Token Best Current Practices”, in February 2020, and gave it the designation BCP 225. It is twenty-odd pages long, it names every attack in this chapter, and it tells you exactly what to do about each one. Almost nobody who ships JWT code has read it. This chapter is, among other things, an attempt to make that document unavoidable. We will use one worked token throughout, issued by a fictional bank called Northbank to a customer numbered 88214, and we will forge it three separate ways before the end.
The plain version#
The chit and the rubber stamp#
Picture a very large hospital. It has one reception desk at the front and about sixty doors inside: wards, laboratories, the pharmacy, the records room. You arrive, you show your appointment letter at reception, and the clerk writes you a paper chit and stamps it.
The chit says three things in plain writing that anyone can read. It says who you are. It says which doors you are allowed through. It says the time after which the chit stops working, which today is a quarter of an hour from now.
At the bottom of the chit is a stamp, pressed in a distinctive ink. Every door in the hospital has been given a card showing what the genuine stamp looks like. When you present your chit at the pharmacy door, the person there does not telephone reception to ask about you. They read the chit, they compare the stamp against their card, they look at the clock, and they let you in or they do not. Reception is not involved. Reception may have gone home.
That is a JSON Web Token. The chit is the token, the writing is the statement, the stamp is the signature, and the fact that each door checks it alone, without calling back, is the entire reason the format exists.
There is a second style of pass, where the chit is a meaningless number and every door has to telephone reception to find out what number 4471 means. That style has its own advantages, and chapter 38 covers it. The style here trades the telephone call for a stamp.
Three strips of paper joined by two dots#
A real chit of this kind is not one piece of paper but three, printed one after another and separated by a full stop, like this:
first strip . second strip . third strip
The first strip is a short note about the stamp itself. It says something like: this chit was stamped with stamp number seven, using the blue ink. It does not contain any statement about you. Its only job is to tell the person at the door how to check the stamp at the bottom.
The second strip is the statement. Who you are, what you may do, when it expires, who issued it, and which building it is for.
The third strip is the stamp impression.
Two dots, three strips. Once you have seen it you will recognize a JWT anywhere, because a JWT is a long run of letters and digits with exactly two full stops in it and no spaces.
Now the crucial detail, and it is the one that the rest of this chapter turns on. The stamp at the bottom is pressed over the first two strips together, not over the second strip alone. Change one character of the statement and the stamp no longer matches. Change one character of the note about the stamp and the stamp no longer matches either. That is good, and it is deliberate.
But the person at the door has to read the first strip before they can check the stamp, because the first strip is what tells them which ink to look for. They read the forger’s instructions, and then they follow them. Everything that has ever gone wrong with this format goes wrong at that moment.
Why the chit is written in a strange alphabet#
If you look at a real JWT you will see that the writing is not readable. It looks like this: eyJhbGciOiJIUzI1NiJ9. People see that and assume it is secret writing. It is not. It is ordinary writing in an awkward alphabet, and undoing it takes about one second with a tool that every computer already has.
Here is why the awkward alphabet exists. A chit like this has to travel in places that are fussy about characters. It goes into web addresses, into the headers of web requests, into files, into log lines. Some of those places choke on a space, a plus sign, a slash, an accented letter or a line break. So the whole chit is rewritten using only sixty-four characters that survive that journey unharmed: the twenty-six capital letters, the twenty-six small letters, the ten digits, the hyphen and the underscore. Nothing else. Not even a full stop, which is exactly why the full stop can be used to separate the strips.
The rewriting is completely reversible and there is no secret involved. Anybody who has the chit can read every word of the statement. This is the single most common misunderstanding about the format: the statement on a JWT is public to whoever holds the token. If you put a customer’s home address on it, everyone who touches that token has the customer’s home address.
The two kinds of stamp#
There are two families of stamp and the difference between them decides how the whole system is built.
The first kind is a shared stamp. Reception has the stamp and every door has an identical copy of the same stamp. A door checks a chit by stamping a fresh impression itself and seeing whether the two match. This is simple and extremely fast. It has one consequence that people underestimate: any door that can check a chit can also make one. There is no difference between the two abilities. Give the stamp to sixty doors and you have sixty offices that can issue passes in reception’s name.
The second kind is a split stamp. Reception has a stamping block that nobody else has. Every door has only a viewing card, which can tell a genuine impression from a false one but cannot make an impression. Now the doors can check and cannot forge. The viewing card can be pinned up in the corridor for everyone to see; it is not a secret. This costs more work for the machine, and it needs a way for reception to publish the card, but it is the only arrangement that works when the doors are run by other organizations.
Almost every argument about JWT design is really an argument about which of those two you should be using, and almost every serious attack in this chapter works by tricking a door into treating a public viewing card as though it were a shared stamp.
The line that says when the chit dies#
The statement carries a time, and the door is supposed to look at the clock. If the chit expired four minutes ago the door refuses it, no matter how perfect the stamp is.
This sounds trivial. It is not, for two reasons that will come back later. The first is that the door has to actually look, and a surprising amount of software does not. The second is that once a chit is written, reception cannot take it back. There is no way to reach out and burn a piece of paper that somebody else is carrying. If a chit is stolen at ten past nine and it expires at a quarter past, the thief has five minutes and nobody can shorten them. That is why these chits are written with short lives, and it is why the expiry line is the most load-bearing line on the whole document.
A worked example: Priya asks for her balance#
Priya banks with Northbank. At nine o’clock in the morning on the seventeenth of August 2026 she signs in on her phone with her customer number and her passcode. The bank’s sign-in service checks them, decides she is genuine, and writes her a chit.
In plain words the chit says: this was stamped with stamp nb-2026-08, using the shared-stamp method with the strong modern recipe. It says: Northbank’s identity service issued this; it concerns customer 88214; it is meant for Northbank’s account service and nobody else; it becomes valid at nine o’clock; it stops working at a quarter past nine; and its own reference number is a3f1c7d2.
Priya’s phone then asks the account service for her balance and encloses the chit. The account service does four things and only four. It undoes the awkward alphabet and reads both strips. It checks the stamp with its copy of stamp nb-2026-08. It checks that the chit names the account service as its intended recipient, and not, say, the internal staff tool. It checks the clock and sees that nine minutes remain. Then it answers with the balance.
At nine sixteen the same chit arrives again. The stamp is still perfect. The account service refuses it anyway, on the clock alone. That fifteen minutes is the whole of the safety margin, and everything else in this chapter exists to make sure the four checks above are actually four checks and not three or two.
Where the plain version stops being true#
The chit is not in an envelope#
The plain version said anyone holding the chit can read it. That is true, and its consequences are larger than the sentence suggests.
A signed JWT provides integrity and origin authentication. It provides no confidentiality whatsoever. The encoding is base64url, defined in RFC 4648 section 5, and reversing it requires no key, no permission and no cleverness. Every intermediary that touches the token reads it: the browser, the phone’s operating system, any proxy in the path, the load balancer, the web server access log, the error tracker, the customer’s own developer tools.
The honest version: putting a claim in a JWT is publishing it to everyone in the request path. Names, email addresses, national identity numbers, internal role names, internal database keys, feature flags that reveal an unannounced product, and the exact structure of your permission model are all routinely leaked this way. If a value must be hidden as well as trusted, a signed token is the wrong container, and you need JWE, which is described in the technical half, or you need to stop putting the value in the token at all.
The door reads the forger’s instructions first#
The plain version said the first strip tells the door how to check the stamp. That is exactly what it does, and it is a circular arrangement that has no clean resolution.
The JOSE header is inside the signature, so it cannot be modified without breaking the signature, but it must be parsed and acted upon before the signature can be checked. A verifier therefore consumes attacker-controlled data before it has established that the data is trustworthy. It uses that data to decide which algorithm to run and which key to use, which means an attacker gets a vote on both.
The honest version: the alg header parameter is not a security control and should not be treated as an input to your decision at all. The correct model is that the application decides, in advance and out of band, which algorithms and which keys are acceptable for this kind of token from this issuer, and the header is used only to select among choices the application has already approved. RFC 8725 section 3.1 states this as a requirement on libraries: they “MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms when performing cryptographic operations.” Michael Jones, one of the format’s own authors, is a co-author of that document. The people who designed the header agree that you should not trust it.
A stamp is not a signature, and only one of the two kinds is#
The plain version described a shared stamp and a split stamp as two ways of doing the same job. Cryptographically they do different jobs, and one of them cannot do what people usually assume.
HS256 is a message authentication code, computed with HMAC and SHA-256. It proves that the token was produced by somebody in possession of the shared secret. If both the issuer and the verifier hold that secret, then from the verifier’s point of view a valid token proves only that it was made by the issuer or by the verifier, and the verifier cannot tell which. That is fine inside one service. It is useless as evidence to a third party, and it is dangerous the moment the secret is distributed to more than one consuming service, because every consumer becomes a potential issuer.
RS256, PS256, ES256 and EdDSA are digital signatures. Only the private key holder can produce them, and possession of the public key permits checking but not forging. This is the property that lets you hand your verification key to a partner organization, or publish it on the internet, without giving them the ability to impersonate you. RFC 7515 section 10.5 is devoted to exactly this distinction.
The honest version: HMAC and digital signatures are not two flavours of the same thing. HMAC gives you integrity between parties who already share a secret. Signatures give you integrity plus origin authentication that a third party can verify. If more than one organization needs to check your tokens, HS256 is the wrong answer and no amount of key hygiene fixes it.
The clock on the wall is not one clock#
The plain version had the door glance at the clock. There is no single clock.
Expiry in a JWT is expressed as a NumericDate, defined in RFC 7519 section 2 as the number of seconds since 1970-01-01T00:00:00Z, ignoring leap seconds. The issuing machine reads its own clock when it writes exp, and the verifying machine reads its own clock when it checks. Those two machines are in different data centres, possibly on different continents, and their clocks agree only to the extent that somebody has configured time synchronization correctly and it has not silently failed.
In practice verifiers allow a small tolerance, usually thirty to sixty seconds, called clock skew leeway. This is a convention, not a standard; RFC 7519 sections 4.1.4 and 4.1.5 permit “some small leeway, usually no more than a few minutes, to account for clock skew” but fix no number. So a token with a stated ninety-second life may be honoured for over two minutes at a sloppy verifier, and a token from a machine whose clock has drifted three minutes fast will be rejected everywhere as not yet valid.
The honest version: exp and nbf are approximate. Any security argument that depends on a token being dead within a precisely known number of seconds is wrong, and any token lifetime under about two minutes is fighting the leeway rather than the attacker.
Reception cannot burn the chit#
The plain version admitted that a written chit cannot be recalled. That is worth stating in its full form because it is the property people most want to wish away.
A signed JWT is valid until it expires, and there is no mechanism inside the format for cancelling one. Every scheme for revoking a JWT works by adding state back to the verifier: a deny-list of jti values, a check against a session store, a short lifetime plus a refresh token that can be revoked centrally. Each of those partially undoes the self-validation that was the reason to use a JWT in the first place. That trade is chapter 38’s territory and this chapter will not relitigate it. Note only that the honest way to describe a JWT’s lifetime is “the maximum time an attacker keeps access after a theft”.
Some of what is on the chit is not about you#
The plain version treated the statement as a description of the holder. Only some of it is.
A JWT payload mixes claims about the subject with claims about the token. sub is about the person. exp, nbf, iat and jti are about the piece of paper. iss and aud are about the route the paper is supposed to travel. Confusing these categories produces a specific and common bug: a service reads sub and grants access without ever reading aud, and so accepts a token that was minted for an entirely different service. RFC 8725 calls this a substitution attack in section 2.7 and cross-JWT confusion in section 2.8, and it is the failure that lets a low-value token from your marketing site open your payments API.
The honest version: a JWT is not a description of a user. It is a routed, timed, one-purpose statement, and every field that describes the routing and the timing is a security control that somebody has to check.
The stamp is checked by whatever software you happened to install#
The plain version implied that checking a stamp is one action. It is roughly fifteen actions, and the library you chose performs some subset of them, silently.
There is no conformance test suite that a JWT library must pass to call itself a JWT library. Two libraries in the same language, both popular, will differ on whether they require exp, whether they accept a token with no alg allowlist configured, whether they compare aud when aud is an array of one, whether they reject duplicate JSON member names, and whether they will follow a jku URL. RFC 8725 section 2.6 warns about the JSON encoding problem directly, because different JSON parsers disagree about duplicate keys, and a parser that keeps the last occurrence and one that keeps the first will read two different tokens out of the same bytes.
The honest version: “we use a well-known JWT library” is not a statement about your security posture. The defaults are the security posture, and you have to read them. The technical half of this chapter ends with the list of checks to verify against your own dependency.
The technical version#
The compact serialization: three parts, two dots, one signing input#
What people call a JWT is almost always a JWS Compact Serialization carrying a JSON claims set as its payload. The layering is worth stating precisely, because the specifications are separate documents and the vulnerabilities live in different ones.
RFC 7515 defines JSON Web Signature: a way to sign an arbitrary sequence of bytes and represent the result compactly. RFC 7519 defines JSON Web Token: a JWS or a JWE whose payload is a JSON object of claims. RFC 7518 defines the algorithms. RFC 7517 defines how keys are written down. All four were published in May 2015. A JWT is therefore not a signature format; it is a claims format that borrows one.
The compact serialization is:
BASE64URL(UTF8(JOSE Header)) || '.' ||
BASE64URL(JWS Payload) || '.' ||
BASE64URL(JWS Signature)
Three parts, two dots, and every character drawn from the sixty-four-character base64url alphabet plus the dot itself. Because the dot is not in that alphabet, splitting a token is unambiguous: split on the dot, expect exactly three pieces.
The single most important structural fact is what gets signed. The signing input, which RFC 7515 section 5.1 calls the JWS Signing Input, is the ASCII text:
ASCII(BASE64URL(UTF8(header)) || '.' || BASE64URL(payload))
It is the encoded text, including the dot, not the decoded JSON. This design choice removes an entire class of problem. If the signature covered the decoded JSON, a verifier would have to re-serialize the JSON identically to the issuer in order to check the signature, and JSON has no canonical form: key order, whitespace, number formatting and Unicode escaping all vary between libraries. By signing the transmitted characters, JOSE sidesteps canonicalization completely. It is one of the format’s genuinely good decisions and it is the reason you must never “clean up” or re-encode a token in transit.
Base64url is defined in RFC 4648 section 5. It is ordinary base64 with two substitutions: the sixty-third character is - instead of +, and the sixty-fourth is _ instead of /. Both replacements exist so the output survives being placed in a URL path or query string without escaping. RFC 7515 section 2 additionally requires that the trailing = padding characters be omitted, so a base64url segment in a JWT never contains = and never contains a line break.
The alphabet, in full:
value 0 to 25 -> A B C D ... X Y Z
value 26 to 51 -> a b c d ... x y z
value 52 to 61 -> 0 1 2 3 ... 7 8 9
value 62 -> -
value 63 -> _
Three bytes of input, twenty-four bits, become four characters of six bits each. When the input length is not a multiple of three there is a remainder, and the encoded length is not a multiple of four. That remainder is why unpadded base64url segments have a characteristic property worth memorizing.
| Input bytes mod 3 | Output chars mod 4 | Padding removed |
|---|---|---|
| 0 | 0 | none |
| 1 | 2 | two = |
| 2 | 3 | one = |
A segment whose length modulo four is exactly one is malformed and no valid base64url string has that shape. Decoders that must re-add padding compute it as (4 - len % 4) % 4 characters of =.
Decoding a real token, byte by byte#
Here is a real token. It was issued by the Northbank identity service at 09:00:00 UTC on 17 August 2026 for customer 88214, signed with HS256 under the shared key northbank-signing-key-2026-08-aa, which is thirty-two ASCII bytes. It is printed here broken across lines only so that it fits on the page; in reality it is one unbroken string of 318 characters.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im5iLTIwMjYtMDgi
fQ.eyJpc3MiOiJodHRwczovL2lkLm5vcnRoYmFuay5leGFtcGxlIiwic3ViI
joiODgyMTQiLCJhdWQiOiJodHRwczovL2FwaS5ub3J0aGJhbmsuZXhhbXBsZ
SIsImV4cCI6MTc4Njk1ODEwMCwibmJmIjoxNzg2OTU3MjAwLCJpYXQiOjE3O
DY5NTcyMDAsImp0aSI6ImEzZjFjN2QyIn0.OcyDn4yCzAR3BVBNLqqiKGecY
732lMzreiIvwn7wE64
Start with the very first three bytes of the header, which is the JSON text beginning {"alg".... The first three characters are {, " and a. Their ASCII values are 0x7B, 0x22 and 0x61. Write them in binary, concatenate, then regroup into blocks of six bits:
characters : { " a
hex : 7B 22 61
binary : 01111011 00100010 01100001
regrouped : 011110 110010 001001 100001
values : 30 50 9 33
base64url : e y J h
Value 30 is the thirty-first entry of the alphabet, counting from zero, which lands in the lower-case run and gives e. Value 50 gives y. Value 9 is in the upper-case run and gives J. Value 33 gives h. So {"a encodes to eyJh, which is why an overwhelming proportion of the base64 strings you have ever seen in a log file begin with eyJ: they are JSON objects, every JSON object begins with {", and {" plus almost any third character produces eyJ. Recognizing eyJ at the start of a string is the fastest way to identify a JWT by eye.
Continue the same process and the first segment decodes to:
{"alg":"HS256","typ":"JWT","kid":"nb-2026-08"}
The second segment decodes to:
{"iss":"https://id.northbank.example",
"sub":"88214",
"aud":"https://api.northbank.example",
"exp":1786958100,
"nbf":1786957200,
"iat":1786957200,
"jti":"a3f1c7d2"}
The real payload contains no line breaks; they are added here for reading. Note that the JSON is compact, with no spaces, because every byte of a token is paid for on every request.
The third segment, OcyDn4yCzAR3BVBNLqqiKGecY732lMzreiIvwn7wE64, is 43 characters, which decodes to exactly 32 bytes, which is the output size of SHA-256. That is the HMAC.
You can do all of this at a shell prompt. The tr step converts base64url back to standard base64 and the printf step re-adds padding.
TOK='eyJhbGciOi...' # the full token, one line
echo "$TOK" | cut -d. -f1 \
| tr '_-' '/+' \
| base64 -d 2>/dev/null
echo
echo "$TOK" | cut -d. -f2 \
| tr '_-' '/+' \
| base64 -d 2>/dev/null
And you can recompute the signature yourself, which is the part most people never do by hand:
SIGNING_INPUT="$(echo "$TOK" | cut -d. -f1,2)"
printf '%s' "$SIGNING_INPUT" \
| openssl dgst -sha256 -binary \
-hmac 'northbank-signing-key-2026-08-aa' \
| base64 | tr '+/' '-_' | tr -d '='
That prints OcyDn4yCzAR3BVBNLqqiKGecY732lMzreiIvwn7wE64, character for character, which is the third segment of the token. If it does not match, the token has been altered or the key is wrong, and there is no third possibility.
Here is the whole structure in one picture:
part 1 part 2 part 3
+---------+ . +-----------+ . +------------+
| header | | payload | | signature |
+---------+ +-----------+ +------------+
base64url base64url base64url
of 46 bytes of 158 bytes of 32 raw
of JSON of JSON bytes
| | ^
+--------+--------+ |
| |
signing input = "part1.part2" |
as ASCII characters |
| |
v |
HMAC-SHA256(key, input) -------------+
The JOSE header: eleven parameters, four of them dangerous#
RFC 7515 section 4.1 registers eleven header parameters. Only one is required. Several exist to help a verifier find the right key, and it is precisely those that become attack surface, because “help the verifier find a key” and “let the attacker choose the key” are the same sentence read from two directions.
| Header | RFC 7515 | Purpose |
|---|---|---|
alg |
4.1.1 | Algorithm identifier |
jku |
4.1.2 | URL of a JWK Set |
jwk |
4.1.3 | Public key, inline |
kid |
4.1.4 | Key identifier hint |
x5u |
4.1.5 | URL of an X.509 chain |
x5c |
4.1.6 | X.509 chain, inline |
x5t |
4.1.7 | SHA-1 cert thumbprint |
x5t#S256 |
4.1.8 | SHA-256 cert thumbprint |
typ |
4.1.9 | Media type of the token |
cty |
4.1.10 | Media type of the payload |
crit |
4.1.11 | Extensions that must be understood |
alg is the only one RFC 7515 marks as required, and it must be present in the protected header. typ is optional and is ignored by most software, which is a mistake we will come back to. crit lists header parameters that a verifier must understand or else reject the token; it is rarely used and often implemented wrongly.
The four to hold at arm’s length are jwk, jku, x5u and kid. The first three tell the verifier where the key is; the fourth tells it which key to pick from a set. All four are supplied by whoever sent the token, so a verifier that acts on any of them without a policy has handed key selection to the sender.
typ deserves attention because RFC 8725 section 3.11 promotes it from decoration to defence. If your system issues more than one kind of JWT from the same issuer with the same key, an attacker will try to present one where another is expected, and setting typ to a specific registered value and checking it makes that substitution fail. RFC 9068, “JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens”, published October 2021 and written by Vittorio Bertocci, requires exactly this: an access token conforming to that profile must carry typ of at+jwt, and a resource server should reject a token that does not. OpenID Connect ID tokens are a different kind, and chapter 41 covers how they are validated.
The seven registered claims and what each one prevents#
RFC 7519 section 4.1 registers seven claim names. None of them is mandatory in the specification. All seven are mandatory in practice, and the correct way to read the list is as seven separate attacks that the absence of each claim enables.
| Claim | RFC 7519 | What its absence allows |
|---|---|---|
iss |
4.1.1 | Any issuer’s token accepted |
sub |
4.1.2 | No principal to authorize |
aud |
4.1.3 | Token replayed at another API |
exp |
4.1.4 | Stolen token valid forever |
nbf |
4.1.5 | Pre-dated token usable early |
iat |
4.1.6 | No age or freshness check |
jti |
4.1.7 | No replay detection possible |
iss, the issuer, identifies the principal that issued the token. It is a StringOrURI, meaning a string that must be a URI if it contains a colon. Its real job is to select the key set: you look up the issuer, find the trusted keys for that issuer, and verify with those. RFC 8725 section 3.8 makes this explicit and demands that “the application MUST validate that the cryptographic keys used for the cryptographic operations in the JWT belong to the issuer.” Reading iss and not using it to constrain key selection is the commonest way this claim is wasted.
sub, the subject, identifies the principal the token is about. It is unique either globally or within the issuer’s namespace, and it is the value your application should key its authorization decisions on. It should be an internal, stable, opaque identifier. Using an email address as sub is a durable mistake, because email addresses are reassigned and changed, and because a sub that is also a login identifier invites confusion between the two.
aud, the audience, identifies the intended recipients. It is either a single StringOrURI or an array of them, and this dual type is a real source of bugs: a verifier written to compare strings will fail or, worse, silently pass when handed an array. A recipient must reject any token whose aud does not contain an identifier it recognizes as itself. RFC 8725 section 3.9 states that if an issuer can issue tokens for more than one relying party, aud is mandatory and the relying party “MUST validate the audience value, and if the audience value is not present or not associated with the recipient, it MUST reject the JWT.”
exp, expiration time, is the moment on or after which the token must not be accepted. nbf, not before, is the moment before which it must not be accepted. iat, issued at, records when it was made. All three are NumericDates: seconds since the Unix epoch, ignoring leap seconds, expressed as a JSON number that may in principle be non-integer. In our worked token exp is 1786958100 and iat is 1786957200, a difference of exactly 900 seconds, which is fifteen minutes.
jti, the JWT ID, is a unique identifier for the token. Its purpose is replay detection, and it only has that purpose if somebody records the values that have been seen. A jti that nobody stores is decoration. Where a token is single-use, such as a client assertion or a one-time authorization code exchange, jti plus a short-lived store of used values is the mechanism that makes single-use real.
Claims beyond these seven fall into two groups. Public claims are registered in the IANA “JSON Web Token Claims” registry, which RFC 7519 section 10.1 establishes, and include scope, client_id, azp, nonce and auth_time. Private claims are whatever you and your counterparties agree on, and collide with everyone else’s private claims, which is why the convention, though not a standard, is to namespace them with a URI you control.
The algorithms, and choosing among them#
RFC 7518 section 3.1 registers thirteen JWS algorithm identifiers plus none. You will meet five of them in modern systems, and the choice among them is decided by one question: does exactly one party need to make tokens, or do many parties need to check them.
alg |
RFC 7518 mark | Kind |
|---|---|---|
| HS256 | Required | HMAC-SHA-256 |
| RS256 | Recommended | RSA PKCS1-v1_5 |
| PS256 | Optional | RSA-PSS |
| ES256 | Recommended+ | ECDSA P-256 |
| EdDSA | in RFC 8037 | Ed25519 or Ed448 |
The “Recommended+” mark on ES256 means, in the language of RFC 7518 section 3.1, that its status may be raised in future; it is the format’s way of pointing at elliptic curves as the preferred direction. none is marked Optional and its dangers get their own section below.
HS256 uses HMAC with SHA-256. The key is a shared secret, which RFC 7518 section 3.2 requires to be “of the same size as the hash output” or larger, meaning at least 256 bits, 32 bytes. It is by far the fastest option and it produces the shortest signature, 32 bytes, 43 base64url characters. Its correct use is symmetric: one service both issues and verifies, or two services that already trust each other completely and share the key over a secure channel. Its incorrect use is fan-out. The moment a third service needs to verify, you must give it the secret, and now it can mint tokens as you. The Northbank worked example uses HS256 because the identity service and the account service are the same company sharing one key; that is exactly the situation HS256 is for.
RS256 uses RSASSA-PKCS1-v1_5 with SHA-256 over an RSA key that RFC 7518 section 3.3 requires to be at least 2048 bits. It is a digital signature: the private key signs, the public key verifies, and the public key can be published. This is the workhorse of federated identity, because the issuer keeps one private key and every relying party in the world can fetch the matching public key and check tokens without any shared secret. Its signature is large: a 2048-bit key produces a 256-byte signature, 342 base64url characters, which is bigger than the rest of a typical token combined. PS256 is the same RSA key material with the modern RSASSA-PSS padding, which has a cleaner security proof; it is preferable to RS256 for new systems but less widely supported by older verifiers.
ES256 uses ECDSA over the NIST P-256 curve with SHA-256. It is also a digital signature, and it gives roughly the same security level as 3072-bit RSA in a far smaller package: a 64-byte signature, 86 base64url characters, and small keys. Its cost is a subtlety in the signing operation, which we return to when we discuss the “psychic signatures” bug, and a historical worry about the quality of random numbers during signing, which is why RFC 8725 section 3.2 recommends the deterministic ECDSA construction of RFC 6979.
EdDSA is defined for JOSE in RFC 8037, published January 2017, using the Edwards curves Ed25519 and Ed448 with the “OKP” key type. It is the modern preference where supported: fast, small 64-byte signatures like ECDSA, no dependence on per-signature randomness by design, and an implementation that is easier to get right. There is a naming wrinkle worth knowing. RFC 8037 registered the single identifier EdDSA, which does not by itself say whether Ed25519 or Ed448 is meant; that ambiguity is the polymorphic-identifier problem. RFC 9864, “Fully-Specified Algorithms for JOSE and COSE”, published October 2025, addresses it by registering fully specified names, and by that document the plain EdDSA identifier’s IANA status was changed to Deprecated, meaning new deployments should prefer the specific name. As of August 2026 the plain EdDSA identifier remains overwhelmingly the one in the wild.
The measured differences are large enough to matter at scale, though the security choice should never be made on speed. The figures below were produced on the machine used to write this chapter, signing a 200-byte input, and are illustrative of the ratios rather than absolute for any other hardware.
| Algorithm | Sig bytes | Relative sign cost |
|---|---|---|
| HS256 | 32 | fastest, baseline |
| RS256 (2048) | 256 | slowest to sign |
| ES256 | 64 | moderate |
| EdDSA (Ed25519) | 64 | moderate |
RSA is asymmetric in cost as well as in keys: it signs slowly and verifies quickly, which suits a system that signs rarely and verifies constantly and is one reason RS256 held on so long despite its size. The decision rule, stated plainly: one party issuing and verifying, use HS256; many parties verifying, use ES256 or EdDSA for new work and RS256 or PS256 where compatibility forces it; and never HS256 across a trust boundary.
alg:none, and the year the format broke#
RFC 7518 section 3.6 defines the algorithm none. An Unsecured JWS “uses the ‘alg’ value ‘none’ and is formatted identically to other JWSs, but MUST use the empty octet sequence as its JWS Signature value.” In compact form that is a header, a payload, a trailing dot, and nothing after it. RFC 7519 section 6 permits an Unsecured JWT built the same way. The feature was included for cases where the integrity of the token is guaranteed by some outer mechanism, such as a token that only ever travels inside an already-signed envelope.
The feature was reasonable. The way libraries implemented it was a catastrophe. In late March 2015, days before RFC 7519 was even formally published, Tim McLean published “Critical vulnerabilities in JSON Web Token libraries” on the Auth0 blog. He had found that several popular libraries, when handed a token whose header said "alg":"none", would report the signature as valid and return the claims. A verifier that trusted that result accepted any token an attacker cared to write.
The attack needs no key and no cleverness. Take a legitimate token, or invent one from scratch. Set the header to {"alg":"none","typ":"JWT"}. Put whatever you like in the payload. Base64url-encode both, join with a dot, and append a final dot with nothing after it. Against our Northbank example, an attacker who wants to become customer 00001 sends:
header : {"alg":"none","typ":"JWT"}
payload : {"iss":"https://id.northbank.example",
"sub":"00001", ... }
token : eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.
eyJpc3MiOiJ...MDAwMS... .
The token ends in a dot. There is no third segment. A correct verifier, configured to accept only HS256 from this issuer, rejects it at the first step because none is not in the allowlist. A broken verifier sees alg:none, concludes that no signature check is required, decides the signature is therefore trivially valid, and reads sub as 00001.
The root cause is the circular design described earlier: the library used the attacker’s alg value to decide how to verify. RFC 8725 section 3.2 responds directly, saying libraries “SHOULD NOT consume JWTs using ‘none’ unless explicitly requested by the caller”, and section 3.1 makes the general rule that the caller, not the token, chooses the acceptable algorithms. The defence is one line: an allowlist that does not contain none. Everything else about this vulnerability is history.
The history did not end in 2015, because the same mistake keeps being reintroduced. Filter-based defences that reject the literal string none have been bypassed with None, nOnE and NONE, because some naive allowlists compared case-insensitively while some verifiers matched loosely. The lesson is to allowlist what is permitted rather than deny-list what is forbidden.
Algorithm confusion: verifying RS256 as HS256#
The second vulnerability in McLean’s 2015 write-up is subtler, more powerful, and still being found in production code in 2026. It turns a public key, which is meant to be published, into the secret that forges tokens.
Set the scene. Northbank’s federation uses RS256. The identity service holds an RSA private key and signs tokens with it. The account service holds only the matching RSA public key, which is published openly, because that is the entire point of asymmetric signing. Every relying party can fetch it.
Now consider how a careless verifier is written. It reads the alg from the token header, and it has one key configured: the RSA public key. Its logic is “look up the algorithm named in the header, then verify using my key.” The attacker changes the header from {"alg":"RS256"} to {"alg":"HS256"}, writes whatever payload they want, and computes the signature as HMAC-SHA-256 over the signing input, using the RSA public key’s bytes as the HMAC secret. Those bytes are public. The attacker has them.
When the verifier receives this token, it reads alg:HS256, and it fetches “its key”, which is the RSA public key. Because that same verifier was written to hand whatever key it has to whatever algorithm the header names, it now runs HMAC-SHA-256 with the RSA public key as the secret, gets the same value the attacker computed, and declares the signature valid. RFC 8725 section 2.1 describes this precisely: an “RS256 (RSA, 2048 bit) parameter value can be changed into HS256 (HMAC, SHA-256), and some libraries would try to validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret.”
Against Northbank, the forged header and the forged HMAC look like this. The exact secret is the PEM text of the published public key, byte for byte, including the -----BEGIN PUBLIC KEY----- lines, because that is the representation the honest verifier happened to load.
header : {"alg":"HS256","typ":"JWT","kid":"nb-2026-08"}
secret : the 451-byte PEM of the public key
payload : {"sub":"00001", ...}
sig : HMAC_SHA256(PEM_bytes, signing_input)
The precise byte representation matters and is where the subtlety bites: whether the verifier loaded the key as PEM text, as DER bytes, or as a parsed object changes what the attacker must use as the HMAC secret, but every one of those representations is public and can be reproduced. This is why the fix is never “protect the public key”. The public key cannot be protected; it is public. The fix is that the verifier must bind the key to the algorithm. RFC 8725 section 3.1 states it as a rule: “Each key MUST be used with exactly one algorithm, and this MUST be checked when the cryptographic operation is performed.” An RSA public key must be usable only with RSA verification. A verifier that is told “this issuer uses RS256, here is its RSA public key, accept nothing else” cannot be confused, because HS256 is not in its allowlist and the RSA key is not an HMAC key in its configuration.
This flaw earned CVE-2015-9235 against the Node.js jsonwebtoken module before 4.2.2, and CVE-2016-10555 against jwt-simple 0.3.0 and earlier, both scored critical or high and both published to the National Vulnerability Database in 2018. It has recurred many times since, because the mistake is not in any one library but in the shape of the API: a verify(token, key) function that reads the algorithm from the token is confusable, and only a verify(token, key, allowed_algorithms) function that rejects everything else is not.
kid, jku, x5u and jwk: the four ways to be told which key to trust#
The header parameters that help a verifier find a key are the richest attack surface in the whole format, because each one lets the sender influence key selection, and key selection is the decision that must not be influenced by the sender.
kid, the key identifier, is a hint that names which key was used, so that a verifier holding several keys can pick the right one. In honest use it is an opaque label such as nb-2026-08, and the verifier looks it up in a table of keys it already trusts. The abuse comes when a verifier uses the kid value to construct something: a file path, a database query, a URL. If the code does the moral equivalent of “open the file named by kid and use its contents as the key”, then an attacker sets kid to a path they control. Two classic forms:
kid path traversal:
{"alg":"HS256","kid":"../../../../dev/null"}
On many systems /dev/null reads as empty, so the
HMAC key is the empty string, which the attacker
also uses to sign. Signature matches.
kid SQL injection:
{"alg":"HS256",
"kid":"none' UNION SELECT 'attackerkey' -- "}
If kid is concatenated into a key-lookup query,
the attacker returns a key of their choosing.
The empty-key case is worth spelling out. If kid points at a file whose contents are empty, and the algorithm is HS256, then the HMAC secret is the empty byte string, and an attacker who signs their forged token with the empty secret too will match. Against a Northbank-style verifier a forged token with kid of ../../../../dev/null and an HMAC computed over the empty key produces the signature xj2bbZhBRZcocMtJ4YDhmlPfPDVMf2spsfkQs4UG_F0, which the broken verifier accepts because it computed the identical value. The defence is that kid must only ever be used as a lookup key into a set of already-trusted keys, never interpolated into a path or a query, and a kid that does not match a known key is a rejection, not a fallback.
jwk, the inline public key, is the most direct attack of all. It lets the sender embed the entire public key in the header. A verifier that trusts it verifies the token against a key the attacker generated moments ago, which of course matches the attacker’s signature perfectly. There is almost no legitimate reason for a verifier to trust a jwk from the token, and the rule is simply to ignore it. This is the flaw behind CVE-2018-0114 in Cisco’s node-jose before 0.11.0, where an attacker could “re-sign tokens using a key that is embedded within the token.”
jku and x5u are the same attack conducted at a distance. jku is a URL pointing at a JWK Set; x5u is a URL pointing at an X.509 certificate chain. A verifier that fetches the URL in the token and trusts the keys it finds there will trust keys hosted by the attacker. Even a verifier that tries to be careful is exposed to server-side request forgery, where the fetch itself is the payload: the attacker points jku at an internal address the verifier can reach but the attacker cannot, and uses the verifier as a proxy into the private network. And URL allowlists are bypassed through parser discrepancies, where a crafted authority such as trusted.example@attacker.example embedded in the URL, or an open redirect on the trusted host, defeats a naive host check.
The correct handling of key material is the mirror image of all of this, and it is worth stating as a positive procedure rather than a list of prohibitions.
The issuer publishes its public keys at a stable, well-known location that the verifier is configured with in advance, out of band. In OpenID Connect and OAuth this is the JWKS endpoint, a JWK Set as defined in RFC 7517, and the verifier learns its URL from configuration or from a discovery document it already trusts, never from the token. RFC 7517 section 5 defines the set as a JSON object with a keys member holding an array of JWKs, and section 4.5 says that within a set “different keys ... SHOULD use distinct ‘kid’ values”, which is what makes kid a usable index. A published RSA key in that set looks like this:
{"keys":[
{"kty":"RSA","use":"sig","kid":"nb-2026-08",
"alg":"RS256","e":"AQAB",
"n":"zhnhlrokm3g9adNzvxvwluBVeTROc2dM..."}
]}
The verifier fetches that document from the URL it was configured with, not the URL in the token; caches it, respecting cache headers, so it is not fetched on every request; and when a token arrives, uses the token’s kid only to select among the keys already in that trusted set. If the kid is unknown, the verifier may refetch the set once, to handle key rotation, and then reject if it is still unknown. It never fetches an arbitrary URL, never trusts an inline key, and always confirms that the selected key’s type matches the expected algorithm. Chapter 41 covers the discovery document and JWKS caching in the specific context of OpenID Connect, so this chapter stops at the principle: the location of trust is configured, and the token is allowed only to point within it.
A signature bug beneath the format#
Not every JWT failure is a misuse of the format. Some are failures of the cryptography underneath it, and one example keeps “use a signature” from being mistaken for “you are safe.”
In April 2022 Neil Madden disclosed CVE-2022-21449, which he named “psychic signatures”, against Oracle’s Java 15 through 18 before that month’s Critical Patch Update. Java’s ECDSA verification failed to check that the signature values r and s were non-zero. An attacker could present a signature in which both were zero, and Java would accept it as valid for any message and any public key. Any system on an affected Java version that verified ES256, ES384 or ES512 JWTs could be handed a completely blank signature and would treat forged tokens as genuine. The token was well-formed, the algorithm was a good one, the key was strong, and the verification was broken beneath all of it.
The lesson is not “avoid ECDSA”. The lesson is that a signature check is only as good as its implementation of the “reject invalid inputs” step, which RFC 8725 section 3.4 calls out as its own best practice, “Validate Cryptographic Inputs”, because operations like this take values that must be checked before use.
JWE: when signing is not enough#
Everything so far has been about JWS, which signs. JWS gives integrity and origin authentication and gives nothing at all in the way of secrecy, because the payload is merely encoded. When the contents of the token must be hidden as well as trusted, the answer is JWE, JSON Web Encryption, defined in RFC 7516, published May 2015 by Michael Jones and Joe Hildebrand.
A JWE in compact form has five parts and four dots, where a JWS has three parts and two:
BASE64URL(UTF8(JWE Protected Header)) || '.' ||
BASE64URL(JWE Encrypted Key) || '.' ||
BASE64URL(JWE Initialization Vector) || '.' ||
BASE64URL(JWE Ciphertext) || '.' ||
BASE64URL(JWE Authentication Tag)
The quickest way to tell a JWE from a JWS by eye is to count the dots: two dots is a JWS, four dots is a JWE. The header of a JWE carries two algorithm parameters instead of one. alg here names the key management algorithm, how the content key is wrapped or agreed, with values such as RSA-OAEP, ECDH-ES or A256KW. enc names the content encryption algorithm, how the payload itself is sealed, with values such as A256GCM or A128CBC-HS256. A JWE therefore encrypts under one key and, because authenticated encryption modes like AES-GCM carry an integrity tag, it also protects against tampering. The authentication tag is the fifth part.
The important architectural point is who holds which key. In JWS the issuer signs with its private key and everyone verifies with the issuer’s public key. In JWE the direction is reversed: the sender encrypts with the recipient’s public key, so that only the recipient’s private key can open it. That means a token meant to be readable by fifty relying parties cannot simply be JWE-encrypted, because you would have to encrypt it separately for each, or share one content key among all of them and lose the point.
When is JWE actually needed? Rarely, and that rarity is itself the lesson. If a token travels only over TLS between a client and a server that both need to read it, the transport already provides confidentiality on the wire, and a signed JWT is enough. JWE earns its place when the token rests somewhere untrusted, or passes through an intermediary that must forward it but must not read it, or carries a value that would be damaging if leaked even to the legitimate holder. A common pattern is the nested JWT: an inner JWS, signed by the issuer, wrapped inside an outer JWE, encrypted for the recipient, so the token is both authenticated and confidential. RFC 7519 section 6 and RFC 8725 section 3.3 both stress that in a nested token both operations must be validated, and RFC 8725 section 2.3 warns specifically about getting the order of signing and encryption wrong.
There is a strong practical recommendation hiding in all of this. If you find yourself reaching for JWE to hide a claim, the better move is almost always to not put the sensitive value in the token at all. Keep it server-side, put an opaque reference in the token, and look it up. That collapses the confidentiality problem back into the ordinary problem of protecting your own database, which you have to solve anyway. JWE is the right tool when a token genuinely must be a self-contained sealed envelope, and the wrong tool when it papers over the decision to put a secret in a bearer credential.
RFC 8725 in one page: the validation order that matters#
The best current practice document, BCP 225 / RFC 8725, is the closest thing this field has to a checklist, and the order of the checks matters because some of them are cheap and decisive and belong first. What follows is a validation sequence in the order a verifier should perform it, with a note on each step about whether a typical off-the-shelf library does it for you or leaves it to you. Treat “leaves it to you” as “will be skipped unless you wrote a line of code”, because that is what it means in practice.
The sequence, stated as an ordered procedure:
1 Split on '.'; require exactly 3 parts (JWS)
or 5 (JWE). Reject otherwise.
2 Base64url-decode the header. Reject on any
padding, whitespace or non-alphabet byte.
3 Parse header JSON. Reject duplicate member
names. Reject unknown 'crit' parameters.
4 Read 'alg'. Reject unless it is in YOUR
configured allowlist. Never read the key first.
5 Select the key by (iss, kid) from a trusted
set. Never from 'jwk', 'jku' or 'x5u'.
6 Confirm the key's type matches the algorithm.
RSA key -> RSA alg only, and so on.
7 Verify the signature over the exact received
'header.payload' bytes. Reject on failure.
8 Only now parse the payload JSON as claims.
9 Check 'iss' equals the expected issuer.
10 Check 'aud' contains this service's identifier.
11 Check 'exp' is in the future, within leeway.
12 Check 'nbf' is in the past, within leeway.
13 Check 'typ' if you issue more than one kind.
14 Check 'jti' against a used-token store if the
token is single-use.
15 Only then read application claims and authorize.
Steps 1, 2, 3 and 7 are the mechanical parts, and every serious library does them. The parts libraries commonly skip, unless you configure them, are the ones that carry the real security weight.
| Step | Typical default |
|---|---|
| 4 alg allowlist | Often absent; you must set it |
| 5 key from trusted set only | Yours to enforce |
| 6 key-type binding | Often not checked |
| 9 iss check | Rarely automatic |
| 10 aud check | Frequently skipped |
| 11 exp check | Usually done |
| 13 typ check | Almost never automatic |
| 14 jti replay check | Always yours |
The two most consequential omissions in real systems are step 4 and step 10. Skipping step 4 is what makes alg:none and algorithm confusion possible, and both were live, exploited flaws in shipped libraries as covered above. Skipping step 10, the audience check, is what lets a token minted for one service be replayed at another; RFC 8725 sections 2.7 and 2.8 name this as substitution and cross-JWT confusion, and RFC 8725 section 3.9 makes the audience check mandatory when an issuer serves more than one relying party. A verifier that does steps 1, 2, 3, 7 and 11, which is what you get from a library called with its defaults, feels like it is validating tokens, and it is checking the signature and the expiry and nothing else. It is those unchecked middle steps that turn a JWT from a security control into a rubber stamp that reads back whatever the sender wrote.
The hardened verifier, stated once#
Pulling the worked example all the way through, here is what correct verification of Priya’s Northbank token looks like, expressed as the configuration the account service holds rather than as any one library’s code, because the point is the policy and not the syntax.
issuer expected : https://id.northbank.example
audience (self) : https://api.northbank.example
algorithms : ["HS256"] # exactly this
key : shared 32-byte secret for
kid = nb-2026-08, from a
secret store, not the token
leeway : 60 seconds
require claims : iss, sub, aud, exp
typ (if used) : checked against expected value
A token arrives. The service confirms three parts. It reads alg as HS256, which is in the one-item allowlist, so it proceeds; anything else, including none and RS256, is an immediate rejection. It reads kid as nb-2026-08 and looks up the matching secret in its own store, ignoring any hint about where else the key might live. It recomputes the HMAC over the received header.payload bytes and compares in constant time. It confirms iss is the expected issuer and aud contains its own identifier, api.northbank.example. It confirms exp is in the future and nbf is in the past, each within sixty seconds of leeway. Then, and only then, it reads sub as 88214 and authorizes the request as that customer. Every one of the three forgeries earlier in this chapter, the alg:none token, the RS256-verified-as-HS256 token, and the kid traversal token, dies at step 4 or step 5 of that procedure, before a single claim is read.
39.98 Common wrong ideas#
Wrong: A JWT is encrypted, so it is safe to put secrets in it. Right: A standard signed JWT is a JWS, and its payload is only base64url-encoded, which anyone can reverse without a key. Signing gives integrity and origin authentication, never confidentiality. To hide the contents you need JWE, defined in RFC 7516, or you keep the secret server-side and put an opaque reference in the token.
Wrong: The alg header tells my verifier which algorithm to use, so my verifier is doing the right thing by reading it. Right: The algorithm must be chosen by the application from an allowlist it controls, and the header is used only to select among already-approved choices. RFC 8725 section 3.1 requires libraries to let the caller fix the algorithm set and to use no other; a verifier that trusts the header’s alg is the root cause of both alg:none and algorithm confusion.
Wrong: Using a strong signing algorithm like RS256 means my tokens cannot be forged. Right: RS256’s public key is published by design, and a verifier that will run whatever algorithm the header names can be tricked into verifying an HS256 token using that public key as an HMAC secret. The fix, per RFC 8725 section 3.1, is to bind each key to exactly one algorithm and to allowlist algorithms, not to try to hide the public key.
Wrong: The none algorithm was a bad idea that has been removed from the specification. Right: RFC 7518 section 3.6 and RFC 7519 section 6 still define Unsecured JWTs, and they remain valid for narrow uses where integrity is provided by an outer layer. The 2015 disaster was libraries treating none as a verified signature by default; the defence is an allowlist that omits none, not the removal of the feature.
Wrong: As long as the signature checks out, the token is valid. Right: A valid signature only proves the token was made by the key holder and not altered. It says nothing about whether the token was meant for you, whether it has expired, or whether it is even the right kind of token. The aud, exp, nbf, iss and typ checks are separate steps, and RFC 8725 sections 3.8 and 3.9 make the issuer and audience checks mandatory when relevant.
Wrong: The kid, jku and jwk headers just help my verifier find the correct key, so trusting them is convenient and harmless. Right: Every one of those is supplied by the sender, so trusting them hands key selection to the sender. jwk lets an attacker supply their own key inline; jku and x5u point the verifier at attacker-hosted keys or into your internal network; and kid interpolated into a path or query becomes traversal or injection. Keys come only from a set the verifier already trusts.
Wrong: A short exp means a stolen token is only dangerous for a few seconds, because the clocks are exact. Right: exp and nbf are compared against the verifier’s own clock, and verifiers allow leeway, usually thirty to sixty seconds, for clock skew, so the effective window is fuzzier than the stated one. Worse, a JWT cannot be recalled before exp, so the honest description of the lifetime is the maximum time an attacker retains access after a theft.
Wrong: My login library validates JWTs, so audience and issuer are handled. Right: There is no conformance suite for JWT libraries, and many verify the signature and expiry by default while leaving the issuer, audience, algorithm-allowlist and type checks to you. The audience check in particular is frequently skipped, which is exactly the gap that lets a token minted for one service be replayed at another.
Wrong: A JWT and a session cookie are interchangeable ways to keep someone logged in. Right: A signed JWT is self-validating and cannot be revoked before it expires, which is a different trade from a server-side session that can be ended instantly but must be looked up. Bearer semantics and revocation belong to chapter 38, and the choice between stateless tokens and stateful sessions is a real design decision, not a matter of taste.
Wrong: sub is the user’s identity, so I can show it to people or match it to their email. Right: sub should be a stable, opaque, internal identifier scoped to the issuer, used for authorization and nothing else. Email addresses change and are reassigned, so using them as sub produces account confusion, and a sub value that doubles as a human-facing login identifier invites the two roles to be mixed up.
39.99 Chapter summary in 20 lines#
- A JWT is a small text credential carrying a JSON statement and a signature over it, defined in RFC 7519 of May 2015, and it is checked by each recipient alone without calling the issuer.
- The usual form is a JWS with three base64url parts separated by two dots: a header describing the signature, a payload of claims, and the signature itself.
- The signature covers the exact transmitted characters of
header.payload, which sidesteps JSON canonicalization and is why a token must never be re-encoded in transit. - Base64url, from RFC 4648 section 5, uses hyphen and underscore for the last two alphabet slots and strips padding, so a JWT segment has no plus, slash, equals, space or line break.
- Any holder can read a signed JWT’s payload, because encoding is not encryption; a signed token gives integrity and origin authentication but never confidentiality.
- RFC 7519 section 4.1 registers seven claims, and each one’s absence enables a specific attack:
iss,sub,aud,exp,nbf,iatandjti. audandissare routing controls: a recipient must reject a token whose audience is not itself or whose issuer is not the expected one, per RFC 8725 sections 3.8 and 3.9.- HS256 is a shared-secret HMAC suitable only inside one trust boundary, because anyone who can verify it can also forge it.
- RS256, PS256, ES256 and EdDSA are digital signatures whose public verification key can be published, which is what makes federation across organizations possible.
- ES256 and EdDSA give the same security as RSA in far smaller signatures, and EdDSA, from RFC 8037, is the modern default where it is supported.
- RFC 9864 of October 2025 introduced fully-specified algorithm identifiers and deprecated the polymorphic
EdDSAname, though that name remains dominant in the wild as of August 2026. - The alg:none flaw of 2015, reported by Tim McLean, let attackers forge tokens by setting the algorithm to
nonewith an empty signature, which broken libraries accepted as verified. - Algorithm confusion changes RS256 to HS256 and signs with the published RSA public key as the HMAC secret; it earned CVE-2015-9235 and CVE-2016-10555 and still recurs.
- Both attacks have the same root cause and the same fix: the verifier must choose the algorithm from its own allowlist and bind each key to exactly one algorithm, per RFC 8725 section 3.1.
- The
kidheader must be used only as an index into already-trusted keys, never interpolated into a file path or database query, or it becomes traversal or injection. - The
jwk,jkuandx5uheaders let a sender supply or point at keys, so a verifier must ignore them and take keys only from a location it was configured with out of band. - JWE, from RFC 7516, has five parts and four dots, encrypts under the recipient’s public key, and is needed only when a token must be confidential and not merely trusted.
- CVE-2022-21449, the “psychic signatures” bug in Java 15 to 18, accepted all-zero ECDSA signatures as valid, showing that even a good algorithm fails if the input-validation step is missing.
- Correct validation is an ordered sequence, and libraries called with defaults typically do the signature and expiry checks while skipping the algorithm allowlist, the audience and the issuer.
- Every forgery in this chapter dies before a single claim is read if the verifier fixes its algorithm allowlist and takes its key only from a trusted set, which is the whole discipline in two rules.
Chapter sources: RFC 7519, “JSON Web Token (JWT)”, M. Jones, J. Bradley and N. Sakimura, May 2015, sections 2, 4.1.1 to 4.1.7, 6, 7.2 and 10.1; RFC 7515, “JSON Web Signature (JWS)”, same authors, May 2015, sections 2, 4.1.1 to 4.1.11, 5.1 and 10.5; RFC 7516, “JSON Web Encryption (JWE)”, M. Jones and J. Hildebrand, May 2015, sections 5.2 and 7.1; RFC 7517, “JSON Web Key (JWK)”, M. Jones, May 2015, sections 4.1 to 4.8 and 5; RFC 7518, “JSON Web Algorithms (JWA)”, M. Jones, May 2015, sections 3.1, 3.2, 3.3, 3.4 and 3.6; RFC 7638, “JSON Web Key (JWK) Thumbprint”, M. Jones and N. Sakimura, September 2015; RFC 8037, “CFRG ECDH and Signatures in JOSE”, I. Liusvaara, January 2017, for the EdDSA and OKP registrations; RFC 8725, “JSON Web Token Best Current Practices”, BCP 225, Y. Sheffer, D. Hardt and M. Jones, February 2020, sections 2.1 to 2.9 and 3.1 to 3.12; RFC 9068, “JWT Profile for OAuth 2.0 Access Tokens”, V. Bertocci, October 2021, for the at+jwt typ requirement; RFC 9864, “Fully-Specified Algorithms for JOSE and COSE”, M. Jones and O. Steele, October 2025, for the deprecation of the polymorphic EdDSA identifier; RFC 9901, “Selective Disclosure for JSON Web Tokens”, D. Fett, K. Yasuda and B. Campbell, November 2025; RFC 4648, “The Base16, Base32, and Base64 Data Encodings”, S. Josefsson, October 2006, section 5 for base64url; RFC 6979, “Deterministic Usage of DSA and ECDSA”, T. Pornin, August 2013, for deterministic ECDSA; Tim McLean, “Critical vulnerabilities in JSON Web Token libraries”, March 2015, for the alg:none and RS256-to-HS256 disclosures; CVE-2015-9235 against node jsonwebtoken before 4.2.2 and CVE-2016-10555 against jwt-simple 0.3.0 and earlier, both published to the National Vulnerability Database on 29 and 31 May 2018; CVE-2018-0114 against Cisco node-jose before 0.11.0, published 4 January 2018, for the inline jwk attack; CVE-2020-15084 against express-jwt up to 5.3.3, published 30 June 2020, for an unenforced algorithm list; Neil Madden, “CVE-2022-21449: Psychic Signatures in Java”, 19 April 2022, and the corresponding Oracle Critical Patch Update, for the all-zero ECDSA verification flaw in Java 15 to 18; Antonio Sanso, “Critical vulnerability in JSON Web Encryption (JWE)”, 13 March 2017, for the invalid-curve attack on ECDH-ES; and the PortSwigger Web Security Academy JWT materials for the kid path-traversal, jku injection and hashcat HMAC brute-force techniques.