Is JWT Secure? 7 Mistakes That Break It
Yes, a JWT can be secure, but only if you set it up correctly, because JWT security problems usually come from configuration mistakes and not from the format itself.
This guide is for developers and site owners who use JSON Web Tokens (JWTs, said "jots") to keep people logged in. You will get seven common mistakes, a quick way to spot each one in your own code, and a short fix for each. Plan on about 20 minutes to review your setup, using the checklist below.
What a JWT is, in plain English
A JWT is a small piece of text your server hands to a user after login. It has three parts separated by dots: a header, a payload (the data, such as a user ID), and a signature. The signature proves your server made the token and that nobody changed it.
Two facts drive everything below. First, the payload is only encoded, not encrypted, so anyone holding the token can read it. Second, your server must check the signature correctly every single time. Most of the mistakes below break one of those two ideas.
The 7 mistakes at a glance
Use this table as a quick review. The details follow.
| # | Mistake | What an attacker can do | Quick fix |
|---|---|---|---|
| 1 | Weak signing secret | Guess it offline, then forge tokens | Use 32+ random bytes |
| 2 | Leaked secret | Forge tokens for any user | Keep it out of code and git, rotate it |
| 3 | The none algorithm or unpinned algorithm | Send an unsigned or confused token | Pin the allowed algorithm |
| 4 | Skipped signature check | Edit the payload freely | Use verify, never just decode |
| 5 | No expiry | Reuse a stolen token forever | Short exp plus refresh |
| 6 | Token in localStorage | Steal it with one XSS bug | HttpOnly cookie |
| 7 | Sensitive data in the payload | Read it without any key | Store only IDs and roles |
1. A weak or guessable secret key
Many apps sign tokens with the HS256 algorithm (HMAC with SHA-256). That uses one shared secret. If the secret is secret, password123 or the app name, an attacker can take any token they receive and guess the secret offline. Tools such as hashcat (mode 16500) do this quickly. No one at your server ever sees the attempts.
Once they know the secret, they can mint a token that says they are the admin.
Fix: use a long random value, not a word. The JWT algorithm spec (RFC 7518, section 3.2) says an HS256 key must be at least 256 bits (32 bytes). Generate one:
openssl rand -base64 32
The rule for a strong JWT secret is simple: make it random, make it long, and never reuse it between apps or environments.
2. A leaked secret
A strong secret does not help if it is public. Common leaks include:
- A
.envfile or config committed to a Git repository. - The secret hard-coded in frontend JavaScript or a mobile app.
- A
.envfile reachable from the web, such asyoursite.com/.env. - Secrets pasted into support tickets, chat or screenshots.
Fix:
- Load the secret from an environment variable or a secrets manager, never from source code.
- Search your Git history:
git log -p -S "JWT_SECRET" --all. A tool likegitleaks detectscans for many secret types. - If it ever leaked, rotate it now. Changing the secret logs everyone out, which is the right outcome.
- Test that
yoursite.com/.envandyoursite.com/.git/configboth return a 404 or 403 (never 200) and never show file contents. A single-page app may return 200 with its index page for unknown paths, so check that the response is not the real file contents.
3. The none algorithm and unpinned algorithms
The JWT header says which algorithm signed the token. The spec includes a value called none, meaning "no signature." If your server reads the algorithm from the token and obeys it, an attacker can send a header like {"alg":"none"}, change the payload and leave the signature empty. Some older libraries accepted this.
A related trick is algorithm confusion. If your server uses RS256 (a public and private key pair), an attacker may switch the header to HS256 and sign with your public key, which is not secret. A real example is CVE-2015-9235 in an old version of the jsonwebtoken Node library.
Fix: tell your library exactly which algorithm to accept. Never let the token choose.
// Node.js (jsonwebtoken)
jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
# Python (PyJWT)
jwt.decode(token, key, algorithms=['HS256'])
Also keep your JWT library up to date. RFC 8725, the JWT best current practices document, recommends this approach.
4. Skipped signature checks
This one is easy to do by accident. Many libraries have a function that only reads a token and a different one that verifies it.
- Node:
jwt.decode()reads the payload and does not check the signature.jwt.verify()does. - Python:
options={'verify_signature': False}turns checking off. It is handy for debugging and dangerous in production.
If a login route or API uses decode, anyone can write their own payload and your server will believe it.
Fix: search your codebase for decode(, verify_signature and ignoreExpiration. Every place that makes a trust decision must use the verifying function. Also check claims that identify the token's purpose: the issuer (iss) and audience (aud), if you use them.
5. No expiry on tokens
A token with no exp (expiration) claim works forever. If someone steals it from a log, a browser extension or a shared computer, they stay logged in as that user indefinitely. JWTs are stateless, so the server cannot easily cancel one early. That makes a short life even more important.
Fix:
- Give access tokens a short life, such as 15 minutes.
- Use a longer-lived refresh token, stored server-side so you can revoke it, to issue new access tokens.
- Reject tokens without an
expclaim.
jwt.sign({ sub: user.id, role: 'user' }, process.env.JWT_SECRET, {
algorithm: 'HS256',
expiresIn: '15m'
});
6. Storing tokens in localStorage
LocalStorage is a browser storage area that any JavaScript on your page can read. If an attacker finds a cross-site scripting (XSS) bug, meaning they can run their own script on your page, that script can copy the token and send it away. OWASP's HTML5 Security Cheat Sheet warns against keeping session identifiers there for this reason.
Fix: send the token in a cookie that JavaScript cannot read:
Set-Cookie: access_token=<jwt>; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900
HttpOnlyblocks scripts from reading the cookie.Securesends it only over HTTPS.SameSite=Laxreduces cross-site request forgery (CSRF), where another site tricks the browser into acting for the user. For sensitive actions, add CSRF tokens too.
HttpOnly stops a script from copying the token and using it elsewhere, but XSS can still act as the user while the page is open, so fix XSS too. A Content-Security-Policy header helps reduce XSS, and our security headers tool shows whether you have one.
7. Sensitive data in the payload
Because the payload is only encoded, you can paste any JWT into a decoder and read it. Putting an email, phone number, password hash, internal notes or API keys inside is a data leak waiting to happen.
Fix: keep the payload small: a user ID (sub), a role and the expiry. Look up everything else on the server. If you truly need secret contents, use encrypted tokens (JWE) or opaque session IDs.
Is a JWT the right choice for your site?
JWTs suit APIs and services that talk to each other. For a typical small site with one server and one login form, plain server-side sessions are simpler. The server can end them instantly, and there is less to get wrong. The OWASP Session Management Cheat Sheet explains how server-side sessions should be handled. For JWT-specific guidance, see the OWASP JSON Web Token for Java Cheat Sheet. If JWTs are not buying you something specific, consider sessions.
Your JWT checklist
- [ ] Secret is random, at least 32 bytes, and not in Git or frontend code.
- [ ]
yoursite.com/.envandyoursite.com/.git/configboth return a 404 (or another error) and never show file contents. - [ ] The verify call lists the allowed algorithm explicitly.
- [ ] No route uses decode-only logic to make trust decisions.
- [ ] Every token has a short
exp. - [ ] Tokens live in HttpOnly, Secure cookies, not localStorage.
- [ ] The payload holds no personal data or secrets.
- [ ] The JWT library is on a current version.
This checklist covers the most common JWT mistakes, but it is not the whole picture. Logic bugs, such as letting user A fetch user B's data by changing an ID, will not show up in this list. If you want a wider view of your exposure, read how to tell if your website is hackable or run the free website security check.
Key takeaways
- JWTs are not insecure by nature. The risk comes from weak secrets, skipped checks and bad storage.
- Use a random secret of at least 32 bytes, keep it out of code and Git, and rotate it if it leaks.
- Pin the allowed algorithm and always use the verifying function, never decode alone.
- Set a short expiry, and store tokens in HttpOnly, Secure cookies instead of localStorage.
- Keep personal data out of the payload, because anyone can read it.
Want a person to check it for you?
A checklist catches the common mistakes. It will not find the odd ones that only show up when someone tries to break your login flow. Our $49 Circuit audit is a manual review of your whole site by a security engineer, with a written report that lists each issue, its severity, the evidence and the exact fix. It is a focused audit for small sites, not an enterprise pentest or a compliance certificate. See the pricing page for details, or read manual vs automated testing to see which fits you.
A real human security engineer audits your whole site by hand and sends a full report: every issue, its severity, and the exact fix. From $49, with a 14-day money-back guarantee.
See pricing