Programming Intermediate 13 min

How to Add JWT Authentication to a Node.js API

JSON Web Tokens (JWT) are the most common way to authenticate stateless REST APIs. The server issues a signed token on login; the client sends it with every subsequent request; the server verifies the signature — no session store, no database round-trip per request.

This guide covers the full cycle: hashing passwords with bcrypt, issuing tokens on login, writing an authMiddleware that verifies the token and attaches the user ID to the request, protecting routes, and understanding the trade-offs between storing tokens in localStorage versus httpOnly cookies.

Step-by-step

  1. 1

    Install the required packages

    jsonwebtoken signs and verifies JWTs. bcryptjs is a pure-JS bcrypt implementation — slower than the native C++ binding bcrypt, but zero native dependencies and easier to deploy.

    bash
    npm install jsonwebtoken bcryptjs
  2. 2

    Add JWT_SECRET to your environment

    The secret is used to sign every token. If it leaks, attackers can forge tokens for any user. Use a long random string — at least 32 bytes. Never hardcode it.

    bash
    # .env
    PORT=3000
    JWT_SECRET=replace-this-with-a-long-random-string-at-least-32-chars
    
    # Generate a good one with:
    # node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
  3. 3

    Create the signup route

    The signup handler validates the input, checks for duplicate emails, hashes the password with bcrypt, and stores the user. Never store a plaintext password. Bcrypt's cost factor of 12 is a good default — high enough to slow brute force, low enough not to slow your server.

    javascript
    // src/controllers/authController.js
    const bcrypt = require("bcryptjs")
    const jwt = require("jsonwebtoken")
    
    // In-memory store — replace with DB in production
    const users = []
    let nextId = 1
    
    exports.signup = async (req, res) => {
      const { name, email, password } = req.body
      if (!name || !email || !password) {
        return res.status(400).json({ error: "name, email and password are required" })
      }
    
      if (users.find((u) => u.email === email)) {
        return res.status(409).json({ error: "Email already registered" })
      }
    
      const passwordHash = await bcrypt.hash(password, 12)
      const user = { id: nextId++, name, email, passwordHash }
      users.push(user)
    
      res.status(201).json({ message: "Account created", id: user.id })
    }
    
    // Export users array so the login handler can reference it
    module.exports.users = users
  4. 4

    Create the login route

    The login handler finds the user by email, compares the submitted password against the stored hash with bcrypt.compare, and — if valid — issues a signed JWT. Set a short expiry for access tokens: '15m' to '1d' is typical. The token payload should contain only what you need on every request (the user ID is usually enough).

    javascript
    // add to src/controllers/authController.js
    exports.login = async (req, res) => {
      const { email, password } = req.body
      if (!email || !password) {
        return res.status(400).json({ error: "email and password are required" })
      }
    
      const user = users.find((u) => u.email === email)
      if (!user) {
        // Same message for missing user and wrong password — prevent user enumeration
        return res.status(401).json({ error: "Invalid credentials" })
      }
    
      const passwordMatch = await bcrypt.compare(password, user.passwordHash)
      if (!passwordMatch) {
        return res.status(401).json({ error: "Invalid credentials" })
      }
    
      const token = jwt.sign(
        { id: user.id },
        process.env.JWT_SECRET,
        { expiresIn: "7d" }
      )
    
      res.json({ token })
    }
  5. 5

    Write the auth middleware

    The middleware reads the Authorization header, strips the Bearer prefix, verifies the token with the secret, and attaches the decoded payload to req.userId. If verification fails for any reason — expired, tampered, missing — it returns 401 immediately.

    javascript
    // src/middleware/auth.js
    const jwt = require("jsonwebtoken")
    
    module.exports = function authMiddleware(req, res, next) {
      const authHeader = req.headers["authorization"]
      if (!authHeader || !authHeader.startsWith("Bearer ")) {
        return res.status(401).json({ error: "Missing or malformed Authorization header" })
      }
    
      const token = authHeader.slice(7) // remove "Bearer "
    
      try {
        const payload = jwt.verify(token, process.env.JWT_SECRET)
        req.userId = payload.id
        next()
      } catch (err) {
        const message = err.name === "TokenExpiredError" ? "Token expired" : "Invalid token"
        return res.status(401).json({ error: message })
      }
    }
  6. 6

    Wire up auth routes and protect endpoints

    Register the signup and login routes — these are public. Protect any route that requires authentication by mounting the middleware before the handler, either inline per-route or as a router-level middleware for an entire group.

    javascript
    // src/routes/auth.js
    const express = require("express")
    const router = express.Router()
    const authController = require("../controllers/authController")
    
    router.post("/signup", authController.signup)
    router.post("/login", authController.login)
    
    module.exports = router
    
    // src/routes/users.js — protect the whole router
    const authMiddleware = require("../middleware/auth")
    const router = express.Router()
    
    router.use(authMiddleware) // All /api/users routes now require a valid token
    
    router.get("/", usersController.getAll)
    router.get("/:id", usersController.getOne)
    // ...
    
    // src/app.js
    app.use("/api/auth", authRouter)   // public
    app.use("/api/users", usersRouter) // protected via router-level middleware
  7. 7

    Test the full auth flow

    Run the server and walk through the complete cycle: sign up, log in, use the token, try an expired or tampered token.

    bash
    # 1. Sign up
    curl -X POST http://localhost:3000/api/auth/signup \
      -H "Content-Type: application/json" \
      -d '{"name":"Alice","email":"alice@example.com","password":"secret123"}'
    
    # 2. Log in — copy the token from the response
    curl -X POST http://localhost:3000/api/auth/login \
      -H "Content-Type: application/json" \
      -d '{"email":"alice@example.com","password":"secret123"}'
    
    # 3. Access a protected route using the token
    TOKEN="eyJhbGci..."
    curl http://localhost:3000/api/users \
      -H "Authorization: Bearer $TOKEN"
    
    # 4. Try without a token — expect 401
    curl http://localhost:3000/api/users
  8. 8

    Understand token storage trade-offs

    Where to store the JWT on the client matters for security:

    • localStorage / sessionStorage — Easy to access with JavaScript, but vulnerable to XSS. An injected script can steal the token. Acceptable only if your API and frontend are on the same origin and you have a strict CSP.
    • httpOnly cookie — JavaScript cannot read it. Immune to XSS token theft. The cookie is sent automatically with every request. Requires CSRF protection (use SameSite=Strict or Lax). This is the better default for browser clients.

    If the API is consumed by a mobile app or another server (not a browser), localStorage or in-memory storage is fine — there is no DOM, so XSS is not relevant.

Tips & gotchas

  • Return the same error message ("Invalid credentials") for both wrong password and unknown email. Different messages let attackers enumerate which emails are registered.
  • Access tokens should be short-lived (15 minutes to 1 day). Use a separate, longer-lived refresh token stored in an httpOnly cookie to issue new access tokens without forcing re-login.
  • Put only the minimum data in the JWT payload (user ID, maybe role). Every byte adds to every request. Avoid storing email or name — fetch it from the DB when you need it.
  • Store `JWT_SECRET` in a secrets manager (AWS Secrets Manager, Doppler, etc.) in production. Rotating it invalidates all existing tokens — plan for that.

Wrapping up

JWT authentication with bcrypt and jsonwebtoken adds fewer than 100 lines to an Express API. The critical decisions are operational: keep the secret out of source code, use short token expiry with a refresh strategy, and choose httpOnly cookies over localStorage for browser clients. Everything else is predictable boilerplate once you understand what each piece does.

#Node.js #JWT #Auth
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.