A JSON Web Token looks like random noise, but it is three base64url-encoded strings joined by dots. Two of them are plain JSON you can read in a second, and the third is a signature you cannot read at all.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0In0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
header payload signature
Decoding one by hand
Split on ., then base64url-decode each of the first two parts.
echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | base64 -d
# {"alg":"HS256","typ":"JWT"}
Base64url is not the same as base64: + becomes -, / becomes _, and the trailing = padding is stripped. If base64 -d complains about invalid input, that is why - add padding back until the length is a multiple of four.
In JavaScript:
const decode = (segment) => {
const padded = segment.replace(/-/g, "+").replace(/_/g, "/");
const json = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
return JSON.parse(json);
};
Note that atob returns a binary string, so tokens containing non-ASCII characters need a TextDecoder pass to come out correctly.
The header
Two fields matter.
alg - the signing algorithm. HS256 is symmetric (one shared secret), RS256 and ES256 are asymmetric (private key signs, public key verifies). If you see alg: "none", treat the token as hostile: it means the token claims to need no signature, and any library that honours it is exploitable.
kid - key ID. Present when the issuer rotates keys, telling the verifier which public key from the JWKS endpoint to use.
The registered claims
Seven claims are defined by RFC 7519. All are optional, all are conventional, and getting them wrong is the source of most JWT bugs.
| Claim | Name | Meaning |
|---|---|---|
iss | Issuer | Who minted the token |
sub | Subject | Who the token is about - usually a user ID |
aud | Audience | Who the token is for; verifiers must reject tokens not addressed to them |
exp | Expiration | Seconds since epoch after which the token is invalid |
nbf | Not before | Seconds since epoch before which the token is invalid |
iat | Issued at | When it was minted |
jti | JWT ID | Unique identifier, used to prevent replay |
All three timestamps are NumericDate - seconds since the Unix epoch, not milliseconds. This is the single most common JWT bug in JavaScript codebases, because Date.now() returns milliseconds. A token with exp set from Date.now() is valid until the year 56,000.
const exp = Math.floor(Date.now() / 1000) + 3600; // correct: one hour
Decoding is not verifying
Reading the payload tells you what the token claims. It tells you nothing about whether those claims are true.
Anyone can take a valid token, change "role":"user" to "role":"admin", re-encode the payload, and hand it to you. The payload will decode perfectly. Only checking the signature against the key catches it.
So: never make an authorisation decision from a decoded payload. Verify first, then read. And never paste a token containing a real session into a website that offers to verify it, because verification requires the secret, and no legitimate tool asks for your signing key.
A JWT is signed, not encrypted. Anyone holding the token can read every claim in it. Do not put anything in a payload that you would not put in a URL.
The mistakes worth knowing
Trusting alg from the token. The classic attack: attacker changes alg from RS256 to HS256 and signs with the public key as the HMAC secret. Always pin the expected algorithm in your verifier rather than reading it from the header.
Skipping aud. If two of your services accept tokens from the same issuer and neither checks aud, a token minted for one is valid at the other.
Long expiry with no revocation. A JWT is valid until it expires, and there is no way to un-issue one. If you need instant revocation, you need a session store - a stateless token cannot give you that. Short exp plus a refresh token is the usual compromise.
Storing tokens in localStorage. Readable by any script on the page, which makes any XSS a total account compromise. An httpOnly cookie is the safer default.
Oversized tokens. Every claim you add ships on every request. Past about 4KB you start hitting header size limits in proxies and CDNs.
When not to use a JWT
If both sides are your own server and you already have a database, a random session ID in an httpOnly cookie is simpler, revocable instantly, and smaller. JWTs earn their complexity when the verifier cannot reach your database - third-party services, other teams' APIs, edge functions.
Launch it where the numbers are checked
RankCert ranks products on domain control we verify ourselves. Listing is free and the link stays dofollow whether or not you display the badge.
