Implementing User Authentication Routes in Express

Understanding Authentication Routes

Now that we have our user model, database structure, and authentication utilities in place, it's time to create the API routes that will allow users to sign up, log in, log out, and retrieve their session information. These routes will form the core of our authentication system and provide the interface that client applications will use.

In this guide, we'll implement four essential authentication routes:

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

Step 1: Understand the Problem

We need to create RESTful API endpoints that will:

Step 2: Devise a Plan

  1. Set up route files for session and user resources
  2. Implement the login route (POST /api/session)
  3. Implement the logout route (DELETE /api/session)
  4. Implement the signup route (POST /api/users)
  5. Implement the get current session route (GET /api/session)
  6. Connect these route files to the main API router
  7. Test all authentication routes thoroughly

Step 3: Execute the Plan

Let's start implementing our authentication routes:

Setting Up Route Files

We'll organize our authentication routes into two separate files:

This separation follows REST principles by organizing routes around resources (sessions and users).

Creating the Session Router

First, let's create a file for our session routes:


// File: backend/routes/api/session.js
const express = require('express');
const { Op } = require('sequelize');
const bcrypt = require('bcryptjs');

const { setTokenCookie, restoreUser } = require('../../utils/auth');
const { User } = require('../../db/models');

const router = express.Router();

module.exports = router;
            

This file imports the necessary modules and utilities:

Creating the Users Router

Similarly, let's create a file for our user routes:


// File: backend/routes/api/users.js
const express = require('express');
const bcrypt = require('bcryptjs');

const { setTokenCookie, requireAuth } = require('../../utils/auth');
const { User } = require('../../db/models');

const router = express.Router();

module.exports = router;
            

The imports are similar to the session router, but note that we import requireAuth instead of restoreUser since user routes might need authentication checks.

Connecting Routers to the API

Now, let's connect these routers to our main API router:


// File: backend/routes/api/index.js
const router = require('express').Router();
const sessionRouter = require('./session.js');
const usersRouter = require('./users.js');
const { restoreUser } = require("../../utils/auth.js");

// Connect restoreUser middleware to the API router
router.use(restoreUser);

// Connect the session and users routers
router.use('/session', sessionRouter);
router.use('/users', usersRouter);

// ... other routes

module.exports = router;
            

This setup creates the following route paths:

The restoreUser middleware is applied to all API routes, ensuring that req.user is populated if a valid session exists.

Implementing the Login Route

The login route is one of the most critical parts of our authentication system. It needs to validate user credentials and establish a session if they're valid.

Login Route Implementation

Let's add the login route to our session router:


// File: backend/routes/api/session.js
// ... previous code

// Log in
router.post('/', async (req, res, next) => {
  // Extract credentials from request body
  const { credential, password } = req.body;

  // Find the user with the provided credential (username or email)
  const user = await User.unscoped().findOne({
    where: {
      [Op.or]: {
        username: credential,
        email: credential
      }
    }
  });

  // Validate user credentials
  if (!user || !bcrypt.compareSync(password, user.hashedPassword.toString())) {
    const err = new Error('Login failed');
    err.status = 401;
    err.title = 'Login failed';
    err.errors = { credential: 'The provided credentials were invalid.' };
    return next(err);
  }

  // Create a safe user object without sensitive information
  const safeUser = {
    id: user.id,
    email: user.email,
    username: user.username,
    firstName: user.firstName,
    lastName: user.lastName
  };

  // Set the JWT cookie to establish a session
  await setTokenCookie(res, safeUser);

  // Return the user information
  return res.json({
    user: safeUser
  });
});
            

Let's break down this implementation step by step:

  1. Route Definition: We use POST /api/session following RESTful conventions (creating a new session)
  2. Credential Extraction: We extract the credential (username or email) and password from the request body
  3. User Lookup: We use User.unscoped() to bypass the default scope, allowing us to access the hashedPassword field
  4. Flexible Authentication: We allow users to log in with either their username or email, using Sequelize's Op.or operator
  5. Password Verification: We use bcrypt to securely verify the password
  6. Error Handling: If authentication fails, we create a descriptive error
  7. Session Establishment: If authentication succeeds, we call setTokenCookie to establish a session
  8. Response: We return a JSON response with the user's non-sensitive information

The error message deliberately doesn't specify whether the username/email was not found or the password was incorrect. This is a security best practice that prevents attackers from determining whether a specific username exists.

Testing the Login Route

Let's test our login route with both valid and invalid credentials:


// First, get a CSRF token
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    const csrfToken = data['XSRF-Token'];
    
    // Test login with valid credentials
    return fetch('/api/session', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'XSRF-TOKEN': csrfToken
      },
      body: JSON.stringify({
        credential: 'Demo-lition',  // or 'demo@user.io'
        password: 'password'
      })
    });
  })
  .then(res => res.json())
  .then(data => console.log('Login response:', data));
            

You should see a successful response with the user's information. Now, let's test with invalid credentials:


// First, get a CSRF token
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    const csrfToken = data['XSRF-Token'];
    
    // Test login with invalid credentials
    return fetch('/api/session', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'XSRF-TOKEN': csrfToken
      },
      body: JSON.stringify({
        credential: 'Demo-lition',
        password: 'wrongpassword'
      })
    });
  })
  .then(res => res.json())
  .then(data => console.log('Invalid login response:', data))
  .catch(err => console.error('Login error:', err));
            

You should see an error response indicating that the credentials were invalid.

Implementing the Logout Route

The logout route is relatively simple - it just needs to clear the session token cookie.

Logout Route Implementation

Let's add the logout route to our session router:


// File: backend/routes/api/session.js
// ... previous code

// Log out
router.delete('/', (_req, res) => {
  // Clear the session token cookie
  res.clearCookie('token');
  
  // Return a success message
  return res.json({ message: 'success' });
});
            

This implementation is straightforward:

  1. Route Definition: We use DELETE /api/session following RESTful conventions (deleting a session)
  2. Cookie Clearing: We call res.clearCookie('token') to remove the token cookie
  3. Response: We return a simple success message

Note the underscore prefix on _req, indicating that we're not using the request parameter. This is a common convention to avoid linting warnings about unused variables.

Testing the Logout Route

Let's test our logout route:


// First, get a CSRF token
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    const csrfToken = data['XSRF-Token'];
    
    // Test logout
    return fetch('/api/session', {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json',
        'XSRF-TOKEN': csrfToken
      }
    });
  })
  .then(res => res.json())
  .then(data => console.log('Logout response:', data));
            

You should see a success message, and the token cookie should be removed from your browser. You can verify this by checking your browser's developer tools.

Implementing the Signup Route

The signup route allows new users to create accounts. It needs to validate the provided information, create a new user record, and establish a session.

Signup Route Implementation

Let's add the signup route to our users router:


// File: backend/routes/api/users.js
// ... previous code

// Sign up
router.post('/', async (req, res) => {
  // Extract user information from request body
  const { email, password, username, firstName, lastName } = req.body;
  
  // Hash the password for storage
  const hashedPassword = bcrypt.hashSync(password);
  
  // Create the user in the database
  const user = await User.create({
    email,
    username,
    hashedPassword,
    firstName,
    lastName
  });

  // Create a safe user object without sensitive information
  const safeUser = {
    id: user.id,
    email: user.email,
    username: user.username,
    firstName: user.firstName,
    lastName: user.lastName
  };

  // Set the JWT cookie to establish a session
  await setTokenCookie(res, safeUser);

  // Return the user information
  return res.json({
    user: safeUser
  });
});
            

Let's analyze this implementation:

  1. Route Definition: We use POST /api/users following RESTful conventions (creating a new user)
  2. Data Extraction: We extract the necessary information from the request body
  3. Password Hashing: We use bcrypt to securely hash the password before storage
  4. User Creation: We create a new user record in the database
  5. Safe User Object: We create an object with only non-sensitive user information
  6. Session Establishment: We call setTokenCookie to automatically log in the new user
  7. Response: We return a JSON response with the user's information

This implementation follows the common pattern of automatically logging in users after signup, providing a smoother user experience.

Error Handling in Signup

Note that our route handler doesn't include explicit error handling for issues like duplicate usernames or emails. This is because we've set up Sequelize validation in our User model and configured the error handling middleware in our application. If validation fails, Sequelize will throw an error that will be caught by our error handlers.

Testing the Signup Route

Let's test our signup route with a new user:


// First, get a CSRF token
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    const csrfToken = data['XSRF-Token'];
    
    // Test signup with a new user
    return fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'XSRF-TOKEN': csrfToken
      },
      body: JSON.stringify({
        email: 'newuser@example.com',
        username: 'NewUser',
        password: 'password123',
        firstName: 'New',
        lastName: 'User'
      })
    });
  })
  .then(res => res.json())
  .then(data => console.log('Signup response:', data));
            

You should see a successful response with the new user's information. Now, let's test with a duplicate username:


// First, get a CSRF token
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    const csrfToken = data['XSRF-Token'];
    
    // Test signup with a duplicate username
    return fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'XSRF-TOKEN': csrfToken
      },
      body: JSON.stringify({
        email: 'another@example.com',
        username: 'NewUser',  // This username already exists
        password: 'password123',
        firstName: 'Another',
        lastName: 'User'
      })
    });
  })
  .then(res => res.json())
  .then(data => console.log('Duplicate username response:', data))
  .catch(err => console.error('Signup error:', err));
            

You should see a validation error response indicating that the username is already in use.

Implementing the Get Current Session Route

The get current session route allows clients to retrieve information about the current logged-in user, if any. This is especially useful after page refreshes or when a client first loads.

Get Current Session Route Implementation

Let's add the get current session route to our session router:


// File: backend/routes/api/session.js
// ... previous code

// Get current session user
router.get('/', (req, res) => {
  // Get the user from the request (set by restoreUser middleware)
  const { user } = req;
  
  if (user) {
    // Create a safe user object without sensitive information
    const safeUser = {
      id: user.id,
      email: user.email,
      username: user.username,
      firstName: user.firstName,
      lastName: user.lastName
    };
    
    // Return the user information
    return res.json({
      user: safeUser
    });
  } else {
    // If no user is logged in, return null
    return res.json({ user: null });
  }
});
            

This implementation is straightforward but important:

  1. Route Definition: We use GET /api/session following RESTful conventions (retrieving session information)
  2. User Extraction: We get the user from req.user, which is populated by the restoreUser middleware
  3. Conditional Response: If a user is logged in, we return their information; otherwise, we return null
  4. Safe User Object: We create an object with only non-sensitive user information

This route doesn't require any request body or parameters - it simply uses the JWT token cookie that's automatically sent with the request.

Testing the Get Current Session Route

Let's test our get current session route both with and without a logged-in user:


// Test with a logged-in user (run this after successful login)
fetch('/api/session')
  .then(res => res.json())
  .then(data => console.log('Current session (logged in):', data));

// Test without a logged-in user (run this after logout)
fetch('/api/session')
  .then(res => res.json())
  .then(data => console.log('Current session (not logged in):', data));
            

When logged in, you should see your user information. When not logged in, you should see { user: null }.

Authentication Flow Review

Step 4: Review the Solution

Now that we've implemented all our authentication routes, let's review the complete authentication flow:

Signup Flow

  1. Client sends a POST request to /api/users with username, email, password, firstName, and lastName
  2. Server validates the input and checks for uniqueness constraints
  3. If valid, server hashes the password and creates a new user record
  4. Server creates a JWT token with the user's information and sets it as a cookie
  5. Server returns the user's information (excluding sensitive data)
  6. Client is now authenticated and can access protected resources

Login Flow

  1. Client sends a POST request to /api/session with credential (username or email) and password
  2. Server looks up the user by username or email
  3. Server verifies the password against the stored hash
  4. If valid, server creates a JWT token with the user's information and sets it as a cookie
  5. Server returns the user's information (excluding sensitive data)
  6. Client is now authenticated and can access protected resources

Logout Flow

  1. Client sends a DELETE request to /api/session
  2. Server clears the token cookie
  3. Server returns a success message
  4. Client is no longer authenticated

Session Restoration Flow

  1. Client sends a GET request to /api/session (automatically including the token cookie if it exists)
  2. Server verifies the token and extracts the user ID
  3. Server looks up the user by ID
  4. If found, server returns the user's information (excluding sensitive data)
  5. If not found or no token, server returns null
  6. Client now knows whether a user is logged in and who they are

Security Considerations

Our implementation includes several security best practices:

Common Authentication Patterns and Extensions

Now that we've implemented the core authentication routes, let's explore some common patterns and possible extensions for more advanced authentication systems.

Remember Me Functionality

Many authentication systems offer a "Remember Me" option that keeps users logged in for longer periods. To implement this:


// Login route with remember me option
router.post('/', async (req, res, next) => {
  const { credential, password, remember = false } = req.body;
  
  // ... authentication logic
  
  // Set different expiration times based on the remember option
  const expiresIn = remember ? 30 * 24 * 60 * 60 : 24 * 60 * 60; // 30 days or 1 day
  
  await setTokenCookie(res, safeUser, expiresIn);
  
  // ... rest of the route
});
            

This would require modifying the setTokenCookie function to accept a custom expiration time.

Multiple Authentication Methods

For applications that support multiple login methods (like username/password, social login, etc.), you might use different endpoints or parameters:


// Login with username/password
router.post('/password', passwordLoginHandler);

// Login with Google OAuth
router.post('/google', googleLoginHandler);

// Login with GitHub OAuth
router.post('/github', githubLoginHandler);
            

Each handler would have its own authentication logic but would eventually call setTokenCookie to establish a session.

Password Reset

A common extension is password reset functionality:


// Request password reset
router.post('/password-reset', async (req, res) => {
  const { email } = req.body;
  
  // Generate a reset token and store it with the user
  // Send an email with a reset link
  
  return res.json({ message: 'Reset email sent' });
});

// Process password reset
router.post('/password-reset/:token', async (req, res) => {
  const { token } = req.params;
  const { password } = req.body;
  
  // Verify the token and reset the password
  
  return res.json({ message: 'Password reset successful' });
});
            

This would require adding reset token fields to your User model.

Account Verification

For applications that require email verification:


// Verify email address
router.get('/verify/:token', async (req, res) => {
  const { token } = req.params;
  
  // Find the user with this verification token
  // Mark the user as verified
  
  return res.json({ message: 'Email verified successfully' });
});
            

This would require adding verification fields to your User model.

Frontend Integration

Now that our backend authentication routes are complete, let's briefly discuss how a frontend application would interact with these endpoints.

Typical Frontend Authentication Flow

A typical React or other frontend application would implement:

  1. Signup Form: Collects user information and submits to /api/users
  2. Login Form: Collects credentials and submits to /api/session
  3. Logout Button: Sends a DELETE request to /api/session
  4. Session Restoration: On initial load, calls GET /api/session to check if a user is logged in
  5. Protected Route Components: Redirect to login if no user is logged in

Example Frontend Authentication Client

Here's a simple example of how a frontend might interact with our authentication endpoints:


// Authentication service in a React application
const AuthService = {
  // Get the current user (if logged in)
  async getCurrentUser() {
    const response = await fetch('/api/session');
    const data = await response.json();
    return data.user;
  },
  
  // Log in a user
  async login(credential, password) {
    const response = await fetch('/api/session', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'XSRF-TOKEN': getCSRFToken()
      },
      body: JSON.stringify({ credential, password })
    });
    
    if (!response.ok) {
      throw await response.json();
    }
    
    return response.json();
  },
  
  // Sign up a new user
  async signup(userData) {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'XSRF-TOKEN': getCSRFToken()
      },
      body: JSON.stringify(userData)
    });
    
    if (!response.ok) {
      throw await response.json();
    }
    
    return response.json();
  },
  
  // Log out the current user
  async logout() {
    const response = await fetch('/api/session', {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json',
        'XSRF-TOKEN': getCSRFToken()
      }
    });
    
    return response.json();
  }
};

// Helper function to get the CSRF token from cookies
function getCSRFToken() {
  return document.cookie
    .split('; ')
    .find(row => row.startsWith('XSRF-TOKEN='))
    ?.split('=')[1];
}
            

This service encapsulates the authentication API calls and could be used throughout a frontend application.

Conclusion

We've successfully implemented a complete set of authentication routes for our Express application. These routes provide all the essential functionality for user authentication:

Our implementation follows RESTful conventions, implements security best practices, and is ready to be integrated with a frontend application.

Next Steps

To further enhance your authentication system, consider:

Remember that authentication is a critical security component of your application. Always follow the latest security best practices, keep your dependencies updated, and regularly audit your authentication system for vulnerabilities.

With the authentication routes in place, your Express application now has a solid foundation for user management and protected resources. You can build on this foundation to create a full-featured application with user-specific content and functionality.