JSON Web Tokens (JWT) Explained: Structure, Signing, and Common Mistakes
JSON Web Tokens look simple on the surface. Three Base64 strings joined by dots. You paste one into jwt.io, see a header and a payload, and assume you understand the thing. Then a security researcher tells you a token with alg: none walked straight past your verification code, your refresh tokens are sitting in localStorage where any cross-site script can grab them, and your "stateless" auth system is actually one leaked signing key away from a full account takeover. Here is what a JWT actually is, what each piece of it does, how the signing really works, and the mistakes that keep showing up in production code year after year.
Table of Contents
Introduction
If you have built any modern web app, you have probably handed a user a JWT. Sign in, server hands back a token, browser stores it, every subsequent request sends it in an Authorization: Bearer header. Easy. The format has become the default for API authentication because it slots neatly between mobile clients, single-page apps, and microservices that all need to pass identity around without sharing a session store.
The catch is that JWT is one of those technologies where the happy path is obvious and the failure modes are not. Most JWT bugs are not in the libraries themselves. They are in how developers configure them, store them, and trust them. This article is the conceptual walkthrough I wish more teams read before shipping a JWT-based auth system into production.
If you want to look at a real JWT while you read this, paste one into the JSON editor after Base64-decoding the parts, or grab a quick hash with the hash generator if you want to verify a signature by hand. Both run entirely in your browser, which matters a lot when you are working with secrets.
What is a JWT
A JSON Web Token, defined in RFC 7519, is a compact, URL-safe way to represent claims between two parties. "Claims" sounds abstract, so think of it as a small bundle of facts: who the user is, when the token was issued, when it expires, what they are allowed to do, and any custom data you want to attach. The whole bundle is signed by the issuer so the recipient can verify it has not been tampered with.
A serialized JWT looks like one long string with two dots in it. That string contains three Base64URL-encoded parts: header, payload, signature. The format is intentionally compact because it has to fit in HTTP headers, URLs, and mobile push notifications.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJSYWhtYXQiLCJpYXQiOjE3NDc0MDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c Three pieces separated by dots. Each piece is Base64URL-encoded (which is Base64 with + replaced by -, / replaced by _, and trailing padding stripped). If you want to peek at the parts, our Base64 decoder handles the first two — paste a part, add any missing = padding, and you get raw JSON back. The third part is a binary signature, not JSON.
The most important thing to understand up front: the payload is not encrypted. Base64 is encoding, not encryption. Anyone who intercepts a JWT can read the entire payload. The signature only guarantees the token has not been modified since the issuer created it. Putting a password, an API key, or personally identifiable information inside a JWT payload is one of the most common mistakes you can make.
The Three Parts of a JWT
1. The header
The header is a small JSON object that tells the verifier two things: what type of token this is and what algorithm was used to sign it. After Base64-decoding the first part of the example above, you get:
{
"alg": "HS256",
"typ": "JWT"
}alg is the signature algorithm. typ is almost always JWT. Some tokens also include a kid (key ID) field that tells the verifier which signing key to use when the issuer rotates keys regularly.
2. The payload
The payload is another JSON object. It contains the actual claims about the user or the session. RFC 7519 defines a set of "registered" claims that every JWT library understands, plus you can add any custom claims you want.
{
"sub": "12345",
"name": "Rahmat",
"iat": 1747400000,
"exp": 1747403600,
"iss": "https://stackconvert.com",
"aud": "stackconvert-api"
}The registered claims worth memorizing:
| Claim | Full Name | Purpose |
|---|---|---|
iss | Issuer | Who created the token (your auth server's URL). |
sub | Subject | Who the token is about (usually a user ID). |
aud | Audience | Who the token is intended for (your API's identifier). |
exp | Expiration | Unix timestamp after which the token is invalid. |
nbf | Not Before | Unix timestamp before which the token is not yet valid. |
iat | Issued At | Unix timestamp when the token was created. |
jti | JWT ID | Unique ID for the token (useful for revocation lists). |
The Unix timestamps in exp, nbf, and iat are seconds since the Unix epoch, not milliseconds. This catches people every day. If you put a millisecond timestamp in exp, your token will be valid for the next 55,000 years. If you want a refresher on how Unix time works, see our deep dive on Unix timestamps and epoch time.
3. The signature
The signature is the part that makes a JWT trustworthy. It is computed by joining the Base64URL-encoded header and payload with a dot, then signing that whole string with the algorithm specified in the header. Pseudo-code looks like this:
signingInput = base64url(header) + "." + base64url(payload)
signature = HMAC-SHA256(signingInput, secret)
jwt = signingInput + "." + base64url(signature)The signature is binary, so it gets Base64URL-encoded for transport. When a verifier receives the token, it recomputes the signature over the first two parts and checks that it matches the third part. If even a single byte of the header or payload has been changed, the recomputed signature will not match, and the token is rejected.
How Signing Actually Works
The alg field in the header decides everything about how the signature is produced and verified. There are three families you will see in practice, and they have very different properties.
HS256 — symmetric HMAC
HS256 stands for HMAC using SHA-256. It is a symmetric algorithm, meaning the same secret is used to both sign and verify. If you have ever wondered what an HMAC really is and how it differs from a plain hash, the short version is that HMAC wraps the hash function with a secret key so attackers cannot forge a valid hash without that key. The library does the hard work; you just feed it the signing input and the secret.
HS256 is simple and fast, which makes it great for monoliths where the same server signs and verifies. It is dangerous in any system where the verifier is not also the issuer, because anyone who can verify a token can also forge one. If your auth server hands HS256 tokens to ten different microservices and even one of them leaks the secret, your entire identity layer is compromised.
RS256 — asymmetric RSA
RS256 uses RSA with SHA-256. It is asymmetric: the issuer signs with a private key, and verifiers check the signature with a public key. The public key cannot be used to sign new tokens, so it is safe to publish.
This is the right choice for any system where the issuer and the verifier are separate. Auth0, Okta, AWS Cognito, Google, and basically every OIDC provider hand out RS256 tokens. They publish their public keys at a well-known URL (the JWKS endpoint), and your API fetches that JWKS, caches it, and uses it to verify every incoming token. Rotation works because the JWKS can list multiple keys at once, and the kid in each token's header tells the verifier which one to use.
ES256 — elliptic curve
ES256 uses ECDSA on the P-256 curve with SHA-256. It is also asymmetric, like RS256, but the keys and signatures are much smaller. A P-256 signature is around 64 bytes, while an RSA-2048 signature is 256 bytes. For mobile or IoT, where every byte over the wire matters, ES256 is the smarter pick.
The downside is library and tooling maturity. RS256 is everywhere, ES256 is supported in most modern libraries but not all of them. If you are starting a greenfield system today and your stack supports it, ES256 is the modern choice. Otherwise RS256 is the safe default.
| Algorithm | Type | Signature Size | Best For |
|---|---|---|---|
| HS256 | Symmetric (HMAC) | 32 bytes | Single trust domain, monoliths |
| RS256 | Asymmetric (RSA) | 256 bytes | Federated identity, OIDC providers |
| ES256 | Asymmetric (ECDSA) | 64 bytes | Mobile, IoT, bandwidth-sensitive APIs |
If you want to go deeper on the hash families that back these algorithms, our breakdown of MD5 vs SHA-256 vs SHA-512 explains why SHA-256 sits at the heart of the modern web's signing infrastructure.
Verifying a JWT by Hand
Doing this once on paper makes everything click. Take the example token from earlier. The signing input is the first two parts joined by a dot:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJSYWhtYXQiLCJpYXQiOjE3NDc0MDAwMDB9 That's a 162-byte string. To verify, you compute HMAC-SHA256(signingInput, secret), Base64URL-encode the result, and check that it matches the third part of the token: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c.
In Node:
import { createHmac } from 'node:crypto'
const signingInput = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJSYWhtYXQiLCJpYXQiOjE3NDc0MDAwMDB9'
const secret = 'your-256-bit-secret'
const sig = createHmac('sha256', secret)
.update(signingInput)
.digest('base64url')
console.log(sig === 'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') That is the whole verification, conceptually. Real libraries add a few more checks on top: that the alg in the header matches what you actually allow, that exp has not passed, that iss and aud match your expected values. Those checks matter more than the cryptographic verification itself, because that is where most JWT bugs live.
Never write your own JWT verification in production code. Use a well-maintained library like jsonwebtoken, jose, php-jwt, pyjwt, or the platform's official SDK. The libraries have absorbed years of security advisories. Your hand-rolled version has not.
JWT vs Session Cookies
This is the question every team asks at some point: should we use JWT or a traditional session cookie backed by a server-side session store? The answer is almost always less exciting than the JWT marketing suggests. A signed session cookie backed by Redis is fine for 90% of web applications. JWTs solve a specific set of problems that most apps do not actually have.
| Property | Session Cookie | JWT |
|---|---|---|
| State | Server-side store (Redis, DB) | Self-contained in the token |
| Revocation | Instant — delete from store | Hard — need a revocation list |
| Cross-domain use | Awkward (CORS, SameSite) | Easy (Authorization header) |
| Mobile app friendly | Less natural | Very natural |
| XSS exposure | Low (HttpOnly cookies) | High if stored in localStorage |
| CSRF exposure | Needs explicit defense | Low (no automatic sending) |
| Server load per request | One store lookup | Just a signature check |
The single best argument for JWT is federated identity. If you have an OIDC provider issuing tokens that ten different services need to verify without phoning home to the issuer for every request, JWTs are the only sensible choice. The single best argument against JWT for a typical SaaS app is that revocation is genuinely hard, and "log me out everywhere right now" is a feature your users will eventually demand.
Six Dangerous JWT Mistakes
1. Trusting the alg header
Every JWT specifies its own signing algorithm in the header. Naive libraries used to read alg from the token and use that algorithm to verify. The attack: send a token with alg: none, drop the signature entirely, and watch it sail through verification. Or send a token signed with HMAC using the server's public RSA key as the secret — older libraries would happily verify it.
Always pass an explicit allowlist of accepted algorithms to your verifier. Never read alg from the token to decide how to verify. Every modern library has a parameter for this; use it.
2. Storing tokens in localStorage
localStorage is reachable by any JavaScript on the page. One XSS vulnerability anywhere in your app — a vulnerable npm dependency, a sloppy innerHTML, a third-party widget — and an attacker can read every JWT and ship them off to their server. The token then works from anywhere until it expires.
Store access tokens in memory (a JavaScript variable that vanishes on reload), and put refresh tokens in HttpOnly, Secure, SameSite=Strict cookies so JavaScript cannot touch them. Yes, this is more work. It is also the difference between a contained XSS bug and an account takeover.
3. Missing or trusted exp
Every JWT must include an exp claim, and your verifier must actually check it. "We trust the issuer" is not the right mindset — the issuer is also the one that might be compromised. Keep access token lifetimes short (5 to 15 minutes is typical), and use a refresh token flow to issue new ones.
4. Skipping iss and aud validation
If your API accepts any token signed by your auth server, it will also accept tokens that were issued for a completely different service inside the same auth domain. An attacker who can get a valid token for one service can replay it against another.
Always validate that iss matches the auth server you trust, and that aud matches your API's identifier. These checks are cheap, the libraries make them one-liners, and they shut down an entire class of attacks.
5. Putting secrets in the payload
The JWT payload is not encrypted. Putting an API key, a password, a session ID for another system, or sensitive PII in there is the same as sending it in plaintext. Anyone who intercepts the token — and tokens are sent on every request, often through middleboxes and logs — gets the data.
Keep the payload minimal: a user ID, a few role flags, the standard claims. Look up everything else server-side using the sub claim as the lookup key.
6. Using HS256 with a weak secret
HMAC's security rests entirely on the secret being unguessable. A secret like "my-app-secret" can be brute-forced offline against any single token your app issues. The attacker captures one token, runs a cracker, recovers the secret, and now they can forge tokens for any user.
If you use HS256, generate the secret from a cryptographic RNG, make it at least 256 bits, and store it in a secret manager (AWS Secrets Manager, HashiCorp Vault, Doppler, GCP Secret Manager). If you cannot guarantee that, switch to RS256 or ES256 so verification only needs the public key.
Where JWT Actually Shines
Despite all those footguns, JWTs are genuinely the right tool for several common scenarios:
- Single sign-on across multiple apps. Your auth provider issues one token, every downstream service verifies it locally. No central session store, no round trip per request.
- Mobile and SPA APIs. Cookies are awkward across origins; bearer tokens in headers are clean.
- Service-to-service auth inside a mesh. Each service hands the next a short-lived token representing the user, signed by a service the receiver already trusts.
- Short-lived signed URLs and email confirmation links. A JWT with a 15-minute expiration and a single-purpose claim makes a great "click here to confirm your email" or "click here to reset your password" link.
- Webhooks and callbacks. If your service calls back to a customer's endpoint, signing the request body as a JWT lets them verify it came from you without sharing a secret.
For a server-rendered web app with a single backend, a Redis-backed session is simpler, easier to revoke, and easier to debug. Pick the tool that matches your topology, not the one with the most blog posts about it.
Alternatives Worth Knowing
JWT is not the only token format. A few alternatives worth being aware of:
- PASETO (Platform-Agnostic Security Tokens). A modern alternative designed by Scott Arciszewski as a direct response to JWT's footguns. PASETO has versioned, hard-coded algorithm choices, so there is no
alg: noneattack and no algorithm confusion. If you are starting a new system today and your stack has a good PASETO library, it is worth a serious look. - Branca. A simpler IETF-style token format that is always encrypted and authenticated. Easier to get right than JWT, less feature-rich.
- Macaroons. A research-grade token format with built-in support for delegation and caveats ("this token works, but only for these resources, only from this IP, only for the next five minutes"). Used internally at Google and by some Hashicorp products.
- Plain signed session cookies. Still the right answer for most web apps. If you have not actively needed JWT's properties, you probably do not need JWT.
Common Questions
Are JWTs encrypted?
No. Standard JWTs (JWS) are signed but not encrypted. Anyone who sees the token can read the payload. If you need the payload to be unreadable, use JWE (JSON Web Encryption), which is a different RFC and a different format. Most "JWT" usage in the wild is actually JWS.
How long should a JWT live?
Access tokens should be short — 5 to 15 minutes is the standard range. Refresh tokens can live longer, often 30 to 90 days, but they should be one-time use and rotated on every refresh. Long-lived access tokens are how stolen tokens turn into ongoing breaches.
Can I revoke a JWT before it expires?
Not natively. The whole point of a JWT is that you can verify it without contacting a server. To revoke one, you either keep a deny-list of revoked jti values (which throws away the stateless property), shorten expiration times so revocation happens automatically, or store a "tokens issued before X are invalid" timestamp per user.
What is the maximum size of a JWT?
There is no hard limit, but in practice you want to stay under 8 KB because that is a common HTTP header size limit, and well under 4 KB because that is the maximum cookie size. If your JWT is approaching those numbers, you are putting too much in the payload.
Do I need HTTPS if I am using JWT?
Absolutely yes. A JWT in plain HTTP traffic can be intercepted and replayed by anyone on the network. The signature only proves the token is genuine, not that it is being used by the legitimate owner. HTTPS is non-negotiable.
Why does my JWT library reject a token that "works" in jwt.io?
jwt.io decodes and verifies tokens but does not enforce exp, nbf, iss, or aud against your specific configuration. Your library is doing more checks. The token's signature is probably fine but one of the claim validations is failing. Check the error message — it will name the specific claim.
Wrapping Up
JWTs are a useful tool with sharp edges. The format itself is straightforward — three Base64URL strings, joined by dots, signed with a well-defined algorithm. The complexity is not in the spec; it is in the operational decisions around it. Pick the right algorithm for your trust topology, store the tokens carefully, validate every claim that matters, and use a real library that enforces the algorithm allowlist. Do those four things and JWT is a solid foundation. Skip any one and you are building on sand.
If you found this useful, you might also want to read our deep dive on bcrypt password hashing, the breakdown of MD5 vs SHA-256 vs SHA-512, or the Base64 encoding guide. And if you want to poke at a real JWT right now, paste its payload into the JSON editor — it runs in your browser, so no token ever leaves your machine.