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 (0)