Quick Answer

A JSON Web Token is a string with three base64url parts — header, payload and signature — that a server signs with a secret. Because the signature proves the payload has not been altered, the server can trust the token without storing session state. Critically the payload is only encoded, not encrypted, so never put secrets in it. The most dangerous mistake is using jwt.decode, which does not check the signature at all, in place of jwt.verify.

What a JWT Actually Is

A JWT is three base64url-encoded sections joined by dots.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9      <- header
.eyJzdWIiOiIxMjMiLCJyb2xlIjoidXNlciJ9    <- payload
.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1     <- signature

The header says which algorithm signed it. The payload holds claims — typically a user id, a role and an expiry. The signature is the header and payload hashed together with a secret only the server knows.

The point is that the server can verify a token without looking anything up. Recompute the signature from the received header and payload; if it matches, the contents were not tampered with. That is what makes JWTs stateless — no session table, no lookup, which is why they suit APIs spread across several servers.

Some claim names are standard and worth using: sub for the subject, exp for expiry, iat for issued-at, iss for issuer. Libraries check exp automatically, which is a good reason not to invent your own field for it.

Signed, Not Encrypted

This is the misunderstanding that causes real data leaks. A standard JWT is not encrypted. Base64 is encoding, not encryption. Anyone holding the token can read the payload with no key at all.

// The payload of any JWT, in one line, no secret required:
JSON.parse(atob(token.split('.')[1]))
// { sub: "123", role: "user", exp: 1767225600 }

Paste a token into jwt.io and it shows you everything. So the payload must never contain a password, a card number, an Aadhaar number or anything else you would not print on a postcard.

What the signature does guarantee is integrity. A user can read "role": "user", and can certainly edit it to "role": "admin" — but they cannot produce a matching signature without the secret, so the server rejects the modified token.

Keep tokens small for a practical reason too: they are sent on every request, usually in a header. Stuffing a full user profile into the payload adds that weight to every single call.

The Bug That Bypasses Authentication Entirely

Most JWT libraries expose two similar-looking functions, and choosing the wrong one removes all security while appearing to work perfectly.

// WRONG — reads the payload without checking the signature at all
const user = jwt.decode(token);

// RIGHT — verifies the signature and the expiry, throws if invalid
const user = jwt.verify(token, process.env.JWT_SECRET);

decode just base64-decodes the middle section. It does not consult the secret. An attacker can hand-craft a token claiming "role": "admin", put any nonsense in the signature slot, and decode will happily return it.

The reason this bug survives code review is that it works flawlessly in testing. Real tokens decode correctly, the app behaves normally, and nothing fails until someone tries a forged token.

Two related traps. Never accept the algorithm from the token's own header — an attacker can set it to none and some older libraries then skip verification entirely, so always pass the expected algorithm explicitly. And never write process.env.JWT_SECRET || 'dev-secret', because the day the environment variable is missing in production, every attacker who has read your public repository knows the fallback.

Where to Store the Token in a Browser

There is no perfect answer, which is why this argument never ends. Understand the trade rather than looking for a winner.

localStorage is easy and survives refreshes, but it is readable by any JavaScript on the page. If an attacker lands a cross-site scripting payload — via a dependency, an ad, or unescaped user content — they can read the token and send it elsewhere. XSS beats localStorage completely.

httpOnly cookies cannot be read by JavaScript at all, which removes that risk. The trade is that browsers attach cookies automatically, which opens cross-site request forgery: another site can cause a request that carries your cookie. The mitigation is SameSite=Strict or Lax, plus Secure so it only travels over HTTPS.

res.cookie('token', token, {
  httpOnly: true,     // JavaScript cannot read it
  secure: true,       // HTTPS only
  sameSite: 'strict', // not sent on cross-site requests
  maxAge: 15 * 60 * 1000,
});

The common recommendation is an httpOnly cookie with SameSite, because CSRF has well-understood defences whereas XSS stealing a token has none. Storing a token in a plain readable cookie is the worst option — it combines the weaknesses of both.

The Revocation Problem

Statelessness is the main selling point and the main weakness. Because the server stores nothing, it cannot cancel a token. If a token is stolen, or a user is banned, or someone logs out, that token stays valid until it expires.

"Log out" in a JWT system usually means the client deletes its copy. Anyone who captured it can keep using it.

The standard mitigation is two tokens. A short-lived access token, valid for perhaps fifteen minutes, is sent with every request. A long-lived refresh token, stored in an httpOnly cookie and recorded server-side, exchanges for a new access token when it expires. Because refresh tokens are stored, they can be revoked — so the damage window for a stolen access token is minutes rather than days.

Some systems keep a denylist of revoked token ids, checked on each request. That works, but note it reintroduces the database lookup JWTs were supposed to avoid — at which point plain server-side sessions may simply be the better fit.

That is the honest summary: JWTs are excellent for short-lived, stateless authorisation across services. If you need instant revocation and precise session control, traditional sessions are not the outdated option people assume.

Frequently Asked Questions

Is a JWT encrypted? No. A standard JWT is signed, not encrypted, and the payload is only base64url encoded. Anyone holding the token can read its contents without any key. The signature prevents tampering, not reading, so never put sensitive data in the payload.
What is the difference between jwt.decode and jwt.verify? decode simply base64-decodes the payload without checking the signature, so a forged token passes. verify recomputes the signature with your secret and checks the expiry, throwing if either fails. Using decode for authentication is a complete bypass.
Should I store JWTs in localStorage or cookies? An httpOnly cookie with SameSite and Secure is generally safer. localStorage is readable by any JavaScript, so a single XSS flaw leaks the token. Cookies are vulnerable to CSRF instead, but that has well-established defences.
How do I log a user out with JWTs? You cannot truly invalidate a stateless token, so the client deletes its copy while any stolen copy remains valid until expiry. The practical answer is short-lived access tokens plus a stored refresh token that can be revoked server-side.
Are JWTs better than sessions? Not universally. JWTs suit stateless, distributed APIs where avoiding a session lookup matters. Sessions suit applications needing instant revocation and fine-grained control. If you add a denylist to make JWTs revocable, you have reintroduced the lookup sessions already provided.