Ever logged into an app, closed the tab, come back, and you're still logged in — no password needed? That's almost always JWT doing its job behind the scenes.
JWT (JSON Web Token) is one of the most common ways to handle authentication in modern backends. But a lot of developers use it without really understanding what's happening — and that's exactly where security bugs sneak in.
Let's fix that. By the end of this post you'll know what a JWT actually is, how to use it in a Node.js + Express app, and the mistakes that quietly break real apps.
What is a JWT, really?
A JWT is just a string with three parts, separated by dots:
xxxxx.yyyyy.zzzzz
│ │ │
header payload signature
-
Header — says which algorithm signed the token (e.g.
HS256). -
Payload — the actual data (like
userId,role, and an expiry time). This is not encrypted — it's just Base64-encoded. Anyone can read it. - Signature — a cryptographic stamp created using a secret only your server knows. This is what stops people from faking tokens.
Want to see this for yourself? Paste any token into a free JWT decoder and you'll instantly see the header and payload. Notice you can read everything without the secret — that's the key lesson: never put passwords or sensitive data in a JWT payload.
Creating a token (login)
Install the library:
npm install jsonwebtoken
When a user logs in successfully, sign a token:
import jwt from 'jsonwebtoken'
// On successful login:
const token = jwt.sign(
{ userId: user._id, role: user.role }, // payload
process.env.JWT_SECRET, // secret (keep it in .env!)
{ expiresIn: '7d' } // auto-expiry
)
res.json({ token })
Three things to notice:
- Keep the payload small — just an id and role, not the whole user object.
- The secret lives in an environment variable, never hardcoded.
- Always set
expiresIn. A token that never expires is a token that can be stolen forever.
Verifying a token (protecting routes)
Now create a middleware that checks the token on every protected request:
export function auth(req, res, next) {
const header = req.headers.authorization || ''
const token = header.startsWith('Bearer ') ? header.slice(7) : null
if (!token) return res.status(401).json({ message: 'No token provided' })
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
req.user = decoded // { userId, role, iat, exp }
next()
} catch (err) {
return res.status(401).json({ message: 'Invalid or expired token' })
}
}
Use it on any route you want to protect:
app.get('/api/profile', auth, (req, res) => {
res.json({ userId: req.user.userId })
})
That's the whole core loop: sign on login, verify on every request.
The mistakes that break real apps
These are the ones I see again and again:
-
Trusting the payload without verifying. Decoding a token (reading it) is not the same as verifying it. Always use
jwt.verify(), never just decode. -
A weak or leaked secret. If your secret is
secret123or committed to GitHub, anyone can forge valid tokens. Use a long random string. -
No expiry. Always set
expiresIn. Pair short-lived access tokens with a longer refresh token for a better experience. -
Storing the token in
localStoragecarelessly. It's vulnerable to XSS. For sensitive apps, anhttpOnlycookie is safer. - Putting secrets in the payload. Remember — the payload is readable by anyone. No passwords, no card numbers.
Debugging tip
When something "just doesn't work," the fastest fix is to actually look at the token. Drop it into a JWT decoder and check:
- Is the
exp(expiry) in the past? → token expired. - Is the payload what you expect? → maybe you signed the wrong data.
- Wrong
algin the header? → algorithm mismatch.
90% of JWT bugs become obvious the moment you see the decoded token.
Wrapping up
JWT isn't magic — it's a signed string that says "this user is who they claim to be, and here's proof my server made it." Get the basics right (small payload, strong secret, always expire, always verify) and you've covered most of what breaks in production.
I teach hands-on backend development (Node.js, Express, MongoDB, Redis) at AS Backend Institute. If you're learning backend and want more practical guides like this, come say hi. 🚀
Top comments (4)
The decode-vs-verify distinction in mistake #1 is the one I keep flagging in code review. What makes it insidious is that
jwt.decode()returns a perfectly structured payload object — so the code looks correct, the tests pass if you feed it a valid token, and the bug only surfaces when someone crafts a token with a forgedroleclaim. I've noticed this pattern appears disproportionately in AI-generated auth code: the model knows both functions exist, picksdecodebecause the name suggests "reading the token," and produces something that works in the happy path. It's also one of the few auth mistakes that's straightforwardly lint-able — a static rule flaggingjwt.decode()on anything derived from request headers would catch it at the diff stage rather than in a security audit.This is such a sharp addition, Ofri 🙌 You nailed exactly why it's dangerous — it works in the happy path, tests pass on valid tokens, and it only blows up when someone forges a claim like
role: admin. "Looks correct" is the trap.The AI-generated-code observation is spot on too —
decodesounds like "just read the token," so it gets picked, and it silently skips the one thing that matters: the signature.And I love the lint-rule idea — flagging
jwt.decode()on anything derived from request headers would catch it at the diff stage instead of a post-incident audit. That's the kind of guardrail more teams should add. Thanks for leveling up the discussion 🙏The lint rule is deceptively hard to write correctly. Banning jwt.decode() outright is too noisy — legitimate use exists when you decode a token you minted yourself. The real signal is the source: req.headers, req.body, req.query. Tying the call to its argument's origin requires taint tracking across call sites, which ESLint can't do inter-procedurally without a custom data-flow pass, so you end up with heuristics. The other gap worth flagging: even teams that switch to verify() often omit the algorithms option, so jwt.verify(token, secret) still accepts RS256 tokens signed with the public key as the HMAC secret — a different exploit that also survives code review.
Spot on, Ofri 🙌 The taint-tracking point is exactly why a blanket jwt.decode() ban gets noisy — without inter-procedural data-flow, ESLint can only guess at the source, so heuristics are the best you get.
The algorithms omission is the scarier one though. The RS256→HS256 confusion — attacker signs with the public key treated as the HMAC secret — sails right through review because verify() looks safe. My rule of thumb: always pin algorithms: 'RS256', keep signing/verifying keys separate per algorithm, and never let the token's header decide the alg.
Genuinely sharp addition — I'll add a short section on algorithm confusion to the post. Thanks! 🔥