Security
JSON Web Tokens explained: what is inside one and what decoding does not prove
A JSON Web Token looks like an opaque string, yet anyone can read it, and reading it says nothing about whether it is genuine. This article opens a real token piece by piece: the three segments, the encoding, the standard claims, and the signature check that separates a trustworthy token from a forged one. It also covers the attacks that follow from checking the wrong thing, how expiry and clock skew behave, and how tokens compare with server-side session cookies. It is written for developers who issue, consume or debug JWTs.
By the MaffeiTools team · Updated · 7 min read
Three base64url segments, none of them encrypted
In its usual compact form, a JWT is a JSON Web Signature (RFC 7515) whose payload is a set of claims (RFC 7519). It is three segments joined by dots: a protected header, the payload, and the signature. The header and payload are JSON objects encoded as UTF-8 and then base64url. The signature is raw bytes, also base64url-encoded.
Base64url is the variant defined in section 5 of RFC 4648. It uses the same 64-character alphabet as ordinary base64 except that it writes a hyphen where base64 has a plus and an underscore where base64 has a slash, so a token survives inside a URL or header. JWS also drops the trailing equals-sign padding. The encoding hides nothing: it is a reversible re-spelling of the bytes, exactly like the base64 decoder tool does, with no key involved.
That has a consequence people miss. A signed JWT gives integrity, not confidentiality. Every claim inside is readable by anyone who holds the token, including the browser, any proxy that logs headers, and anyone who pastes it into a debugging site. Confidentiality would need JSON Web Encryption, a different structure from the signed form covered here.
Worth knowing. A plain base64 decoder can choke on real tokens. The base64 decoder tool here passes its input straight to the browser's atob, which accepts only the standard alphabet. Missing padding is tolerated, but any segment containing a hyphen or underscore fails with an invalid-character error. Swap hyphens for plus and underscores for slash first, or use the JWT decoder, which handles base64url and UTF-8 correctly.
A token built by hand, byte by byte
The example below was produced by a script that builds the token with the WebCrypto HMAC-SHA256 primitive, using no JWT library, and then decodes it with this site's own decoder code. The header says the algorithm is HS256. The payload uses the standard claims with fictional values. The signing input is the first two encoded segments joined with a dot, and the signature is HMAC-SHA256 over exactly those ASCII characters, using a 35-byte demo secret.
header JSON : {"alg":"HS256","typ":"JWT"}
payload JSON: {"iss":"https://auth.example.com","sub":"user-4821","aud":"api.example.com","iat":1790000000,"nbf":1790000000,"exp":1790000900,"jti":"b7c1e0f2"}
segment 1: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
segment 2: eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTQ4MjEiLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20iLCJpYXQiOjE3OTAwMDAwMDAsIm5iZiI6MTc5MDAwMDAwMCwiZXhwIjoxNzkwMDAwOTAwLCJqdGkiOiJiN2MxZTBmMiJ9
segment 3: Dlxz0UZ2NOvx3NsHIL8NOBo-T3KgyTIqnYm-RP6XYN8 (32 bytes, 43 characters)
signing input = segment1 + "." + segment2
signature = HMAC-SHA256(secret, signing input)The registered claims and what each one is for
RFC 7519 registers a small set of claim names. None is mandatory; the specification says applications should define which claims they use and which are required. Treating them as optional is the source of many bugs, because a verifier that only checks signature and expiry will happily accept a token issued for a different service.
- iss (issuer): who created the token. Check it against the issuers you trust.
- sub (subject): whom the claims are about, unique within the issuer or globally.
- aud (audience): who the token is for. RFC 7519 says that if the claim is present and the recipient does not identify itself in it, the token must be rejected.
- exp (expiration time): on or after this moment the token must not be accepted.
- nbf (not before): before this moment the token must not be accepted.
- iat (issued at): when the token was created, useful for measuring age.
- jti (JWT ID): a unique identifier, usable to detect replay or to revoke one token.
Worth knowing. The three time claims are NumericDate values: seconds since 1970-01-01T00:00:00Z, not milliseconds. JavaScript's Date.now() returns milliseconds, so putting it into exp unconverted produces a token that expires tens of thousands of years from now.
Decoding is not verifying
Decoding reverses the encoding to show the JSON. Verifying recomputes the signature over the header and payload segments exactly as received and compares it to the third segment. A token that decodes cleanly can be entirely forged, because nothing stops an attacker from writing any JSON they like and encoding it.
The JWT decoder tool only decodes; it says so on the page, since checking a signature needs the key. The JWT verifier tool performs the check for HS256, HS384 and HS512 and also evaluates exp, nbf and iat. Notably, it takes the algorithm from the person verifying, not from the token, which is the correct habit and the subject of a later section.
The run below changes the sub claim from user-4821 to admin and keeps the original signature. The decoder happily shows admin. The verifier recomputes the HMAC over the new input, gets a different value, and rejects it. A wrong secret fails the same way.
original token, correct secret -> valid: true
original token, wrong secret -> valid: false
sub changed to "admin", same sig -> decoder shows: admin
verifier: valid: falseHS256, RS256 and ES256: who can verify, and who can forge
HS256 is HMAC with SHA-256 (RFC 7518, where it is the one signature algorithm implementations must support). It is symmetric: the same secret signs and verifies. Any service able to verify tokens can also mint them, so the secret has to be shared carefully, and RFC 7518 requires a key at least as long as the hash output, 256 bits for HS256. Human-memorable secrets fall to offline guessing once an attacker has one token, as RFC 8725 warns.
RS256 (RSASSA-PKCS1-v1_5 with SHA-256) and ES256 (ECDSA on the P-256 curve with SHA-256) are asymmetric. The issuer signs with a private key; verifiers need only the public key, so an API gateway or a third-party service can check tokens without being able to create them. RFC 7519 recommends implementing both. ES256 signatures and keys are considerably smaller than RSA ones, which keeps tokens shorter.
The decision rule is about who verifies. If a single service both issues and checks its own tokens, HS256 with a long random secret is simple and adequate. If tokens cross a trust boundary or several teams' services verify them, use an asymmetric algorithm so that verifying does not confer the power to forge. The JWT generator on this site signs with HS256, HS384 and HS512 only, so it cannot produce RS256 or ES256 tokens.
alg none and algorithm confusion
The alg header tells the verifier which algorithm to use, which invites a mistake: believing it. RFC 8725, the JWT best-current-practices document, describes two attacks that follow. In the first, the attacker sets alg to none, an unsecured JWT with an empty signature, and libraries that trust the header accept the token without checking any signature. In the second, a token issued with RS256 is rewritten to say HS256, and a library that trusts the header validates it as an HMAC using the RSA public key, which the attacker knows, as the shared secret.
The defense in RFC 8725 is that libraries must let the caller specify the acceptable algorithms, must not use others, and each key must be bound to exactly one algorithm. In practice: pin the algorithm in your verification call instead of reading it from the token, and do not accept none unless the caller explicitly opted in. The run below shows this site's verifier applying that rule to a none-algorithm token whose payload is otherwise the same.
header : {"alg":"none","typ":"JWT"}
token : <base64url of that header>.<same payload segment>.
decoder : parses it fine (three segments, valid JSON)
verifier: valid: false
Token header declares "none" but you selected HS256.
Verifying with a different algorithm than the token claims is unsafe.Expiry, clock skew, and what not to put inside
Verifiers compare exp and nbf to their own clock, and two machines rarely agree to the second. RFC 7519 allows a small leeway for skew, usually no more than a few minutes. The example token expires at 1790000900. Checked 50 seconds later with no leeway it fails as expired; with a 60-second leeway it passes. Keep the leeway small: it stretches every token's lifetime by the same amount.
Because payloads are readable, keep secrets, passwords and unneeded personal data out of them. RFC 8725 also cautions against trusting received claims blindly, including header values such as kid or jku that select a key or a URL to fetch, since these have caused injection and server-side request forgery bugs. Use short lifetimes, since a stolen token works until it expires.
leeway 0 s : exp failed "Expired 50s ago."
nbf ok "Token is already active."
iat ok "Issued 15m 50s ago."
leeway 60 s : exp ok "Not expired - valid for another 50s."JWTs versus server-side session cookies
A session cookie holds an opaque random identifier, and the server looks up the real state in a store on each request. A self-contained JWT carries that state itself and is checked by signature alone. Neither is inherently more secure; they trade different things.
The JWT advantage is that verifying needs no database read, and with asymmetric keys many services can verify independently. The cost is revocation: a valid signed token stays valid until exp, so logging a user out or banning them immediately requires a denylist keyed on jti, which brings back the server-side state the token was meant to avoid. Tokens are also larger than a session ID and are sent with every request.
The session-cookie advantage is that deleting the server-side record ends the session at once, and the cookie reveals nothing if read. The cost is the lookup and, for multiple servers, a shared store. Where the token is kept matters as much as its format: an HttpOnly cookie is unreadable to page scripts, while a token in script-accessible storage is exposed to any cross-site scripting bug. A JWT can itself be delivered in an HttpOnly cookie. For a single web application with its own login, a session cookie is usually the simpler choice; for delegated access between services, JWTs fit naturally.
To check your own tokens, paste one into the JWT decoder to read the claims, run the signature and time checks in the verifier, build test tokens in the generator, and reproduce a signature by hand with the HMAC generator, using the signing input as the message.