How to Set Up a Secure REST API with Node.js and JWT (Step-by-Step Guide)

Node JS

Last Updated: March 23, 2025

Creating robust, secure REST APIs is essential for modern web applications. This comprehensive guide will walk you through implementing JWT authentication in your Node.js API, with practical code examples and industry best practices to protect your application against common security threats.

Introduction to REST API Security with JWT

API security remains one of the most critical aspects of modern web application development. By implementing JSON Web Tokens (JWT) in your Node.js applications, you can ensure secure, stateless authentication that scales with your application’s growth.

In this step-by-step guide, you’ll learn how to build a fully functional secure REST API using Node.js and JWT authentication. We’ll cover everything from initial project setup to advanced security implementations and best practices.

What You’ll Learn:

  • ✅ How to set up a Node.js project with Express and MongoDB
  • ✅ Implementing JWT authentication and authorization
  • ✅ Creating secure user registration and login flows
  • ✅ Building protected routes with role-based access
  • ✅ Advanced security patterns for token management
  • ✅ Best practices for production deployment

Understanding JWT Authentication Fundamentals

Before diving into implementation, it’s important to understand what makes JWT authentication uniquely valuable for REST APIs.

What is JWT?

JSON Web Tokens (JWT) provide a compact, self-contained way to securely transmit information between parties. A JWT consists of three crucial parts:

Header

Contains token type and signing algorithm information

Payload

Contains claims (user data and metadata)

Signature

Ensures token integrity and authenticity

Benefits of JWT for REST APIs

  • Stateless authentication – Servers don’t need to store session information
  • Scalability – Ideal for distributed systems and microservices
  • Cross-domain compatibility – Works seamlessly across different domains
  • Performance – Reduces database lookups for session information

Setting Up Your Node.js Project

Initial Project Setup

Step 1: Create Project Directory and Initialize npm


mkdir secure-api
cd secure-api
npm init -y

Step 2: Install Required Dependencies


npm install express jsonwebtoken mongoose bcrypt dotenv
npm install @types/jsonwebtoken --save-dev

Step 3: Set Up Project Structure


secure-api/
├── src/
│   ├── config/
│   ├── controllers/
│   ├── middleware/
│   ├── models/
│   ├── routes/
│   └── index.js
├── .env
└── package.json

Environment Configuration

Step 4: Create .env File for Sensitive Information


PORT=3000
MONGODB_URI=mongodb://localhost:27017/secure-api
JWT_SECRET=your_super_secure_secret_key
JWT_EXPIRES_IN=1h
JWT_ISSUER=your-api
JWT_AUDIENCE=your-client

⚠️ Security Tip

Generate a secure JWT secret using Node.js crypto instead of creating one manually:


require('crypto').randomBytes(128).toString('hex');

Creating User Authentication Models & Controllers

Implementing the User Model

First, let’s create a secure user model with password hashing:


// src/models/User.js
const mongoose = require("mongoose");
const bcrypt = require("bcrypt");

const userSchema = new mongoose.Schema({
  username: {
    type: String,
    required: true,
    unique: true,
  },
  password: {
    type: String,
    required: true,
  },
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user'
  }
}, { timestamps: true });

// Hash password before saving
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  
  try {
    const salt = await bcrypt.genSalt(10);
    this.password = await bcrypt.hash(this.password, salt);
    next();
  } catch (error) {
    next(error);
  }
});

// Method to compare passwords
userSchema.methods.comparePassword = async function(candidatePassword) {
  return bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model("User", userSchema);

Creating the Authentication Controller

Now let’s implement the controller that handles user registration and login:


// src/controllers/AuthController.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const config = require('../config');

class AuthController {
  static register = async (req, res) => {
    try {
      const { username, password } = req.body;
      
      // Check if user already exists
      const existingUser = await User.findOne({ username });
      if (existingUser) {
        return res.status(400).json({ message: 'Username already exists' });
      }
      
      // Create new user
      const user = new User({ username, password });
      await user.save();
      
      res.status(201).json({ message: 'User registered successfully' });
    } catch (error) {
      res.status(500).json({ message: 'Internal server error' });
    }
  };

  static login = async (req, res) => {
    try {
      const { username, password } = req.body;
      
      // Validate input
      if (!(username && password)) {
        return res.status(400).json({ message: 'Username and password are required' });
      }
      
      // Find user
      const user = await User.findOne({ username });
      if (!user) {
        return res.status(401).json({ message: 'Invalid credentials' });
      }
      
      // Verify password
      const isPasswordValid = await user.comparePassword(password);
      if (!isPasswordValid) {
        return res.status(401).json({ message: 'Invalid credentials' });
      }
      
      // Generate JWT
      const token = jwt.sign(
        { 
          userId: user._id, 
          username: user.username,
          role: user.role 
        }, 
        config.jwt.secret, 
        {
          expiresIn: config.jwt.expiresIn,
          issuer: config.jwt.issuer,
          audience: config.jwt.audience,
          algorithm: 'HS256'
        }
      );
      
      res.json({ token });
    } catch (error) {
      res.status(500).json({ message: 'Internal server error' });
    }
  };
}

module.exports = AuthController;

💡 Security Best Practice

Notice how we return the same error message (“Invalid credentials”) whether the username doesn’t exist or the password is incorrect. This prevents user enumeration attacks where attackers can determine if a username exists in your system.

Implementing JWT Authentication Middleware

JWT Verification Middleware

Let’s create middleware to protect routes by verifying JWT tokens:


// src/middleware/checkJwt.js
const jwt = require('jsonwebtoken');
const config = require('../config');

exports.checkJwt = (req, res, next) => {
  // Get authorization header
  const authHeader = req.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ message: 'No token provided' });
  }
  
  const token = authHeader.split(' ')[1];
  
  try {
    // Verify token
    const decoded = jwt.verify(token, config.jwt.secret, {
      algorithms: ['HS256'],
      issuer: config.jwt.issuer,
      audience: config.jwt.audience
    });
    
    // Add decoded payload to request object
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ message: 'Invalid or expired token' });
  }
};

Role-Based Authorization Middleware

For more granular control, we’ll implement role-based access control:


// src/middleware/checkRole.js
exports.checkRole = (roles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ message: 'Unauthorized' });
    }
    
    if (roles.includes(req.user.role)) {
      next();
    } else {
      res.status(403).json({ message: 'Insufficient permissions' });
    }
  };
};

🔒 Security Note

This implementation allows for fine-grained access control where different API endpoints can require different roles. Always apply the principle of least privilege by giving users only the permissions they need to perform their tasks.

Setting Up Protected API Routes

Authentication Routes


// src/routes/auth.js
const express = require('express');
const AuthController = require('../controllers/AuthController');

const router = express.Router();

router.post('/register', AuthController.register);
router.post('/login', AuthController.login);

module.exports = router;

Protected Resource Routes


// src/routes/users.js
const express = require('express');
const UserController = require('../controllers/UserController');
const { checkJwt } = require('../middleware/checkJwt');
const { checkRole } = require('../middleware/checkRole');

const router = express.Router();

// Public route
router.get('/public', UserController.getPublicInfo);

// Protected routes
router.get('/', checkJwt, UserController.getProfile);
router.get('/all', [checkJwt, checkRole(['admin'])], UserController.getAllUsers);
router.put('/', checkJwt, UserController.updateProfile);

module.exports = router;

Main Router Configuration


// src/routes/index.js
const express = require('express');
const authRoutes = require('./auth');
const userRoutes = require('./users');

const router = express.Router();

router.use('/auth', authRoutes);
router.use('/users', userRoutes);

module.exports = router;

Building the Main Application

Now, let’s tie everything together in our main application file:


// src/index.js
const express = require('express');
const mongoose = require('mongoose');
const dotenv = require('dotenv');
const routes = require('./routes');

// Load environment variables
dotenv.config();

// Create Express app
const app = express();
app.use(express.json());

// Connect to MongoDB
mongoose.connect(process.env.MONGODB_URI)
  .then(() => console.log('Connected to MongoDB'))
  .catch(err => console.error('MongoDB connection error:', err));

// API routes
app.use('/api', routes);

// Error handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ message: 'Something went wrong!' });
});

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

⚠️ Production Warning

For production environments, you should add additional security measures like rate limiting, CORS configuration, and helmet for setting various HTTP headers.

Advanced JWT Implementation: Refresh Token Workflow

For production applications, implement a refresh token workflow to enhance security:


// src/controllers/AuthController.js (additional method)
static refresh = async (req, res) => {
  try {
    const { refreshToken } = req.cookies;
    
    if (!refreshToken) {
      return res.status(401).json({ message: 'Refresh token required' });
    }
    
    // Verify refresh token
    const decoded = jwt.verify(refreshToken, config.jwt.refreshSecret);
    
    // Check if token is blacklisted
    const isBlacklisted = await TokenBlacklist.findOne({ token: refreshToken });
    if (isBlacklisted) {
      return res.status(401).json({ message: 'Token has been revoked' });
    }
    
    // Find user
    const user = await User.findById(decoded.userId);
    if (!user) {
      return res.status(401).json({ message: 'User not found' });
    }
    
    // Generate new access token
    const accessToken = jwt.sign(
      { userId: user._id, username: user.username, role: user.role },
      config.jwt.secret,
      {
        expiresIn: config.jwt.expiresIn,
        issuer: config.jwt.issuer,
        audience: config.jwt.audience
      }
    );
    
    // Generate new refresh token
    const newRefreshToken = jwt.sign(
      { userId: user._id },
      config.jwt.refreshSecret,
      { expiresIn: '7d' }
    );
    
    // Blacklist old refresh token
    await TokenBlacklist.create({ token: refreshToken });
    
    // Set new refresh token in cookie
    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
    });
    
    res.json({ accessToken });
  } catch (error) {
    res.status(401).json({ message: 'Invalid refresh token' });
  }
};

🔄 Refresh Token Strategy

This implementation uses refresh token rotation, where each refresh token can only be used once. When used, it’s blacklisted and a new refresh token is issued. This approach significantly improves security by limiting the window of opportunity for token theft.

Security Best Practices for JWT in Production

Token Storage & Transmission

  • Keep access tokens short-lived (15-60 minutes)
  • Store refresh tokens in HTTP-only cookies
  • Always use HTTPS in production
  • Implement proper CORS headers

Token Validation & Revocation

  • Verify all token claims (exp, iss, aud)
  • Implement token blacklisting for revocation
  • Rotate signing keys periodically
  • Invalidate tokens when passwords change

⚠️ Common JWT Security Pitfalls

  • Using weak or hardcoded secrets
  • Storing sensitive data in JWT payload
  • Using the “none” algorithm
  • Not validating the algorithm
  • Setting extremely long expiration times
  • Not handling token revocation properly

CSRF Protection Implementation

When using cookies for token storage, CSRF protection is essential:


// src/middleware/csrfProtection.js
const crypto = require('crypto');

exports.generateCsrfToken = (req, res, next) => {
  const csrfToken = crypto.randomBytes(64).toString('hex');
  
  // Store token in session or Redis
  req.session.csrfToken = csrfToken;
  
  // Add token to response
  res.locals.csrfToken = csrfToken;
  next();
};

exports.validateCsrfToken = (req, res, next) => {
  const token = req.headers['x-csrf-token'];
  
  if (!token || token !== req.session.csrfToken) {
    return res.status(403).json({ message: 'Invalid CSRF token' });
  }
  
  next();
};

Real-world Case Study: E-commerce API Security

E-Commerce Platform JWT Implementation

A major e-commerce platform implemented the following JWT security architecture:

  • Short-lived access tokens (15 minutes) stored in memory
  • Long-lived refresh tokens (7 days) in HTTP-only cookies
  • Token blacklisting using Redis for immediate revocation
  • Role-based access control for different user types

Results:

  • 99.9% reduction in session hijacking attempts
  • Improved scalability by eliminating server-side session storage
  • Seamless user experience with automatic token refresh

Frequently Asked Questions (continued)

How can I revoke a JWT before it expires?

Since JWTs are stateless by design, you need to implement a token blacklist using a fast database like Redis. When a token needs to be revoked (e.g., on logout or password change), add it to the blacklist and check against this list during token verification.

Is it safe to store sensitive user data in a JWT?

No, you should never store sensitive information like passwords or personal data in a JWT payload, as the payload is only encoded (not encrypted) and can be decoded by anyone. Store only non-sensitive identifiers and authorization information.

How should I handle token refresh securely?

Implement a refresh token rotation strategy where each refresh token can only be used once. Store refresh tokens in HTTP-only, same-site cookies, and maintain them in a database to allow revocation. When a refresh token is used, invalidate it and issue a new one.

Testing Your Secure REST API

Manual Testing with Postman

Use Postman to test your authentication flows:

  1. 1
    Register a new user by sending a POST request to /api/auth/register with username and password

  2. 2
    Log in with the created user by sending a POST request to /api/auth/login

  3. 3
    Save the JWT from the response

  4. 4
    Test protected routes by including the JWT in the Authorization header as Bearer <token>

Automated Testing

Create automated tests to verify your authentication functionality:


// test/auth.test.js
const request = require('supertest');
const app = require('../src/index');
const mongoose = require('mongoose');
const User = require('../src/models/User');

describe('Auth Routes', () => {
  beforeAll(async () => {
    // Clear users collection before tests
    await User.deleteMany({});
  });

  afterAll(async () => {
    await mongoose.connection.close();
  });

  describe('POST /api/auth/register', () => {
    it('should register a new user', async () => {
      const res = await request(app)
        .post('/api/auth/register')
        .send({
          username: 'testuser',
          password: 'password123'
        });
      
      expect(res.statusCode).toEqual(201);
      expect(res.body).toHaveProperty('message', 'User registered successfully');
    });

    it('should return 400 if username already exists', async () => {
      const res = await request(app)
        .post('/api/auth/register')
        .send({
          username: 'testuser',
          password: 'anotherpassword'
        });
      
      expect(res.statusCode).toEqual(400);
      expect(res.body).toHaveProperty('message', 'Username already exists');
    });
  });

  describe('POST /api/auth/login', () => {
    it('should login and return a token', async () => {
      const res = await request(app)
        .post('/api/auth/login')
        .send({
          username: 'testuser',
          password: 'password123'
        });
      
      expect(res.statusCode).toEqual(200);
      expect(res.body).toHaveProperty('token');
    });

    it('should return 401 if credentials are invalid', async () => {
      const res = await request(app)
        .post('/api/auth/login')
        .send({
          username: 'testuser',
          password: 'wrongpassword'
        });
      
      expect(res.statusCode).toEqual(401);
      expect(res.body).toHaveProperty('message', 'Invalid credentials');
    });
  });

  describe('Protected Routes', () => {
    let token;

    beforeAll(async () => {
      // Login to get token
      const res = await request(app)
        .post('/api/auth/login')
        .send({
          username: 'testuser',
          password: 'password123'
        });
      
      token = res.body.token;
    });

    it('should access protected route with valid token', async () => {
      const res = await request(app)
        .get('/api/users')
        .set('Authorization', `Bearer ${token}`);
      
      expect(res.statusCode).toEqual(200);
    });

    it('should return 401 with invalid token', async () => {
      const res = await request(app)
        .get('/api/users')
        .set('Authorization', 'Bearer invalidtoken');
      
      expect(res.statusCode).toEqual(401);
    });
  });
});

Production Deployment Considerations

Security Hardening

Before deploying to production, implement these additional security measures:


// src/index.js (enhanced for production)
const express = require('express');
const mongoose = require('mongoose');
const dotenv = require('dotenv');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const cors = require('cors');
const routes = require('./routes');

// Load environment variables
dotenv.config();

// Create Express app
const app = express();

// Security middleware
app.use(helmet()); // Set security headers
app.use(cors({
  origin: process.env.CLIENT_ORIGIN || '*',
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true
}));

// Rate limiting
const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  standardHeaders: true,
  legacyHeaders: false,
});
app.use('/api/', apiLimiter);

// Body parsing
app.use(express.json({ limit: '10kb' })); // Body size limit

// Connect to MongoDB
mongoose.connect(process.env.MONGODB_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
  useCreateIndex: true,
  useFindAndModify: false
})
  .then(() => console.log('Connected to MongoDB'))
  .catch(err => console.error('MongoDB connection error:', err));

// API routes
app.use('/api', routes);

// 404 handler
app.use((req, res) => {
  res.status(404).json({ message: 'Resource not found' });
});

// Error handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack);
  
  // Don't leak error details in production
  const statusCode = err.statusCode || 500;
  const message = process.env.NODE_ENV === 'production' && statusCode === 500
    ? 'Internal server error'
    : err.message;
  
  res.status(statusCode).json({ message });
});

// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});

module.exports = app; // For testing

📝 Production Checklist

  • Use environment-specific configurations
  • Set up proper logging for security events
  • Implement monitoring for suspicious activity
  • Use a strong Content Security Policy
  • Configure proper CORS settings
  • Set up rate limiting for authentication endpoints
  • Apply HTTPS strict transport security
  • Schedule regular security audits

Conclusion and Next Steps

Building a secure REST API with Node.js and JWT authentication involves careful implementation of multiple security layers. This guide has covered the essential components for a production-ready API, including:

  • Setting up proper authentication flows with JWT
  • Implementing role-based access control
  • Managing token refresh securely
  • Protecting against common security vulnerabilities
  • Preparing your API for production deployment

By following these practices, you’ve established a solid foundation for building secure, scalable APIs that protect user data while providing a seamless experience.

What to Explore Next

  • Two-factor authentication integration
  • OAuth 2.0 and social login implementation
  • Microservices authentication patterns
  • Advanced logging and monitoring solutions
  • API documentation with Swagger/OpenAPI

“Security is not a product, but a process.” — Bruce Schneier

Remember that securing your API is an ongoing commitment. Stay informed about emerging threats and security best practices to protect your application and users effectively.

Check us out for more at Softwarestudylab.com

Leave a Reply

Your email address will not be published. Required fields are marked *