Skip to content
devtoolbox.cc

What is a JWT? Structure, signatures and common mistakes

· 7 min read

Open the JWT Debugger tool

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.
  • HttpOnly cookie: not readable by JavaScript, sent automatically. Needs Secure, a sensible SameSite setting 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 HttpOnly cookie).

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:

  1. Does it have three parts, and do the header and payload decode to JSON?
  2. Is alg the one the server expects?
  3. Is the key right? For RS/ES tokens, does the kid match a key in the JWKS?
  4. Are exp and nbf in seconds, and is the current time between them (allowing for skew)?
  5. Do aud and iss match 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.

← All posts