Implementing User Authentication in Express

Understanding User Authentication

Authentication is the process of verifying who a user is, while authorization is the process of verifying what they have access to. In this guide, we'll focus on building a secure authentication system for our Express application.

Building a robust authentication system involves several key components:

Let's approach this systematically using George Polya's problem-solving method:

Step 1: Understand the Problem

We need to create a secure user authentication system that will:

Step 2: Devise a Plan

  1. Set up a database schema for the Users table
  2. Create a User model with proper validation
  3. Add model scopes to protect sensitive information
  4. Seed the database with test users
  5. Create authentication utility functions
  6. Implement authentication middleware
  7. Test the authentication system

Step 3: Execute the Plan

Let's start implementing our user authentication system:

Database Schema for Users

Understanding the User Schema

The foundation of our authentication system is the Users table. This table will store essential user information, including:

Let's define this schema using Sequelize, a popular ORM for Node.js:

Creating the Users Migration

First, we'll generate a migration file that will create our Users table:


// Command to generate the migration
npx sequelize model:generate --name User --attributes username:string,email:string,hashedPassword:string
            

This command creates two files:

Let's update the migration file to include all the constraints we need:


// backend/db/migrations/XXXXXX-create-user.js
'use strict';

let options = {};
if (process.env.NODE_ENV === 'production') {
  options.schema = process.env.SCHEMA;  // define schema in options
}

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Users', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      username: {
        type: Sequelize.STRING(30),
        allowNull: false,
        unique: true
      },
      email: {
        type: Sequelize.STRING(256),
        allowNull: false,
        unique: true
      },
      hashedPassword: {
        type: Sequelize.STRING.BINARY,
        allowNull: false
      },
      firstName: {
        type: Sequelize.STRING,
        allowNull: false
      },
      lastName: {
        type: Sequelize.STRING,
        allowNull: false
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
        defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
        defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
      }
    }, options);
  },

  async down(queryInterface, Sequelize) {
    options.tableName = "Users";
    return queryInterface.dropTable(options);
  }
};
            

This migration file defines the structure of our Users table with several important constraints:

The options object with schema handling is important for PostgreSQL in production, especially when deploying to platforms like Render.

Running the Migration

Let's run this migration to create the Users table:


// Command to run the migration
npx dotenv sequelize db:migrate
            

You can verify the migration was successful by checking your database:


// For SQLite (development)
sqlite3 db/dev.db ".schema Users"
            

User Model and Validations

Creating the User Model

The User model defines how we interact with the Users table from our code. It's important to add validations to ensure data integrity and security:


// backend/db/models/user.js
'use strict';
const { Model, Validator } = require('sequelize');
const bcrypt = require('bcryptjs');

module.exports = (sequelize, DataTypes) => {
  class User extends Model {
    // This method will hash a password before a User is created
    static async signup({ username, email, password, firstName, lastName }) {
      const hashedPassword = bcrypt.hashSync(password);
      const user = await User.create({
        username,
        email,
        hashedPassword,
        firstName,
        lastName
      });
      return user;
    }
    
    // This method validates a login attempt
    static async login({ credential, password }) {
      const user = await User.unscoped().findOne({
        where: {
          [sequelize.Op.or]: {
            username: credential,
            email: credential
          }
        }
      });
      
      if (user && bcrypt.compareSync(password, user.hashedPassword.toString())) {
        return user;
      }
      return null;
    }
    
    // Define associations to other models
    static associate(models) {
      // define association here
    }
    
    // Helper method to return a safe user object
    toSafeObject() {
      const { id, username, email, firstName, lastName } = this;
      return { id, username, email, firstName, lastName };
    }
  }


  User.init(
    {
      username: {
        type: DataTypes.STRING,
        allowNull: false,
        unique: true,
        validate: {
          len: [4, 30],
          isNotEmail(value) {
            if (Validator.isEmail(value)) {
              throw new Error('Cannot be an email.');
            }
          },
        },
      },
      email: {
        type: DataTypes.STRING,
        allowNull: false,
        unique: true,
        validate: {
          len: [3, 256],
          isEmail: true,
        },
      },
      hashedPassword: {
        type: DataTypes.STRING.BINARY,
        allowNull: false,
        validate: {
          len: [60, 60],
        },
      },
      firstName: {
        type: DataTypes.STRING,
        allowNull: false,
        validate: {
          len: [1, 50],
        },
      },
      lastName: {
        type: DataTypes.STRING,
        allowNull: false,
        validate: {
          len: [1, 50],
        },
      },
    },
    {
      sequelize,
      modelName: 'User',
      defaultScope: {
        attributes: {
          exclude: ['hashedPassword', 'email', 'createdAt', 'updatedAt'],
        },
      },
      scopes: {
        // Include email and timestamps but not hashedPassword
        currentUser: {
          attributes: { exclude: ['hashedPassword'] }
        },
        // Include only the id and username
        loginUser: {
          attributes: {}
        }
      }
    }
  );
  return User;
};
            

This model includes several important features:

Understanding Model Scopes

Model scopes help protect sensitive user information by controlling which fields are included in query results. We've defined:

These scopes will be used in different contexts to ensure that sensitive information is only accessible when needed.

Validation Logic

Our model includes several important validations:

The custom isNotEmail validator prevents users from using email addresses as usernames, which helps avoid confusion in the authentication system.

Seeding Test Users

Creating a User Seed File

To test our authentication system, we'll create seed data with demo users:


// Command to generate the seed file
npx sequelize seed:generate --name demo-user
            

Now, let's update the seed file to create test users:


// backend/db/seeders/XXXXXX-demo-user.js
'use strict';

const bcrypt = require('bcryptjs');

let options = {};
if (process.env.NODE_ENV === 'production') {
  options.schema = process.env.SCHEMA;  // define schema in options
}

module.exports = {
  async up(queryInterface, Sequelize) {
    options.tableName = 'Users';
    return queryInterface.bulkInsert(options, [
      {
        email: 'demo@user.io',
        username: 'Demo-lition',
        hashedPassword: bcrypt.hashSync('password'),
        firstName: 'Demo',
        lastName: 'User',
        createdAt: new Date(),
        updatedAt: new Date()
      },
      {
        email: 'user1@user.io',
        username: 'FakeUser1',
        hashedPassword: bcrypt.hashSync('password2'),
        firstName: 'Fake',
        lastName: 'User One',
        createdAt: new Date(),
        updatedAt: new Date()
      },
      {
        email: 'user2@user.io',
        username: 'FakeUser2',
        hashedPassword: bcrypt.hashSync('password3'),
        firstName: 'Fake',
        lastName: 'User Two',
        createdAt: new Date(),
        updatedAt: new Date()
      }
    ], {});
  },

  async down(queryInterface, Sequelize) {
    options.tableName = 'Users';
    const Op = Sequelize.Op;
    return queryInterface.bulkDelete(options, {
      username: { [Op.in]: ['Demo-lition', 'FakeUser1', 'FakeUser2'] }
    }, {});
  }
};
            

This seed file does several important things:

Running the Seed File

Let's seed our database with these test users:


// Command to run the seed
npx dotenv sequelize db:seed:all
            

We can verify the seeding was successful by querying the database:


// For SQLite (development)
sqlite3 db/dev.db "SELECT username, email FROM Users"
            

This should show our three test users, confirming that the seed was successful.

Authentication Utilities

Now that we have our User model and database set up, we need to create utilities for handling authentication. These utilities will:

Creating the Auth Utilities

Let's create a new file for our authentication utilities:


// backend/utils/auth.js
const jwt = require('jsonwebtoken');
const { jwtConfig } = require('../config');
const { User } = require('../db/models');

const { secret, expiresIn } = jwtConfig;

// Sends a JWT Cookie
const setTokenCookie = (res, user) => {
  // Create the token
  const safeUser = {
    id: user.id,
    email: user.email,
    username: user.username,
    firstName: user.firstName,
    lastName: user.lastName
  };
  
  const token = jwt.sign(
    { data: safeUser },
    secret,
    { expiresIn: parseInt(expiresIn) } // 604,800 seconds = 1 week
  );

  const isProduction = process.env.NODE_ENV === "production";

  // Set the token cookie
  res.cookie('token', token, {
    maxAge: expiresIn * 1000, // maxAge in milliseconds
    httpOnly: true,
    secure: isProduction,
    sameSite: isProduction && "Lax"
  });

  return token;
};

// Restore user from JWT token
const restoreUser = (req, res, next) => {
  // token parsed from cookies
  const { token } = req.cookies;
  req.user = null;

  return jwt.verify(token, secret, null, async (err, jwtPayload) => {
    if (err) {
      return next();
    }

    try {
      const { id } = jwtPayload.data;
      req.user = await User.findByPk(id, {
        attributes: ['id', 'email', 'username', 'firstName', 'lastName', 'createdAt', 'updatedAt']
      });
    } catch (e) {
      res.clearCookie('token');
      return next();
    }

    if (!req.user) res.clearCookie('token');

    return next();
  });
};

// Require authentication middleware
const requireAuth = function (req, _res, next) {
  if (req.user) return next();

  const err = new Error('Authentication required');
  err.title = 'Authentication required';
  err.errors = { message: 'Authentication required' };
  err.status = 401;
  return next(err);
};

module.exports = { setTokenCookie, restoreUser, requireAuth };
            

Let's examine each utility function in detail:

Understanding setTokenCookie

The setTokenCookie function:

  1. Creates a "safe" user object with non-sensitive information
  2. Signs a JWT token with this user data using the secret key
  3. Sets an HTTP-only cookie containing the token
  4. Configures cookie security based on the environment

This function will be used whenever a user logs in or signs up, establishing their authenticated session.

Understanding restoreUser

The restoreUser middleware:

  1. Extracts the JWT token from the request cookies
  2. Verifies the token's validity using the secret key
  3. If valid, retrieves the corresponding user from the database
  4. Attaches the user object to the request (req.user)
  5. Clears the cookie if the token is invalid or the user doesn't exist

This middleware will be used for all API routes to automatically restore the user's session if they have a valid token cookie.

Understanding requireAuth

The requireAuth middleware:

  1. Checks if req.user exists (set by restoreUser)
  2. If it exists, allows the request to continue
  3. If not, creates an authentication error and passes it to the error handlers

This middleware will be used for routes that should only be accessible to authenticated users.

Testing Authentication Utilities

Before we implement the authentication routes, let's test our authentication utilities to ensure they're working correctly.

Setting Up Test Routes

Let's add some temporary test routes to our API router:


// backend/routes/api/index.js
// ... previous imports
const { setTokenCookie, restoreUser, requireAuth } = require('../../utils/auth.js');
const { User } = require('../../db/models');

// ... Connect restoreUser middleware
router.use(restoreUser);

// Test route for setting a token cookie
router.get('/set-token-cookie', async (_req, res) => {
  const user = await User.findOne({
    where: { username: 'Demo-lition' }
  });
  setTokenCookie(res, user);
  return res.json({ user: user });
});

// Test route for restoring a user from a token
router.get('/restore-user', (req, res) => {
  return res.json(req.user);
});

// Test route for requiring authentication
router.get('/require-auth', requireAuth, (req, res) => {
  return res.json(req.user);
});

// ... rest of the file
            

These test routes will help us verify each component of our authentication system:

Testing setTokenCookie

Let's test our token cookie setting functionality:

  1. Navigate to http://localhost:8000/api/set-token-cookie in your browser
  2. Check your browser's developer tools to verify a token cookie was set
  3. Examine the response to confirm it contains the user information

Testing restoreUser

Now, let's test restoring the user from the token cookie:

  1. After setting the token cookie, navigate to http://localhost:8000/api/restore-user
  2. Verify that the response includes the user information
  3. Try removing the token cookie and refreshing to see a null user

Testing requireAuth

Finally, let's test the authentication requirement:

  1. With the token cookie set, navigate to http://localhost:8000/api/require-auth
  2. Verify that you see the user information
  3. Remove the token cookie and try again
  4. You should see an "Authentication required" error

Once we've verified that all our authentication utilities are working correctly, we can remove these test routes and proceed with implementing the actual authentication routes.

Connecting Authentication to the API

Now that we have our authentication utilities tested and working, we need to connect them to our API router to protect our endpoints.

Adding restoreUser to the API Router

Let's ensure that the restoreUser middleware is connected to all API routes:


// backend/routes/api/index.js
const router = require("express").Router();
const { restoreUser } = require("../../utils/auth.js");

// Connect restoreUser middleware to the API router
// If current user session is valid, set req.user to the user in the database
// If current user session is not valid, set req.user to null
router.use(restoreUser);

// ... other route connections

module.exports = router;
            

With this middleware in place, every API route will automatically have access to the current user via req.user if they have a valid session.

Using requireAuth for Protected Routes

For routes that should only be accessible to authenticated users, we can use the requireAuth middleware:


// Example of a protected route
router.post('/protected-resource', requireAuth, (req, res) => {
  // Only authenticated users can reach this code
  // req.user is guaranteed to exist
  return res.json({ message: 'You have access to this resource', user: req.user });
});
            

By adding requireAuth as middleware to specific routes, we ensure that only authenticated users can access those resources.

Understanding the Authentication Flow

With our authentication system in place, this is how the flow works:

  1. When a user logs in or signs up, we call setTokenCookie to establish their session
  2. On subsequent requests, the restoreUser middleware automatically checks for a valid token
  3. If the token is valid, req.user is populated with the user's information
  4. For protected routes, the requireAuth middleware verifies that req.user exists
  5. If the user is not authenticated, they receive a 401 Unauthorized error

This flow allows for a stateless authentication system where the server doesn't need to maintain session information in memory.

Security Considerations

Step 4: Review the Solution

Let's review our authentication implementation and discuss important security considerations:

Password Security

Our implementation uses several best practices for password security:

Token Security

Our JWT implementation follows these security practices:

Information Exposure

We protect sensitive user information in multiple ways:

Additional Security Measures

To further enhance security, consider implementing:

Real-World Authentication Systems

Understanding JWT-Based Authentication

Our implementation uses JWT-based authentication, which has become a standard in modern web applications. Here's why:

Major services like Auth0, Firebase Authentication, and many enterprise applications use JWT-based authentication for these reasons.

JWT vs. Session-Based Authentication

It's worth understanding how JWT authentication compares to traditional session-based authentication:

JWT Authentication (our approach):

Session-Based Authentication:

For many modern applications, especially those with microservice architectures, JWT authentication provides a better balance of security and scalability.

OAuth and Social Authentication

Many production applications extend basic authentication with OAuth for social login (Google, Facebook, GitHub, etc.). This could be a future enhancement to our system:

Implementing OAuth would involve adding new routes for initiating OAuth flows and handling provider callbacks.

Conclusion

We've successfully implemented a robust user authentication system for our Express application. Our implementation includes:

This authentication system provides a solid foundation for your Express application, ensuring that user accounts are secure and that restricted resources are properly protected.

Next Steps

To complete your authentication system, you should:

Remember that authentication is a critical security component of your application. Always follow the latest security best practices and keep your dependencies updated to address any discovered vulnerabilities.

As your application grows, you may need to extend this authentication system with more advanced features like role-based access control, social authentication, or multi-factor authentication. The foundation we've built here will make those extensions easier to implement.

Extending Your Authentication System

Role-Based Access Control

If your application needs different permission levels, you can add a role field to your User model:


// In User model
role: {
  type: DataTypes.ENUM('user', 'moderator', 'admin'),
  allowNull: false,
  defaultValue: 'user'
}
            

Then create middleware to check for specific roles:


// Role check middleware
const requireRole = (role) => (req, res, next) => {
  if (!req.user) {
    const err = new Error('Authentication required');
    err.status = 401;
    return next(err);
  }
  
  if (req.user.role !== role) {
    const err = new Error('Forbidden');
    err.status = 403;
    return next(err);
  }
  
  next();
};

// Use in routes
router.delete('/admin/users/:id', requireAuth, requireRole('admin'), (req, res) => {
  // Admin-only code
});
            

Email Verification

To add email verification:

  1. Add emailVerified and verificationToken fields to your User model
  2. Generate a verification token when a user signs up
  3. Send an email with a verification link
  4. Create a route to handle verification
  5. Optionally restrict certain features for unverified users

Password Reset

For password reset functionality:

  1. Add resetToken and resetTokenExpiry fields to your User model
  2. Create a route to request a password reset (generates token, sends email)
  3. Create a route to process the reset with a valid token
  4. Implement frontend forms for requesting and processing resets

Remember Me Functionality

To implement a "remember me" option:

  1. Add a remember parameter to your login route
  2. Adjust the token expiration time based on this parameter
  3. Update your login form to include a remember checkbox

// In setTokenCookie function
const tokenExpirationTime = remember ? 30 * 24 * 60 * 60 : 24 * 60 * 60; // 30 days or 1 day

const token = jwt.sign(
  { data: safeUser },
  secret,
  { expiresIn: tokenExpirationTime }
);

res.cookie('token', token, {
  maxAge: tokenExpirationTime * 1000,
  httpOnly: true,
  // other options
});