The labs on this site need JavaScript to run. The same content is written out below.
Learn › Security › Authentication
JWT structure and verification
How does the server trust a token that anyone can read?
A JWT is a string made of three parts joined by dots (.): a header, a payload, and a signature. The first two parts are not encrypted. They are only written in a different notation, so anyone can read and change them. The server can still trust the token because it recalculates the third part, the signature, by itself.
Checked against real execution · Tokens were actually created with crypto (HMAC-SHA256) in Node.js 24 and the verification results of all 16 combinations were checked, and the Java code on the screen was also run on JDK 21 and checked
Concepts in this lab
- JSON Web Token (JWT)
- A token that carries content such as user information (claims) and has a signature attached. It has three parts: a header, a payload, and a signature. The server verifies the signature to confirm that the token was not forged, and it does not need to look up a separate store. The payload is encoded, not encrypted, so anyone can read its contents. Do not put sensitive information in it.
- Base64url encoding
- A notation that writes bytes using only uppercase and lowercase English letters, digits, and the characters - and _. It is Base64 with + and / replaced by - and _ and the trailing = removed, so that the result can be put in a URL or an HTTP header without breaking. All three parts of a JWT are written in this notation. Anyone can turn it back into the original contents without a key, so it is not encryption and does not hide the contents at all.
- Claim
- A name/value pair written in the payload of a token. Some names are defined by the standard, such as sub (who the token is about), exp (the expiration time), and iss (who issued it), and some are added by a service because it needs them, such as role. exp is written in seconds counted from midnight (0:00) on January 1, 1970 (UTC), and the server accepts the token only while the current time is before exp. Claims can be read by anyone, so you do not put sensitive values such as passwords in them, and you do not trust their contents before the signature has been verified.
- HMAC and digital signatures
- Techniques for confirming that a message was not changed on the way and that the legitimate party sent it. HMAC hashes the message together with a secret key that both sides share to produce an authentication code. With a digital signature, the sender signs with a private key and the receiver verifies with the public key. Only the signer can create the signature, so the sender cannot deny having sent the message.
- Algorithm verification
- The practice of having the server, not the token, decide which signature algorithm is used to verify a token. The header of a JWT has an item called alg that says which algorithm was used to sign it, but the header is text that anyone can change. If the server simply follows this value, the sender can change alg to none (no signature) and make the server skip the signature check. So the server decides the list of algorithms it accepts in advance, and if the alg in the header is not in that list, it rejects the token before even calculating the signature.
Try these
- Default settings — This is a token whose role in the payload was changed to ADMIN. Anyone can read and change it, but it gets caught at the signature.
- Exactly as issued — The signature matches and the token has not expired, so it passes.
- If a new signature is made with a different key — It differs from the value calculated with the server's key, so it is rejected.
- After the expiration time — It is rejected even though the signature is correct.
- A token with alg changed to none — A server that has fixed the algorithm rejects it before even looking at the signature.
- A server that trusts the alg the token states — The token with no signature passes as ADMIN.
Running with the default settings
- Someone changed the middle part of the token before sending it. They rewrote the role from USER to ADMIN and left the original signature at the end as it was.
- token.split("\\."): the server cuts the token at the dots (.) into three parts. The first is the header, the second is the payload, and the third is the signature.
- dec.decode(part[0]): the server decodes the first part with a Base64url decoder. No key is needed. The result is {"alg":"HS256","typ":"JWT"}. The alg is what the token says about itself: "this token was signed with this algorithm."
- dec.decode(part[1]): the server decodes the second part the same way. The result is {"sub":"7","role":"ADMIN","exp":1767225600}. It is not encrypted, only written in a different notation, so anyone who gets hold of the token can read it. The signature has not been checked yet, so the server does not trust this content.
- Algorithm check: this server has decided to accept only HS256. The alg in the header is HS256, so it moves on to the next check.
- mac.doFinal(part[0] + "." + part[1]): the server recalculates the signature from the first two parts of the received token and the key only the server knows. The result is _Crl3waj…. The server does not trust the signature that came with the token. It builds one itself.
- MessageDigest.isEqual: it compares the recalculated value (_Crl3waj…) with the third part of the token (5hho4Bt2…). They differ. The attached signature was made from the contents before the change, so it does not match the value calculated from the changed contents. The server rejects the token as a "signature mismatch".
- Rejected: the server does not use the contents of the token and rejects the request. It usually answers with 401 Unauthorized.
Result of this run
- The header and the payload are written in base64url, so they can be read and changed without a key. That is why you do not put things like passwords in a token.
- If you change even one character of the contents, the signature the server recalculates changes. The signature matches only these exact contents.
Code
// Request header Authorization: Bearer <token>
// Token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
// .eyJzdWIiOiI3Iiwicm9sZSI6IkFETUlOIiwiZXhwIjoxNzY3MjI1NjAwfQ
// .5hho4Bt2LLsypoE43dqORdbPqUDkMlzinRCY-zDKmMc
// Now now = 1767225000 (exp = 1767225600)
Claims verify(String token, long now) throws Exception {
String[] part = token.split("\\.", -1); // header . payload . signature
if (part.length != 3) throw new BadTokenException("invalid format");
Base64.Decoder dec = Base64.getUrlDecoder();
String header = new String(dec.decode(part[0]), UTF_8); // decodes without a key
String payload = new String(dec.decode(part[1]), UTF_8); // decodes without a key
// 1. Algorithm: accept only HS256, which the server has set
if (!"HS256".equals(field(header, "alg")))
throw new BadTokenException("algorithm not allowed");
// 2. Signature: the server recalculates it from the first two parts and compares
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(serverKey, "HmacSHA256")); // a key only the server knows
byte[] expected = mac.doFinal((part[0] + "." + part[1]).getBytes(US_ASCII));
if (!MessageDigest.isEqual(expected, dec.decode(part[2])))
throw new BadTokenException("signature mismatch");
// 3. Expiration: the current time must be before exp
if (now >= Long.parseLong(field(payload, "exp")))
throw new BadTokenException("expired");
return new Claims(field(payload, "sub"), field(payload, "role")); // the payload is trusted only after reaching this point
}
// field(json, name) is a helper that pulls one value out of the JSON.
// This code is written by hand to show the principle. A real service uses a well-tested JWT library.
Assumptions of this model
The token text, the signature values, and the verification results of all 16 combinations on the screen were checked against the results of actually creating and verifying tokens with crypto (HMAC-SHA256) in Node.js 24 on the PC used to write this lab. The Java code on the screen was also run as is on JDK 21, and the same tokens and the same results came out. There is no JWT library on this PC, so the verification procedure was run with code written by hand from the standard documents. The basis is as follows. The shape of three parts joined by dots and the base64url notation are in RFC 7515 Section 3 and Section 2, that the signature is calculated over "first part.second part" is in RFC 7515 Section 5.1, the order of verification is in RFC 7515 Section 5.2 and RFC 7519 Section 7.2, that the current time must be before exp is in RFC 7519 Section 4.1.4, and the shape of a token whose alg is none and whose signature is empty is in RFC 7519 Section 6.1. When the current time is exactly equal to exp, the token is rejected (checked by running it). That the server should set the algorithms it accepts is the recommendation of RFC 8725 Section 3.1, and that widely used JWT libraries reject none by default was not verified by execution. A real server may allow a margin of a few seconds to a few minutes on exp to allow for clock skew (RFC 7519 Section 4.1.4 allows this), and it also checks other claims such as iss and aud. The keys and tokens on the screen are values made for this lab. The problem of sending a token with alg changed to HS256 to a server that uses a public-key algorithm (RS256) is not covered.
Previous lab: Password hashing · Next lab: OAuth login flow