DEV Community

Akash Gupta
Akash Gupta

Posted on

JWT Authentication in Node.js: A Practical Guide (with Express)

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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

Three things to notice:

  1. Keep the payload small — just an id and role, not the whole user object.
  2. The secret lives in an environment variable, never hardcoded.
  3. 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' })
  }
}
Enter fullscreen mode Exit fullscreen mode

Use it on any route you want to protect:

app.get('/api/profile', auth, (req, res) => {
  res.json({ userId: req.user.userId })
})
Enter fullscreen mode Exit fullscreen mode

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:

  1. Trusting the payload without verifying. Decoding a token (reading it) is not the same as verifying it. Always use jwt.verify(), never just decode.
  2. A weak or leaked secret. If your secret is secret123 or committed to GitHub, anyone can forge valid tokens. Use a long random string.
  3. No expiry. Always set expiresIn. Pair short-lived access tokens with a longer refresh token for a better experience.
  4. Storing the token in localStorage carelessly. It's vulnerable to XSS. For sensitive apps, an httpOnly cookie is safer.
  5. 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 alg in 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)

Collapse
 
ofri-peretz profile image
Ofri Peretz

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 forged role claim. I've noticed this pattern appears disproportionately in AI-generated auth code: the model knows both functions exist, picks decode because 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 flagging jwt.decode() on anything derived from request headers would catch it at the diff stage rather than in a security audit.

Collapse
 
akashguptasky profile image
Akash Gupta

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 — decode sounds 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 🙏

Collapse
 
ofri-peretz profile image
Ofri Peretz

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.

Thread Thread
 
akashguptasky profile image
Akash Gupta

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! 🔥