What is a JWT? Structure, signatures and common mistakes
· 7 min read
A JWT looks like random noise: three long strings of letters separated by dots. That opacity is misleading. Anyone can read what’s inside a JWT, and most JWT vulnerabilities come from treating it as either more secret or more trustworthy than it is.
This article takes one token apart piece by piece, explains how signatures work, and lists the mistakes that keep turning up in security reviews. Paste any token into the JWT debugger to follow along. It decodes and verifies tokens in the browser, so the token never leaves your machine.
A token, taken apart
This is the example token jwt.io has shown on its front page for years:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
(Line breaks added for readability; a real token is one line.) It has three parts, each encoded with base64url:
| Part | Decoded |
|---|---|
| Header | {"alg":"HS256","typ":"JWT"} |
| Payload | {"sub":"1234567890","name":"John Doe","iat":1516239022} |
| Signature | 32 bytes of HMAC-SHA256 output |
Decoding needs no key. Run the first two parts through any Base64 decoder and you get plain JSON. That’s the most important fact about JWTs: the payload is encoded, not encrypted. Never put anything in it you wouldn’t show the user who holds the token.
(There is an encrypted variant, JWE, with five parts instead of three. Almost all tokens you see in practice are the signed three-part kind, JWS.)
The payload: registered claims
The payload is a JSON object of “claims”. RFC 7519 reserves a few short names:
| Claim | Meaning | Example value |
|---|---|---|
iss |
Issuer: who created the token | "https://auth.example.com" |
sub |
Subject: who the token is about | "1234567890" |
aud |
Audience: who it’s meant for | "api.example.com" |
exp |
Expiration time | 1893456000 |
nbf |
Not valid before | 1516239022 |
iat |
Issued at | 1516239022 |
jti |
Unique token id | "a1b2c3" |
exp, nbf and iat are Unix timestamps in seconds, not milliseconds. 1516239022 is 2018-01-18 01:30:22 UTC. Putting Date.now() (milliseconds) into exp is a classic bug: the token then “expires” tens of thousands of years from now. The Unix timestamp converter catches that at a glance, and the JWT debugger shows every time claim as a readable date with “expired 3 hours ago” or “valid for 2 days”.
How the signature works
The signature covers exactly the first two parts, as they appear in the token:
signature = HMAC-SHA256(secret, base64url(header) + "." + base64url(payload))
Change a single character of the header or payload and the signature no longer matches. That’s the whole security model: a JWT doesn’t hide data, it proves the data hasn’t been changed since someone with the key signed it.
For the example token, the secret is your-256-bit-secret. Enter it in the JWT debugger and it says “Signature verified”. Change one letter and it says “Invalid signature”.
Symmetric vs asymmetric algorithms
The alg header names the algorithm. The two families work very differently:
| Family | Algorithms | Signs with | Verifies with | Typical use |
|---|---|---|---|---|
| HMAC | HS256, HS384, HS512 | shared secret | the same secret | One service issues and checks its own tokens |
| RSA | RS256, RS384, RS512, PS256 | private key | public key | An identity provider issues, many APIs verify |
| Elliptic curve | ES256, ES384 | private key | public key | Same as RSA, with smaller keys and signatures |
With HS256, everyone who can verify a token can also create one, because they hold the same secret. That’s fine inside a single service and a real problem when several services verify tokens. With RS256 or ES256, the identity provider keeps the private key and publishes the public keys, usually as a JWKS at a URL like /.well-known/jwks.json. The kid header says which key signed the token.
For HS256, the secret should be at least 256 bits of random data (RFC 7518 requires a key at least as long as the hash output). A short word like secret can be brute-forced offline from any token you’ve issued.
The mistakes that keep happening
1. Accepting alg: none
The spec includes an “unsecured” algorithm, none, with an empty signature. Early libraries honoured whatever alg the token claimed, so an attacker could take a valid token, change the payload to "role": "admin", set alg to none, strip the signature, and be accepted. Modern libraries reject none by default, but the underlying rule still matters: the server decides which algorithms it accepts; the token doesn’t. Pass an explicit allow-list when you verify.
// jose
await jwtVerify(token, key, { algorithms: ['RS256'] });
The JWT debugger decodes alg: none tokens but flags them with a warning.
2. Algorithm confusion (RS256 → HS256)
A server expects RS256 and verifies with its public key. An attacker creates a token with alg: HS256 and signs it using that public key, which is public, as the HMAC secret. A library that picks the algorithm from the header then verifies the HMAC with the same key bytes and accepts the forgery. The fix is the same as above: pin the expected algorithm, and don’t let one key object serve both families.
3. Decoding instead of verifying
jwt.decode() in most libraries only parses the token. It doesn’t check the signature. Code that calls decode and then trusts payload.userId accepts any token anyone typed. Use the verify function on the server, every time.
4. Ignoring exp, aud and iss
A valid signature only proves who issued a token. The server still has to check that it hasn’t expired, that it was issued for this API (aud) and by the expected issuer (iss). Most libraries do this when you pass the expected values; some don’t check aud unless you ask. Allow a little clock skew (30–60 seconds) so servers with slightly different clocks don’t reject fresh tokens.
5. Long-lived tokens with no way to revoke them
A JWT is valid until it expires, even if the user logs out or their account is disabled, because the server doesn’t look anything up. Keep access tokens short-lived (minutes, not days) and use refresh tokens, which are checked against a database, to issue new ones. If you need instant revocation, you need some server-side state: a deny-list of jti values or a per-user “tokens issued before” timestamp.
Where to store tokens in a browser
There’s no perfect answer, only trade-offs:
localStorage: easy to use, readable by any JavaScript on the page. One XSS bug and the token is gone.HttpOnlycookie: not readable by JavaScript, sent automatically. NeedsSecure, a sensibleSameSitesetting and CSRF protection.- Memory only: safest against theft, but it’s lost on reload, so it has to be paired with a refresh flow (often a refresh token in an
HttpOnlycookie).
For most web apps, a short-lived access token in memory plus a refresh token in an HttpOnly, Secure, SameSite cookie is a reasonable default.
Debugging checklist
When a token is rejected, check in this order:
- Does it have three parts, and do the header and payload decode to JSON?
- Is
algthe one the server expects? - Is the key right? For RS/ES tokens, does the
kidmatch a key in the JWKS? - Are
expandnbfin seconds, and is the current time between them (allowing for skew)? - Do
audandissmatch exactly, including trailing slashes?
The JWT debugger answers the first four questions at once: it shows the decoded parts, the algorithm, human-readable times and whether the signature verifies with the secret or public key you supply. Keys and tokens stay in memory and are never saved or sent anywhere.