JavaScriptNode.js

Authentication in Express with Passport and JWT

Introduction

Secure authentication is one of the most important parts of building backend systems. With Express, developers often rely on Passport for handling authentication strategies and JSON Web Tokens (JWT) for stateless session management. Together, they create a clean, flexible, and scalable authentication setup suitable for modern applications. In this guide, you will learn how authentication works in Express, how Passport and JWT fit into the workflow, and which best practices help you build secure systems. These patterns will help you design authentication flows that stay easy to maintain and safe for production use.

Why Use Passport and JWT in Express?

Express is minimal by design, so it does not include built-in authentication. Passport fills this gap by offering a modular way to support many strategies, including local authentication, OAuth, and JWT-based flows. Since JWT tokens allow stateless verification, they work especially well for APIs running across distributed systems.

Passport provides flexible authentication strategies that plug into your routes. JWT enables stateless session management without server-side storage. Express offers simple routing and middleware support for clean integration. Tokens work well with mobile apps and microservices that need cross-service authentication. The entire setup remains lightweight and easy to extend as requirements grow.

Because this stack stays both powerful and beginner-friendly, many teams choose it for production systems.

How Passport Works

Passport operates through strategies that plug into your Express routes. Each strategy handles a specific type of authentication. Although Passport supports OAuth, SAML, and many others, the Local Strategy and JWT Strategy are the most common when building custom backends.

Authentication Flow

The typical Passport + JWT flow works as follows: User submits credentials (email and password). Passport uses Local Strategy to verify identity against the database. Server issues a signed JWT containing user claims. Client stores the token securely. Subsequent requests include the token in the Authorization header. Passport uses JWT Strategy to validate it and attach user data to the request.

This flow keeps the system stateless because no session data is stored on the server.

Setting Up Authentication in Express

Let’s look at the essential components needed to implement Passport and JWT inside an Express app.

Install Dependencies

npm install express passport passport-local passport-jwt jsonwebtoken bcryptjs dotenv

Project Structure

src/
├── config/
│   └── passport.js      # Passport strategies
├── middleware/
│   └── auth.js          # Authentication middleware
├── routes/
│   └── auth.js          # Auth routes (login, register)
├── models/
│   └── User.js          # User model
├── utils/
│   └── jwt.js           # JWT helpers
└── app.js               # Express app

Configure the Local Strategy

// src/config/passport.js
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import bcrypt from 'bcryptjs';
import { findUserByEmail, findUserById } from '../models/User.js';

// Local Strategy for login
passport.use(
  new LocalStrategy(
    {
      usernameField: 'email',
      passwordField: 'password',
    },
    async (email, password, done) => {
      try {
        const user = await findUserByEmail(email.toLowerCase());
        
        if (!user) {
          return done(null, false, { message: 'Invalid credentials' });
        }

        const isValidPassword = await bcrypt.compare(password, user.password);
        
        if (!isValidPassword) {
          return done(null, false, { message: 'Invalid credentials' });
        }

        // Don't include password in returned user object
        const { password: _, ...userWithoutPassword } = user;
        return done(null, userWithoutPassword);
      } catch (error) {
        return done(error);
      }
    }
  )
);

// JWT Strategy for protected routes
passport.use(
  new JwtStrategy(
    {
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET,
      algorithms: ['HS256'],
    },
    async (payload, done) => {
      try {
        const user = await findUserById(payload.sub);
        
        if (!user) {
          return done(null, false);
        }

        return done(null, user);
      } catch (error) {
        return done(error);
      }
    }
  )
);

export default passport;

JWT Utility Functions

// src/utils/jwt.js
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';

export function generateAccessToken(user) {
  return jwt.sign(
    {
      sub: user.id,
      email: user.email,
      role: user.role,
    },
    JWT_SECRET,
    {
      expiresIn: ACCESS_TOKEN_EXPIRY,
      algorithm: 'HS256',
    }
  );
}

export function generateRefreshToken(user) {
  return jwt.sign(
    {
      sub: user.id,
      type: 'refresh',
    },
    JWT_SECRET,
    {
      expiresIn: REFRESH_TOKEN_EXPIRY,
      algorithm: 'HS256',
    }
  );
}

export function verifyToken(token) {
  try {
    return jwt.verify(token, JWT_SECRET);
  } catch (error) {
    return null;
  }
}

export function generateTokenPair(user) {
  return {
    accessToken: generateAccessToken(user),
    refreshToken: generateRefreshToken(user),
    expiresIn: 900, // 15 minutes in seconds
  };
}

Authentication Routes

// src/routes/auth.js
import express from 'express';
import passport from 'passport';
import bcrypt from 'bcryptjs';
import { createUser, findUserByEmail, saveRefreshToken } from '../models/User.js';
import { generateTokenPair, verifyToken } from '../utils/jwt.js';

const router = express.Router();

// Register new user
router.post('/register', async (req, res) => {
  try {
    const { email, password, name } = req.body;

    // Validate input
    if (!email || !password || !name) {
      return res.status(400).json({ error: 'All fields are required' });
    }

    if (password.length < 8) {
      return res.status(400).json({ error: 'Password must be at least 8 characters' });
    }

    // Check if user exists
    const existingUser = await findUserByEmail(email.toLowerCase());
    if (existingUser) {
      return res.status(409).json({ error: 'Email already registered' });
    }

    // Hash password
    const hashedPassword = await bcrypt.hash(password, 12);

    // Create user
    const user = await createUser({
      email: email.toLowerCase(),
      password: hashedPassword,
      name,
      role: 'user',
    });

    // Generate tokens
    const tokens = generateTokenPair(user);
    await saveRefreshToken(user.id, tokens.refreshToken);

    res.status(201).json({
      message: 'Registration successful',
      user: { id: user.id, email: user.email, name: user.name },
      ...tokens,
    });
  } catch (error) {
    console.error('Registration error:', error);
    res.status(500).json({ error: 'Registration failed' });
  }
});

// Login
router.post('/login', (req, res, next) => {
  passport.authenticate('local', { session: false }, async (err, user, info) => {
    if (err) {
      return res.status(500).json({ error: 'Authentication error' });
    }

    if (!user) {
      return res.status(401).json({ error: info?.message || 'Invalid credentials' });
    }

    try {
      const tokens = generateTokenPair(user);
      await saveRefreshToken(user.id, tokens.refreshToken);

      res.json({
        message: 'Login successful',
        user: { id: user.id, email: user.email, name: user.name },
        ...tokens,
      });
    } catch (error) {
      res.status(500).json({ error: 'Token generation failed' });
    }
  })(req, res, next);
});

// Refresh token
router.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  if (!refreshToken) {
    return res.status(400).json({ error: 'Refresh token required' });
  }

  try {
    const payload = verifyToken(refreshToken);
    
    if (!payload || payload.type !== 'refresh') {
      return res.status(401).json({ error: 'Invalid refresh token' });
    }

    const user = await findUserById(payload.sub);
    
    if (!user) {
      return res.status(401).json({ error: 'User not found' });
    }

    const tokens = generateTokenPair(user);
    await saveRefreshToken(user.id, tokens.refreshToken);

    res.json(tokens);
  } catch (error) {
    res.status(401).json({ error: 'Token refresh failed' });
  }
});

export default router;

Authentication Middleware

// src/middleware/auth.js
import passport from 'passport';

export const authenticate = passport.authenticate('jwt', { session: false });

export const authorize = (...allowedRoles) => {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    next();
  };
};

export const optionalAuth = (req, res, next) => {
  passport.authenticate('jwt', { session: false }, (err, user) => {
    if (user) {
      req.user = user;
    }
    next();
  })(req, res, next);
};

Protected Routes Example

// src/routes/users.js
import express from 'express';
import { authenticate, authorize } from '../middleware/auth.js';

const router = express.Router();

// Get current user profile - requires authentication
router.get('/me', authenticate, (req, res) => {
  res.json({
    id: req.user.id,
    email: req.user.email,
    name: req.user.name,
    role: req.user.role,
  });
});

// Update profile - requires authentication
router.put('/me', authenticate, async (req, res) => {
  const { name } = req.body;
  const updatedUser = await updateUser(req.user.id, { name });
  res.json(updatedUser);
});

// Admin only route
router.get('/all', authenticate, authorize('admin'), async (req, res) => {
  const users = await getAllUsers();
  res.json(users);
});

export default router;

Secure Token Storage

How you store tokens on the client affects security significantly.

// For web applications - use httpOnly cookies for refresh tokens
router.post('/login', async (req, res) => {
  // ... authentication logic
  
  const tokens = generateTokenPair(user);
  
  // Set refresh token as httpOnly cookie
  res.cookie('refreshToken', tokens.refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  });
  
  // Only send access token in response body
  res.json({
    accessToken: tokens.accessToken,
    expiresIn: tokens.expiresIn,
  });
});

Real-World Production Scenario

Consider a SaaS application with three user roles: admin, manager, and user. Each role has different permissions across the platform. The authentication system uses Passport with JWT for stateless API access.

Access tokens expire after 15 minutes to limit exposure if compromised. Refresh tokens stored in httpOnly cookies last 7 days. When users make requests, the JWT middleware validates the token and attaches role information. Route-level authorization checks ensure users can only access permitted resources.

The team stores refresh tokens in a database to enable revocation. When users log out or change passwords, all their refresh tokens are invalidated. Rate limiting on the login endpoint prevents brute-force attacks. Failed login attempts are logged and monitored for security analysis.

Best Practices for Passport and JWT

Store JWT secrets in environment variables, never in code. Use HTTPS in production to protect tokens in transit. Set appropriate token expiration times to balance security and usability. Use httpOnly cookies for refresh tokens in web applications. Implement token refresh flows for seamless user experience. Hash passwords with bcrypt using a cost factor of 12 or higher. Validate and sanitize all input before processing.

When to Use Passport and JWT

The Passport + JWT stack is a strong choice for mobile APIs that require stateless authentication. Microservices that share a central identity provider benefit from JWT’s self-contained nature. Applications needing flexibility in authentication flows can leverage Passport’s strategy system. Teams that want full control over user models and logic gain that control without framework constraints.

When NOT to Use This Approach

Applications requiring server-side session management may find JWT unnecessarily complex. Systems needing immediate token revocation require additional infrastructure with JWT. Simple applications might benefit from managed authentication services like Auth0 or Firebase Auth instead of building custom solutions.

Common Mistakes

Storing tokens in localStorage exposes them to XSS attacks. Use httpOnly cookies for refresh tokens and keep access tokens in memory.

Using weak or short JWT secrets makes tokens vulnerable to brute-force attacks. Use at least 256-bit random secrets.

Not validating token expiration allows expired tokens to grant access. Always check the exp claim.

Conclusion

Using Passport and JWT together provides a secure and flexible authentication workflow for Express applications. This approach offers clear control over credential validation, token management, and API protection, making it a strong choice for modern backend systems. By implementing proper token storage, refresh flows, and role-based authorization, you can build authentication systems that scale securely.

If you want to explore more hands-on backend techniques, read “Building REST APIs with Django Rest Framework.” For developers interested in modern real-time features, see “Real-Time Notifications with Socket.io and Redis.” You can also learn more from the Passport documentation and the JWT specification. When implemented correctly, Passport and JWT help you build secure authentication flows that scale smoothly and stay easy to maintain.

Leave a Comment