How to Implement JWT Authentication in Node.js REST API: Complete Tutorial

by | Jun 1, 2026 | Uncategorized | 0 comments

If you’re building a REST API with Node.js and Express, securing your endpoints is non-negotiable. JWT authentication in Node.js has become the industry standard for stateless API security, and for good reason: it’s lightweight, scalable, and works seamlessly across mobile apps, SPAs, and microservices.

In this complete tutorial, we’ll walk through building a production-ready JWT authentication system from scratch. Unlike most guides that stop at basic token generation, we’ll cover access tokens, refresh tokens, middleware verification, and secure storage practices that you can copy directly into your own projects.

What You’ll Build

By the end of this tutorial, you’ll have a working Express REST API with:

  • User registration with hashed passwords
  • Login endpoint that issues access + refresh tokens
  • Protected routes guarded by JWT middleware
  • A refresh token rotation system
  • Secure logout with token invalidation
node js code security

Why JWT for Node.js APIs?

Before we dive into code, let’s quickly compare JWT to traditional session-based authentication.

Feature JWT Sessions
Storage Stateless (client-side) Stateful (server-side)
Scalability Excellent Requires shared store
Mobile-friendly Yes Limited
Revocation Trickier Easy

Step 1: Project Setup

Create a new Node.js project and install the dependencies we’ll need:

mkdir jwt-auth-api && cd jwt-auth-api
npm init -y
npm install express jsonwebtoken bcryptjs dotenv cookie-parser
npm install --save-dev nodemon

Create a .env file at the root of your project:

PORT=4000
ACCESS_TOKEN_SECRET=your_super_long_random_access_secret_here
REFRESH_TOKEN_SECRET=your_super_long_random_refresh_secret_here
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d

Pro tip: Generate strong secrets with node -e "console.log(require('crypto').randomBytes(64).toString('hex'))".

node js code security

Step 2: Basic Express Server

Create server.js:

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

const app = express();
app.use(express.json());
app.use(cookieParser());

app.use('/api/auth', authRoutes);
app.use('/api', protectedRoutes);

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

Step 3: Generate Access and Refresh Tokens

Create a helper file at utils/tokens.js:

const jwt = require('jsonwebtoken');

function generateAccessToken(user) {
  return jwt.sign(
    { id: user.id, email: user.email },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
  );
}

function generateRefreshToken(user) {
  return jwt.sign(
    { id: user.id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
  );
}

module.exports = { generateAccessToken, generateRefreshToken };

The two-token strategy is the cornerstone of secure JWT authentication:

  • Access token: Short-lived (15 min), sent on every API request.
  • Refresh token: Long-lived (7 days), used only to obtain new access tokens.

Step 4: Register and Login Routes

For this tutorial, we’ll use an in-memory user store. In production, replace it with PostgreSQL, MongoDB, or your database of choice.

Create routes/auth.js:

const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { generateAccessToken, generateRefreshToken } = require('../utils/tokens');

const router = express.Router();
const users = []; // Replace with real DB
let refreshTokens = []; // Replace with Redis or DB table

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

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

    const hashed = await bcrypt.hash(password, 12);
    const user = { id: Date.now().toString(), email, password: hashed };
    users.push(user);

    res.status(201).json({ message: 'User created', userId: user.id });
  } catch (err) {
    res.status(500).json({ error: 'Server error' });
  }
});

// LOGIN
router.post('/login', async (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 valid = await bcrypt.compare(password, user.password);
  if (!valid) return res.status(401).json({ error: 'Invalid credentials' });

  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);
  refreshTokens.push(refreshToken);

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

  res.json({ accessToken });
});

module.exports = router;
node js code security

Step 5: JWT Verification Middleware

Create middleware/auth.js:

const jwt = require('jsonwebtoken');

function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) return res.status(401).json({ error: 'Access token required' });

  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
    if (err) return res.status(403).json({ error: 'Invalid or expired token' });
    req.user = user;
    next();
  });
}

module.exports = authenticateToken;

Now apply it to any protected route. Create routes/protected.js:

const express = require('express');
const authenticateToken = require('../middleware/auth');
const router = express.Router();

router.get('/profile', authenticateToken, (req, res) => {
  res.json({ message: 'Protected data', user: req.user });
});

module.exports = router;

Test it with curl:

curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" http://localhost:4000/api/profile

Step 6: Refresh Token Endpoint

Add this to routes/auth.js:

// REFRESH
router.post('/refresh', (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ error: 'No refresh token' });
  if (!refreshTokens.includes(token)) return res.status(403).json({ error: 'Invalid refresh token' });

  jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, (err, decoded) => {
    if (err) return res.status(403).json({ error: 'Invalid refresh token' });

    // Rotate refresh token
    refreshTokens = refreshTokens.filter(t => t !== token);
    const user = users.find(u => u.id === decoded.id);
    const newAccess = generateAccessToken(user);
    const newRefresh = generateRefreshToken(user);
    refreshTokens.push(newRefresh);

    res.cookie('refreshToken', newRefresh, {
      httpOnly: true, secure: true, sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });
    res.json({ accessToken: newAccess });
  });
});

// LOGOUT
router.post('/logout', (req, res) => {
  const token = req.cookies.refreshToken;
  refreshTokens = refreshTokens.filter(t => t !== token);
  res.clearCookie('refreshToken');
  res.json({ message: 'Logged out' });
});

Secure Storage Best Practices

Where you store tokens on the client side determines how vulnerable your app is. Here’s the rule of thumb:

  1. Refresh tokens: Store in httpOnly, Secure, SameSite=Strict cookies. They cannot be accessed by JavaScript, blocking XSS theft.
  2. Access tokens: Keep them in memory (a JS variable or state manager). Avoid localStorage if possible.
  3. Never store sensitive tokens in localStorage or sessionStorage if your app processes untrusted user content.
  4. Always serve your API over HTTPS in production.
  5. Implement refresh token rotation (we did this above) so a stolen refresh token becomes invalid after one use.
node js code security

Common Pitfalls to Avoid

  • Hard-coded secrets: Use environment variables and a secrets manager in production.
  • Long-lived access tokens: 15 minutes is a sane default. Anything over an hour increases risk.
  • No token blacklist: For high-security apps, store revoked refresh tokens in Redis with a TTL matching expiry.
  • Putting sensitive data in the JWT payload: JWTs are signed, not encrypted. Anyone can decode the payload.
  • Skipping rate limiting: Add express-rate-limit on auth endpoints to deter brute force attacks.

Production Checklist

Before shipping your JWT auth to production, verify the following:

  • Secrets are at least 64 random bytes, stored outside the codebase
  • HTTPS is enforced (HSTS header recommended)
  • Refresh tokens are stored in a database or Redis, not in memory
  • Rate limiting and account lockout are in place
  • Passwords are hashed with bcrypt (cost factor 12+) or argon2
  • You log authentication events for auditing
  • CORS is configured strictly for your frontend origin

Frequently Asked Questions

What is JWT authentication in Node.js?

JWT (JSON Web Token) authentication is a stateless authentication method where the server issues a signed token to the client after successful login. The client sends this token with every subsequent request, and the server verifies its signature without needing to query a session store.

Which is the best JWT library for Node.js?

The jsonwebtoken package is the de facto standard. It’s well-maintained, supports all major signing algorithms (HS256, RS256, ES256), and is used in production by countless companies. For more advanced use cases, consider jose, which is faster and supports the full JOSE spec.

How do I verify a JWT token in Node.js?

Use jwt.verify(token, secret, callback) from the jsonwebtoken library. If the token is valid and not expired, you’ll receive the decoded payload. If invalid or expired, an error is returned. Wrap this inside Express middleware to protect routes, as shown in Step 5.

What are the three parts of a JWT?

Every JWT consists of three Base64Url-encoded parts separated by dots: the header (algorithm and type), the payload (claims like user ID and expiry), and the signature (verifies the token wasn’t tampered with).

Should I use JWT or sessions for my Node.js API?

Use JWT for stateless REST APIs, mobile backends, and microservices. Use sessions for traditional server-rendered web apps where you need easy revocation and don’t need cross-domain support. Many large apps use a hybrid approach.

How long should JWT tokens last?

Access tokens should be short-lived: 5 to 15 minutes is standard. Refresh tokens can last from 1 day to 30 days, depending on your security requirements. For banking or healthcare apps, lean toward shorter lifetimes.

Wrapping Up

You now have a fully working JWT authentication system in Node.js with Express, complete with refresh token rotation and secure cookie handling. The code samples above are designed to be copy-paste ready, but always remember to swap the in-memory stores for a real database before deploying.

Authentication is one of those areas where shortcuts cost you. Take the time to add rate limiting, audit logging, and proper secret management to your stack. Your future self, and your users, will thank you.

Search

Recent Posts

Subscribe