How to Implement JWT Authentication in Node.js: A Complete Step-by-Step Guide

If you’re building APIs with Node.js, chances are you’ll need a secure way to authenticate users. JWT authentication in Node.js remains the go-to approach in 2026 because it’s stateless, scalable, and works beautifully with modern frontends, mobile apps, and microservices.

This guide is a hands-on tutorial. No theory dumps, no fluff. You’ll get real code snippets you can copy, paste, and adapt to your own Express app, including the parts most tutorials skip: refresh tokens, secret rotation, and safe storage practices.

What You’ll Build

  • An Express API with register and login endpoints
  • JWT access tokens for protected routes
  • JWT refresh tokens stored securely in HTTP-only cookies
  • Middleware to verify tokens on protected endpoints
  • A clean way to store and rotate your JWT secrets

Why JWT for Node.js APIs?

Unlike session-based auth, JWT is stateless. The server doesn’t store session data, which means easier horizontal scaling and a smaller memory footprint. The token itself carries the user identity, signed by your server, and any service that knows the secret (or public key) can verify it.

Feature Session-based JWT
State Stored on server Stateless
Scalability Needs shared store (Redis) Scales horizontally by default
Mobile / SPA friendly Average Excellent
Revocation Easy Needs strategy (refresh tokens, blacklist)

Step 1: Project Setup

Create a new Node.js project and install the dependencies:

mkdir jwt-auth-demo && cd jwt-auth-demo
npm init -y
npm install express jsonwebtoken bcrypt cookie-parser dotenv
npm install -D nodemon

Project structure:

jwt-auth-demo/
├── src/
│   ├── controllers/authController.js
│   ├── middleware/authMiddleware.js
│   ├── routes/authRoutes.js
│   ├── utils/tokens.js
│   └── server.js
├── .env
└── package.json

Step 2: Storing Secrets Safely

Never hardcode JWT secrets. Use environment variables and generate strong keys (at least 256 bits).

Generate two strong secrets in your terminal:

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

Then add them to your .env file:

ACCESS_TOKEN_SECRET=your_long_random_access_secret_here
REFRESH_TOKEN_SECRET=your_long_random_refresh_secret_here
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=7d
NODE_ENV=development

Secret Storage Best Practices

  • Add .env to your .gitignore file. Always.
  • In production, use a secrets manager: AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, or Doppler.
  • Use different secrets for access and refresh tokens.
  • Rotate secrets periodically. Plan for rotation by supporting two valid secrets during the transition window.
  • For multi-service architectures, consider RS256 (asymmetric) instead of HS256 so consumers verify with a public key.

Step 3: Token Utility Functions

Create src/utils/tokens.js:

const jwt = require('jsonwebtoken');

function signAccessToken(payload) {
  return jwt.sign(payload, process.env.ACCESS_TOKEN_SECRET, {
    expiresIn: process.env.ACCESS_TOKEN_TTL,
    issuer: 'adproductstogo',
    audience: 'adproductstogo-users'
  });
}

function signRefreshToken(payload) {
  return jwt.sign(payload, process.env.REFRESH_TOKEN_SECRET, {
    expiresIn: process.env.REFRESH_TOKEN_TTL,
    issuer: 'adproductstogo',
    audience: 'adproductstogo-users'
  });
}

function verifyAccessToken(token) {
  return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
}

function verifyRefreshToken(token) {
  return jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
}

module.exports = {
  signAccessToken,
  signRefreshToken,
  verifyAccessToken,
  verifyRefreshToken
};

Step 4: The Auth Controller

For demo purposes we’ll use an in-memory user array. In production, use a real database (Postgres, MongoDB, etc.).

Create src/controllers/authController.js:

const bcrypt = require('bcrypt');
const {
  signAccessToken,
  signRefreshToken,
  verifyRefreshToken
} = require('../utils/tokens');

const users = []; // replace with DB
const refreshTokenStore = new Set(); // replace with Redis or DB

async function register(req, res) {
  const { email, password } = req.body;
  if (!email || !password) return res.status(400).json({ error: 'Missing fields' });

  const exists = users.find(u => u.email === email);
  if (exists) return res.status(409).json({ error: 'User already exists' });

  const hash = await bcrypt.hash(password, 12);
  const user = { id: Date.now().toString(), email, password: hash };
  users.push(user);
  return res.status(201).json({ id: user.id, email: user.email });
}

async function login(req, res) {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  const ok = await bcrypt.compare(password, user.password);
  if (!ok) return res.status(401).json({ error: 'Invalid credentials' });

  const accessToken = signAccessToken({ sub: user.id, email: user.email });
  const refreshToken = signRefreshToken({ sub: user.id });
  refreshTokenStore.add(refreshToken);

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  return res.json({ accessToken });
}

async function refresh(req, res) {
  const token = req.cookies.refreshToken;
  if (!token || !refreshTokenStore.has(token)) {
    return res.status(401).json({ error: 'No refresh token' });
  }
  try {
    const payload = verifyRefreshToken(token);
    const accessToken = signAccessToken({ sub: payload.sub });
    return res.json({ accessToken });
  } catch (err) {
    refreshTokenStore.delete(token);
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
}

async function logout(req, res) {
  const token = req.cookies.refreshToken;
  if (token) refreshTokenStore.delete(token);
  res.clearCookie('refreshToken');
  return res.json({ message: 'Logged out' });
}

module.exports = { register, login, refresh, logout };

Step 5: Auth Middleware

Create src/middleware/authMiddleware.js to protect routes:

const { verifyAccessToken } = require('../utils/tokens');

function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header || !header.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }
  const token = header.split(' ')[1];
  try {
    const payload = verifyAccessToken(token);
    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { requireAuth };

Step 6: Routes & Server

Create src/routes/authRoutes.js:

const router = require('express').Router();
const { register, login, refresh, logout } = require('../controllers/authController');
const { requireAuth } = require('../middleware/authMiddleware');

router.post('/register', register);
router.post('/login', login);
router.post('/refresh', refresh);
router.post('/logout', logout);

router.get('/me', requireAuth, (req, res) => {
  res.json({ user: req.user });
});

module.exports = router;

Create src/server.js:

require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/authRoutes');

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/auth', authRoutes);

const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`API running on port ${port}`));

Step 7: Test Your Endpoints

  1. Register: POST /auth/register with { "email": "jane@example.com", "password": "Secret123!" }
  2. Login: POST /auth/login with the same credentials. You’ll get an accessToken in the response and a refreshToken cookie.
  3. Access protected route: GET /auth/me with header Authorization: Bearer <accessToken>
  4. Refresh: POST /auth/refresh (cookie sent automatically) returns a new access token.
  5. Logout: POST /auth/logout revokes the refresh token.

Refresh Token Strategy: What Most Tutorials Get Wrong

A lot of guides stop at “send a refresh token” without explaining the security implications. Here are the rules we recommend in 2026:

  • Short-lived access tokens (5 to 15 minutes). If a token leaks, the damage window is small.
  • Long-lived refresh tokens (7 to 30 days), stored in HTTP-only, Secure, SameSite cookies. Never put refresh tokens in localStorage.
  • Rotate refresh tokens on every use. Issue a new refresh token and invalidate the old one.
  • Detect reuse. If a refresh token is used twice, treat it as a stolen token and revoke the entire user session family.
  • Persist refresh tokens in Redis or your database with a TTL. The in-memory Set in the demo is for learning only.

Common Pitfalls to Avoid

  • Storing JWTs in localStorage. They’re vulnerable to XSS. Prefer HTTP-only cookies for refresh tokens.
  • Putting sensitive data in the payload. JWTs are signed, not encrypted. Anyone can decode them.
  • Using weak secrets. Use 64+ random bytes. Never reuse secrets across environments.
  • Skipping iss and aud claims. They prevent tokens from one service being accepted by another.
  • No token revocation strategy. Without refresh-token rotation or a blacklist, you can’t kick out a compromised user fast.

Going Further in Production

  • Switch to RS256 if multiple services need to verify tokens.
  • Add rate limiting on login and refresh endpoints with express-rate-limit.
  • Implement account lockout after repeated failed logins.
  • Log security events (login, refresh, logout, token reuse detected) to a SIEM.
  • Add 2FA for sensitive accounts using TOTP or WebAuthn.

FAQ

What is JWT authentication?

JWT (JSON Web Token) authentication is a stateless mechanism where the server issues a signed token after a successful login. The client sends this token with each request, and the server verifies its signature to authenticate the user without storing session data.

What’s the best JWT library for Node.js?

The most widely used is jsonwebtoken. For more advanced use cases (JWKS, OIDC), jose is excellent and modern. Both are actively maintained in 2026.

What’s the difference between JWT and OAuth?

OAuth 2.0 is an authorization framework that defines flows like authorization code grant. JWT is a token format. OAuth often uses JWTs as access tokens, but you can use JWTs without OAuth for your own simple authentication system.

Should I store JWT in localStorage or cookies?

For refresh tokens, use HTTP-only Secure cookies with SameSite=strict. For access tokens, in-memory storage in your SPA is the safest. Avoid localStorage because it’s accessible to any JavaScript on the page, making it an XSS target.

How long should access and refresh tokens last?

A common pattern is 15 minutes for access tokens and 7 days for refresh tokens. Adjust based on your security requirements. High-security apps may use 5 minutes and 1 day.

Can JWTs be revoked?

Not natively. Workarounds include short expiration times, refresh token rotation with reuse detection, or maintaining a token blacklist (in Redis) checked on each request.

Wrapping Up

You now have a working JWT authentication system in Node.js with access tokens, refresh tokens, secure secret storage, and middleware to protect your routes. The code above is a strong foundation. Replace the in-memory stores with your database, plug in a secrets manager, add rate limiting, and you’re production-ready.

Bookmark this guide and share it with your team. Secure auth is a team sport.