How to Implement JWT Authentication in Express.js: A Complete Tutorial

If you’re building a modern API with Express.js, securing your endpoints is non-negotiable. JWT (JSON Web Token) authentication has become the industry standard for stateless authentication in Node.js applications, and for good reason: it’s lightweight, scalable, and works beautifully with single-page apps and mobile clients.

In this tutorial, we’ll walk through a production-ready implementation of JWT authentication in Express.js, covering everything from token generation to refresh token rotation. No fluff, just working code with security best practices baked in.

What is JWT and Why Use It in Express.js?

A JSON Web Token is a compact, URL-safe string composed of three parts: a header, a payload, and a signature. When a user logs in successfully, your server generates a signed token that the client stores and sends back with each subsequent request.

The advantages over traditional session-based auth:

  • Stateless: No need to store session data on the server
  • Scalable: Works perfectly across distributed systems and microservices
  • Cross-domain friendly: Ideal for SPAs, mobile apps, and third-party APIs
  • Self-contained: The token carries the user identity and claims
jwt token security

Project Setup

Let’s start by setting up a fresh Express.js project. Open your terminal and run:

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

Required Dependencies

Package Purpose
express Web framework
jsonwebtoken Sign and verify JWTs
bcrypt Hash user passwords securely
dotenv Manage environment variables
cookie-parser Parse refresh token cookies

Creating the .env File

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

PORT=3000
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 using node -e "console.log(require('crypto').randomBytes(64).toString('hex'))".

Step 1: Building the Express Server

Create a file called 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 || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
jwt token security

Step 2: Generating Access and Refresh Tokens

A robust JWT setup uses two tokens:

  1. Access token: Short-lived (15 minutes), sent with every request
  2. Refresh token: Long-lived (7 days), used only to get a new access token

Create a utils/tokens.js file:

const jwt = require('jsonwebtoken');

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

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

module.exports = { generateAccessToken, generateRefreshToken };

Step 3: Building the Auth Routes

Create routes/auth.js. For brevity, we’ll use an in-memory user store, but you’d typically use MongoDB, PostgreSQL, or another database.

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

const router = express.Router();
const users = [];
let refreshTokens = [];

// Register
router.post('/register', async (req, res) => {
  try {
    const { email, password } = req.body;
    if (!email || !password) {
      return res.status(400).json({ message: 'Email and password required' });
    }
    const existing = users.find(u => u.email === email);
    if (existing) return res.status(409).json({ message: 'User already exists' });

    const hashedPassword = await bcrypt.hash(password, 12);
    const user = { id: Date.now().toString(), email, password: hashedPassword };
    users.push(user);
    res.status(201).json({ message: 'User registered successfully' });
  } catch (err) {
    res.status(500).json({ message: '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({ message: 'Invalid credentials' });

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

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

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

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

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

  jwt.verify(token, process.env.REFRESH_TOKEN_SECRET, (err, decoded) => {
    if (err) return res.status(403).json({ message: 'Token expired or invalid' });
    const user = users.find(u => u.id === decoded.id);
    if (!user) return res.status(404).json({ message: 'User not found' });
    const newAccessToken = generateAccessToken(user);
    res.json({ accessToken: newAccessToken });
  });
});

// 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 successfully' });
});

module.exports = router;

Step 4: Creating JWT Verification Middleware

This middleware will protect any route you attach it to. Create middleware/authenticate.js:

const jwt = require('jsonwebtoken');

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

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

  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, decoded) => {
    if (err) {
      const message = err.name === 'TokenExpiredError' 
        ? 'Token expired' 
        : 'Invalid token';
      return res.status(403).json({ message });
    }
    req.user = decoded;
    next();
  });
};

module.exports = authenticateToken;
jwt token security

Step 5: Protecting Routes

Create routes/protected.js to test our middleware:

const express = require('express');
const authenticateToken = require('../middleware/authenticate');

const router = express.Router();

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

module.exports = router;

Testing Your JWT Authentication Flow

Start the server with node server.js and test using curl, Postman, or any HTTP client:

  1. Register: POST to /api/auth/register with email and password
  2. Login: POST to /api/auth/login, receive an access token
  3. Access protected route: GET /api/profile with header Authorization: Bearer YOUR_TOKEN
  4. Refresh: POST to /api/auth/refresh when the access token expires
  5. Logout: POST to /api/auth/logout to invalidate the refresh token
jwt token security

Security Best Practices for JWT in Express.js

Implementing JWT correctly is more than just generating tokens. Follow these rules in production:

  • Keep access tokens short-lived: 15 minutes is a healthy default
  • Store refresh tokens in HttpOnly cookies: Prevents XSS-based theft
  • Use HTTPS everywhere: Set the secure cookie flag in production
  • Rotate refresh tokens: Issue a new refresh token each time one is used
  • Maintain a token blacklist or database whitelist: For revocation on logout or compromise
  • Use strong secrets: At least 64 random bytes, stored only in environment variables
  • Validate the algorithm: Pass { algorithms: ['HS256'] } to jwt.verify to prevent algorithm confusion attacks
  • Rate-limit auth endpoints: Use express-rate-limit to block brute-force attempts
  • Never put sensitive data in the payload: JWT payloads are base64-encoded, not encrypted

Common JWT Mistakes to Avoid

Mistake Fix
Storing tokens in localStorage Use HttpOnly cookies for refresh tokens
Hardcoding secrets in code Always use environment variables
Using long-lived access tokens Keep them under 30 minutes
No way to revoke tokens Track refresh tokens server-side
Skipping bcrypt for passwords Always hash with at least 12 rounds

Frequently Asked Questions

What is the difference between an access token and a refresh token?

An access token is short-lived and sent with every API request to authenticate the user. A refresh token is long-lived and used solely to request a new access token when the old one expires, without forcing the user to log in again.

Should I store JWTs in localStorage or cookies?

For refresh tokens, always use HttpOnly cookies to protect against XSS attacks. Access tokens can be stored in memory (a JavaScript variable) and re-fetched via the refresh endpoint when the page reloads.

How do I invalidate a JWT before it expires?

Since JWTs are stateless, you cannot truly invalidate them without server-side tracking. The common approach is to maintain a blacklist of revoked tokens or a whitelist of valid refresh tokens in a fast database like Redis.

Is JWT secure enough for production?

Yes, when implemented correctly. Use HTTPS, strong secrets, short expiration times, HttpOnly cookies for refresh tokens, and token rotation. Combined with rate limiting and proper password hashing, JWT is widely used in production by major companies.

Can I use JWT with sessions?

You can, but it usually defeats the purpose. JWT shines in stateless architectures. If you need server-side session control, consider traditional session cookies instead, or use a hybrid where refresh tokens are session-backed.

Wrapping Up

You now have a complete, production-ready JWT authentication system in Express.js with token generation, secure middleware verification, refresh token rotation, and proper logout handling. From here, you can extend this with role-based access control, social login providers, email verification, or two-factor authentication.

The key takeaway: JWT is powerful but unforgiving. Stick to the security best practices outlined here, and your API will be both fast and secure. Happy coding from the team at GeminiWeb.

Search

Recent Blog

  • All Post
  • Email Marketing
  • Responsive Website
  • SEO
  • Social Media Marketing
  • Web Design
  • Web Development

Subscribe

You have been successfully Subscribed! Ops! Something went wrong, please try again.

Company Name

Gemini Web

Company Address

3444 Hall Valley Drive, Davy, WV 24828 USA

Company Email

[email protected]

Copyright © 2022 Gemini Web. All Rights Reserved.