← Blog
August 5, 2026 · 12 min

JWT Auth in Node.js/Express: Step by Step

A complete, practical walkthrough of adding JWT-based authentication to an Express API, from login to protected routes to refresh tokens.

What we're building A minimal but production-shaped Express API with: a login endpoint that issues a JWT, middleware that protects routes by requiring a valid token, and a refresh endpoint. We'll use the popular `jsonwebtoken` package and `bcrypt` for password hashing.

Step 1: Install dependencies Install `express`, `jsonwebtoken`, and `bcrypt`. In a real project you'd also add a database client; this walkthrough uses an in-memory user store for clarity.

Step 2: Generate a strong signing secret Never hardcode a guessable string. Generate one with `crypto.randomBytes(64).toString('base64')` and store it in an environment variable, e.g. `JWT_SECRET`. See our dedicated secret generation guide for the full reasoning, and use this site's generator to produce one instantly.

Step 3: Hash passwords at signup Store only a bcrypt hash of the user's password, never the plaintext, using something like `bcrypt.hash(password, 12)`. This is unrelated to JWTs directly but is a prerequisite for any login endpoint worth building.

Step 4: The login endpoint On `POST /login`: - Look up the user by email. - Compare the submitted password against the stored hash with `bcrypt.compare`. - If it matches, sign a JWT with `jwt.sign({ sub: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: "15m", algorithm: "HS256" })`. - Also issue a refresh token: a random opaque string, stored (hashed) in your database alongside the user ID and an expiry, then set as an HttpOnly, Secure, SameSite cookie. - Return the access token in the JSON response body for the client to attach to future requests.

Step 5: Authentication middleware Write a small middleware that: - Reads the `Authorization: Bearer <token>` header. - Returns **401** immediately if it's missing or malformed. - Calls `jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"], clockTolerance: 15 })` inside a try/catch. - On failure (expired, bad signature, wrong algorithm), returns **401**. - On success, attaches the decoded payload to `req.user` and calls `next()`.

Note the explicit algorithms array — this is what prevents algorithm confusion attacks discussed elsewhere on this blog.

Step 6: Protecting routes Apply the middleware to any route that needs authentication, e.g. `app.get("/me", authenticate, handler)`. Inside the handler, `req.user.sub` gives you the authenticated user's ID without a database round trip.

Step 7: Authorization on top of authentication For role-based restrictions, add a second, small middleware after `authenticate` that checks `req.user.role` against what's required, and returns **403** (not 401) if the user is authenticated but lacks permission. Keeping these as two separate middleware functions keeps the 401/403 distinction clean.

Step 8: The refresh endpoint On `POST /auth/refresh`: - Read the refresh token from the HttpOnly cookie. - Look it up in your database by its hashed value. - Confirm it's not expired or already revoked. - Issue a new access token JWT, and rotate the refresh token (invalidate the old record, issue and store a new one, set the new cookie). - If the presented refresh token was already marked used, treat it as a possible theft and revoke the entire session.

Step 9: Logout On `POST /auth/logout`, delete or mark-revoked the refresh token record server-side and clear the cookie. The already-issued access token will simply expire naturally within its short lifetime.

Step 10: Testing the flow - Confirm `/login` with correct credentials returns a token and sets the refresh cookie. - Confirm a protected route returns **401** with no token, and succeeds with a valid one. - Confirm a protected route returns **401**, not a crash, with a deliberately expired token. - Confirm `/auth/refresh` issues a new working access token and rotates the refresh token. - Confirm `/auth/logout` followed by a refresh attempt fails.

Checklist - Passwords hashed with bcrypt, never stored or logged in plaintext. - JWT secret generated with a CSPRNG and loaded from environment, not source. - Access tokens short-lived; refresh tokens opaque, hashed at rest, and rotated on use. - Verification pinned to an explicit algorithm list with reasonable clock tolerance. - Clear separation between authentication (401) and authorization (403) middleware.