JWT Security: How to Read a Token and Catch the Red Flags
A JWT is signed, not encrypted · anyone holding one can read it. Here is how to decode a token by hand and the exact red flags to catch in your app's auth.
By Daniel A · Kraftwire Software
· 9 min readKey takeaway
A JWT is not encrypted. It is signed. Anyone who holds one of your tokens can read every field inside it in about ten seconds, so the security of a JWT comes entirely from how it is signed and verified, not from the fact that it looks like random gibberish. If your vibe-coded app puts sensitive data in the payload, accepts unsigned tokens, or never checks expiry on the server, you have a real hole. This post shows you how to read a token by hand and the exact red flags to catch.
What a JWT actually is
A JSON Web Token (defined in RFC 7519) is the little string your auth system hands out after login and your app sends back on every request to prove who you are. It has three parts separated by dots:
header.payload.signature
Each of the first two parts is a JSON object that has been **base64url-encoded**. That last word is the one thing most people get wrong. Base64url is an encoding, not encryption. It exists to make JSON safe to put in a URL or an HTTP header, and it is trivially reversible by anyone. There is no key involved. If you can copy the token, you can read the header and payload.
The third part, the signature, is the only piece that provides security. It is a cryptographic stamp over the header and payload. A server that knows the secret (or public key) can recompute that stamp and confirm the token has not been tampered with. That is the whole game: base64url makes it readable, the signature makes it trustworthy, and your job is to make sure the signature is actually checked.
How to read a token in ten seconds
Grab any JWT, for example from your browser dev tools under Application then Local Storage or Cookies. Split it on the dots. Take the first chunk and base64url-decode it. You will see something like:
{ "alg": "HS256", "typ": "JWT" }
That is the header. It tells you which algorithm signed the token. Now decode the second chunk:
{
"sub": "user_123",
"email": "user@example.com",
"role": "admin",
"exp": 1751500000
}
That is the payload, also called the claims. The third chunk is the signature and will look like random characters. Do not try to decode it. It is raw bytes, not JSON.
You do not have to do this in a terminal. Our free [JWT Debugger](/tools/jwt-debugger) decodes all three parts entirely in your browser (nothing is sent to a server) and highlights the problems described below. Paste a token from your own app and read what your users can already read.
The red flags
Once you can see inside a token, here is what to look for. Every one of these is a real mistake that shows up in AI-generated auth code.
alg is "none" or the token is unsigned
The JWT spec allows an algorithm value of `none`, which means the token carries no signature at all. It exists for narrow internal use cases, but if your server accepts a token whose header says `"alg": "none"`, an attacker can forge any payload they like. They set `"role": "admin"`, drop the signature, and walk in. Your verification library should reject `none` outright. If you decode a token and see it, or if flipping the header to `none` still gets you past auth, that is a critical bug.
Algorithm confusion (HS256 vs RS256)
There are two common families of signing algorithm. HS256 is symmetric: the same secret signs and verifies. RS256 is asymmetric: a private key signs, and a public key verifies. The classic attack is algorithm confusion. Your app is built for RS256, so its public key is, by design, public. An attacker takes that public key, signs a forged token using it as an HS256 secret, and sends a header that says `HS256`. A naive verifier that trusts the header's algorithm will use the public key as an HMAC secret and happily validate the forgery. The fix is to pin the expected algorithm on the server instead of reading it from the token you are trying to trust.
Sensitive data in the payload
Because the payload is only base64url, it is readable by anyone who holds the token. So never put anything secret in it. That means no passwords, no full credit card numbers, no API keys, no security answers, and ideally no more personal data than you strictly need. Treat the payload as a postcard, not a sealed envelope. A user ID and a role are fine. A user's home address and phone number sitting in a token stored in the browser is a leak waiting to happen. This is the same class of exposure behind vibe-coded incidents like CVE-2025-48757, where data that should have been protected was reachable by anyone who looked.
Missing or far-future exp
The `exp` claim is the expiry timestamp, in Unix seconds. If it is missing, the token may be valid forever, which means a token stolen once is a permanent key. If it is set to something absurd like ten years out, same problem. Access tokens should be short-lived (minutes to an hour is typical) and paired with a refresh mechanism. Decode a token, convert the `exp` number to a date, and ask whether you would be comfortable with that token working that long if it leaked.
Weak HMAC secrets
If you use HS256, the entire security of your tokens rests on one shared secret. If that secret is short, a dictionary word, or a value like `secret` or `changeme` copied from a tutorial, it can be brute-forced offline, after which anyone can mint valid tokens for any user. Use a long, random secret (32 bytes or more), store it as an environment variable, and never commit it. If you are unsure whether your secrets are safe, see [stop leaking secrets to production](/blog/environment-variables-security).
Storing tokens in localStorage
Where you keep the token in the browser matters. `localStorage` is readable by any JavaScript running on your page, which means a single cross-site scripting bug hands your users' tokens to an attacker. An **httpOnly** cookie cannot be read by JavaScript at all, which removes that entire attack path. For most apps, httpOnly cookies with the `Secure` and `SameSite` attributes are the safer default. If you must use `localStorage`, keep tokens short-lived so a stolen one expires quickly.
The server never validates the claims
This is the quiet one. Decoding a token is not the same as verifying it. Your backend must, on every protected request, check the signature, confirm `exp` has not passed, and where relevant confirm `aud` (the audience the token was issued for) and `iss` (the issuer that minted it). Vibe-coded backends often decode the payload to read the user ID but skip re-verifying the signature and expiry, trusting whatever the client sends. If the check is not on the server, it does not exist. Token validation belongs alongside your other server-side controls, which is exactly what we cover in [API security best practices](/blog/api-security-best-practices).
Your fix checklist
Reject `alg: none` and pin the expected algorithm on the server instead of trusting the header.
Verify the signature on every protected request, not just the payload decode.
Check `exp` server-side, and validate `aud` and `iss` when your provider sets them.
Keep access tokens short-lived and use refresh tokens for longevity.
Put only non-sensitive claims (user ID, role) in the payload · nothing you would not print on a postcard.
Use a long, random HMAC secret from an environment variable, never a tutorial default.
Prefer httpOnly, Secure, SameSite cookies over `localStorage` for token storage.
Wrapping up
JWTs are not scary once you can read one, and reading one is the fastest way to catch the mistakes that matter. Decode a token from your own app right now and check it against the list above. Start with our free [JWT Debugger](/tools/jwt-debugger), or browse the rest of our [free tools](/tools) for more quick checks.
When you are ready to see the whole picture · tokens, RLS, exposed endpoints, leaked secrets, and speed · run a full scan of your app at [simplyscan.io](https://simplyscan.io). It finds the red flags before your users do.