Step-by-step
-
1
Install the required packages
jsonwebtokensigns and verifies JWTs.bcryptjsis a pure-JS bcrypt implementation — slower than the native C++ bindingbcrypt, but zero native dependencies and easier to deploy.bashnpm install jsonwebtoken bcryptjs -
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
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
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
Write the auth middleware
The middleware reads the
Authorizationheader, strips theBearerprefix, verifies the token with the secret, and attaches the decoded payload toreq.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
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
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
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=StrictorLax). 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.