If you've worked on any API with token-based authentication, you've run into a JWT — usually as a long string starting with eyJ. Most developers know roughly what a JWT is for (proving who a request is from) without ever looking closely at what's actually inside one, or where the security guarantees really come from. That gap is where most JWT-related bugs live.
The Three Parts of a JWT
A JWT is three Base64URL-encoded segments joined by dots: header.payload.signature.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwibmFtZSI6IkFsaWNlIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
Header — identifies the signing algorithm and token type:
{ "alg": "HS256", "typ": "JWT" }
Payload — the claims. Some are standardized (sub for subject, exp for expiry, iat for issued-at), others are custom to your app:
{ "sub": "1234", "name": "Alice", "exp": 1735689600 }
Signature — computed over the header and payload, proving the token wasn't tampered with since it was issued.
It's easy to assume the payload is somehow protected, but it isn't — it's just Base64, not encryption. Anyone can decode a JWT and read the claims inside it; only the signature is cryptographically meaningful. If you need to inspect a token you've been handed, our JWT Decoder will split it into these three parts and show you the decoded header and payload directly in your browser, without sending the token anywhere.
Signing: HMAC vs RSA/ECDSA
This is where most of the real confusion happens, because "signing" means two very different things depending on the algorithm family.
Symmetric (HS256, HS384, HS512) — one secret key both signs and verifies the token. Whoever can verify a token can also forge one. This is fine when the signer and verifier are the same service, but dangerous the moment you share that secret with a third party — you've effectively given them the ability to mint tokens as if they were you.
Asymmetric (RS256, ES256) — a private key signs, a separate public key verifies. This is the right choice whenever multiple services need to verify tokens but only one service should be able to issue them (a classic single-sign-on setup). The public key can be distributed freely; only the issuer holds the private key.
A common real-world mistake: an API accepts a alg value from the token header and blindly uses whatever algorithm it names. Some early JWT libraries had a vulnerability where an attacker could set alg: "none" and strip the signature entirely, and the library would accept it as valid. Modern libraries have fixed this, but it's a good reminder — always pin the expected algorithm on the verifying side, never trust the alg field from an untrusted token.
Expiry Is Not Optional
A JWT with no exp claim is valid forever unless you build your own revocation system around it — which most teams don't. Because JWTs are stateless (the server doesn't store a session to invalidate), there's no built-in way to "log someone out" early. The two practical mitigations:
- Keep access token lifetimes short (5–15 minutes is common) and pair them with a longer-lived, revocable refresh token stored server-side.
- If you truly need to revoke a specific token before it expires, you need a denylist — which reintroduces the statefulness JWTs were meant to avoid. This is a legitimate tradeoff, not a JWT design flaw; understand it before you commit to JWTs for session management.
Where People Get Storage Wrong
Storing a JWT in localStorage is convenient and extremely common, but it's readable by any JavaScript running on the page — including an injected script from an XSS vulnerability. An httpOnly cookie can't be read by JavaScript at all, which closes that specific attack path, at the cost of needing CSRF protection instead. Neither option is universally "correct" — it depends on what your threat model actually is, but it's a decision worth making deliberately rather than defaulting to localStorage because it's the first thing that works.
Decoding vs Verifying
This is the single most important distinction to internalize: decoding a JWT tells you nothing about whether it's legitimate. Decoding just reverses the Base64 encoding — it works on any string shaped like a JWT, real or forged. Verification checks the signature against the expected key and algorithm, and only verification tells you the token can be trusted.
// Decoding — DOES NOT prove authenticity
const payload = JSON.parse(atob(token.split('.')[1]))
// Verifying (Node.js, using the `jsonwebtoken` package) — this is the check that matters
const jwt = require('jsonwebtoken')
const decoded = jwt.verify(token, secretOrPublicKey, { algorithms: ['HS256'] })
Any tool — including ours — that shows you a JWT's decoded contents is doing the first operation, not the second. Never treat a decoded payload as trusted input on the server side without running it through a real verification step first.
Summary
- A JWT is
header.payload.signature— only the signature is protected; the payload is plainly readable by anyone. - Symmetric algorithms (HS256) share one secret between signer and verifier; asymmetric algorithms (RS256) separate signing and verification keys — pick based on who needs to issue vs. verify tokens.
- Always pin the expected algorithm when verifying; never trust the
algfield from the token itself. - Short-lived access tokens plus a revocable refresh token is the standard mitigation for JWTs having no built-in revocation.
- Decoding is not verifying — use our JWT Decoder to inspect a token's contents, but always verify signatures server-side before trusting any claim in it.