Building API Routes for Authentication

Understanding the Problem

In our Express application, we need to create API routes that handle user authentication. These routes will be the interface between the frontend client and our backend authentication system. This is an essential part of any web application that requires user accounts and secured access.

Let's once again apply George Polya's problem-solving approach to build our API routes:

Step 1: Understand the Problem

We need to create REST API endpoints that will:

Step 2: Devise a Plan

  1. Set up an API router structure
  2. Create session-related routes (login, logout, current session)
  3. Create user-related routes (signup)
  4. Implement request validation
  5. Connect these routes to our main application
  6. Test all routes thoroughly

Step 3: Execute the Plan

Let's start implementing our API routes:

Setting Up API Router Structure

Creating the API Router Base

First, we'll create a base router file that will serve as the entry point for all API routes. This router will be mounted at the path /api.


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

// Connect restoreUser middleware to the API router
// This will check for a valid token and set req.user
router.use(restoreUser);

// Test route for verifying API setup and CSRF protection
router.post('/test', function(req, res) {
  res.json({ requestBody: req.body });
});

module.exports = router;
            

This base file does two important things:

Connecting the API Router to the Main Application

Next, we need to connect this API router to our main routes file:


// File: backend/routes/index.js
const express = require('express');
const router = express.Router();
const apiRouter = require('./api');

// CSRF token restoration route
router.get("/api/csrf/restore", (req, res) => {
  const csrfToken = req.csrfToken();
  res.cookie("XSRF-TOKEN", csrfToken);
  res.status(200).json({
    'XSRF-Token': csrfToken
  });
});

// Connect all API routes
router.use('/api', apiRouter);

module.exports = router;
            

This structure gives us a clean separation of concerns: the base routes/index.js handles top-level routes (like CSRF token restoration), while the API-specific routes are organized in the routes/api directory.

Testing the API Router Setup

Before continuing, let's test our API router setup by making a request to the test endpoint:


// First, get a CSRF token
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    // Then, test the API endpoint
    return fetch('/api/test', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'XSRF-TOKEN': data['XSRF-Token']
      },
      body: JSON.stringify({ hello: 'world' })
    });
  })
  .then(res => res.json())
  .then(data => console.log(data));
            

If everything is set up correctly, you should see { requestBody: { hello: 'world' } } logged to the console.

Creating Session Routes

Setting Up the Session Router

Now, let's create a dedicated router for session management. This will handle login, logout, and retrieving the current session:


// 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;
            

Login Route

The login route will accept a credential (username or email) and password, verify them, and establish a session if valid:


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

// Log in
router.post('/', async (req, res, next) => {
  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
      }
    }
  });

  // Verify password and handle invalid 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 token as a cookie
  await setTokenCookie(res, safeUser);

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

The login route demonstrates several important concepts:

Logout Route

The logout route simply clears the session token cookie:


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

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

Get Current Session Route

This route returns information about the current user session, if one exists:


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

// Restore session user
router.get('/', (req, res) => {
  const { user } = req;
  if (user) {
    const safeUser = {
      id: user.id,
      email: user.email,
      username: user.username,
      firstName: user.firstName,
      lastName: user.lastName
    };
    return res.json({
      user: safeUser
    });
  } else return res.json({ user: null });
});
            

This route leverages the restoreUser middleware we applied to all API routes, which sets req.user if a valid session exists.

Creating User Routes

Setting Up the Users Router

Now, let's create a dedicated router for user-related operations, starting with signup:


// 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;
            

Signup Route

The signup route creates a new user and establishes a session:


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

// Sign up
router.post('/', async (req, res) => {
  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 token as a cookie
  await setTokenCookie(res, safeUser);

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

The signup route demonstrates these key concepts:

Connecting the Routers

Now, let's connect these routers to our 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 session and user routers
router.use('/session', sessionRouter);
router.use('/users', usersRouter);

// Test route
router.post('/test', (req, res) => {
  res.json({ requestBody: req.body });
});

module.exports = router;
            

With this structure, our API routes are now organized as follows:

Implementing Request Validation

Creating Validation Middleware

To enhance security and provide better user feedback, let's add validation to our routes using express-validator:


// File: backend/utils/validation.js
const { validationResult } = require('express-validator');

// middleware for formatting errors from express-validator middleware
const handleValidationErrors = (req, _res, next) => {
  const validationErrors = validationResult(req);

  if (!validationErrors.isEmpty()) { 
    const errors = {};
    validationErrors
      .array()
      .forEach(error => errors[error.path] = error.msg);

    const err = Error("Bad request.");
    err.errors = errors;
    err.status = 400;
    err.title = "Bad request.";
    next(err);
  }
  next();
};

module.exports = {
  handleValidationErrors
};
            

Adding Login Validation

Let's add validation to our login route:


// File: backend/routes/api/session.js
// ... (imports)
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');

// ... (other code)

// Validate login inputs
const validateLogin = [
  check('credential')
    .exists({ checkFalsy: true })
    .notEmpty()
    .withMessage('Please provide a valid email or username.'),
  check('password')
    .exists({ checkFalsy: true })
    .withMessage('Please provide a password.'),
  handleValidationErrors
];

// Apply validation to the login route
router.post(
  '/',
  validateLogin,
  async (req, res, next) => {
    // ... (login route handler)
  }
);
            

Adding Signup Validation

Similarly, let's add validation to our signup route:


// File: backend/routes/api/users.js
// ... (imports)
const { check } = require('express-validator');
const { handleValidationErrors } = require('../../utils/validation');

// ... (other code)

// Validate signup inputs
const validateSignup = [
  check('email')
    .exists({ checkFalsy: true })
    .isEmail()
    .withMessage('Please provide a valid email.'),
  check('username')
    .exists({ checkFalsy: true })
    .isLength({ min: 4 })
    .withMessage('Please provide a username with at least 4 characters.'),
  check('username')
    .not()
    .isEmail()
    .withMessage('Username cannot be an email.'),
  check('password')
    .exists({ checkFalsy: true })
    .isLength({ min: 6 })
    .withMessage('Password must be 6 characters or more.'),
  check('firstName')
    .exists({ checkFalsy: true })
    .withMessage('Please provide a first name.'),
  check('lastName')
    .exists({ checkFalsy: true })
    .withMessage('Please provide a last name.'),
  handleValidationErrors
];

// Apply validation to the signup route
router.post(
  '/',
  validateSignup,
  async (req, res) => {
    // ... (signup route handler)
  }
);
            

These validation checks provide several benefits:

Testing API Routes

Testing Login

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


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

// Test login with email
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    const csrfToken = data['XSRF-Token'];
    
    return fetch('/api/session', {
      method: 'POST',
      headers: {
        "Content-Type": "application/json",
        "XSRF-TOKEN": csrfToken
      },
      body: JSON.stringify({ credential: 'demo@user.io', password: 'password' })
    });
  })
  .then(res => res.json())
  .then(data => console.log("Login with email:", data))
  .catch(err => console.error("Login error:", err));

// Test login with invalid credentials
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    const csrfToken = data['XSRF-Token'];
    
    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:", data))
  .catch(err => console.error("Login error:", err));
            

Testing Signup

Let's test our signup route:


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

// Test signup with validation errors
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    const csrfToken = data['XSRF-Token'];
    
    return fetch('/api/users', {
      method: 'POST',
      headers: {
        "Content-Type": "application/json",
        "XSRF-TOKEN": csrfToken
      },
      body: JSON.stringify({
        email: 'invalid-email',
        username: 'a',
        password: 'short',
        firstName: '',
        lastName: ''
      })
    });
  })
  .then(res => res.json())
  .then(data => console.log("Invalid signup:", data))
  .catch(err => console.error("Signup error:", err));
            

Testing Logout

Let's test our logout route:


// Test logout (make sure you're logged in first)
fetch('/api/csrf/restore')
  .then(res => res.json())
  .then(data => {
    const csrfToken = data['XSRF-Token'];
    
    return fetch('/api/session', {
      method: 'DELETE',
      headers: {
        "Content-Type": "application/json",
        "XSRF-TOKEN": csrfToken
      }
    });
  })
  .then(res => res.json())
  .then(data => console.log("Logout:", data))
  .catch(err => console.error("Logout error:", err));
            

Testing Get Current Session

Finally, let's test our get current session route:


// Test get current session
fetch('/api/session')
  .then(res => res.json())
  .then(data => console.log("Current session:", data))
  .catch(err => console.error("Session error:", err));
            

Understanding RESTful API Design

Step 4: Review the Solution

Let's review what we've accomplished and understand the principles behind our API design:

RESTful Resource Modeling

We've structured our API around two key resources:

We've used HTTP verbs to represent actions on these resources:

Consistent Response Format

Our API returns responses in a consistent JSON format:

Security Considerations

Our API includes several security measures:

Real-World Applications

This type of API design is common in modern web applications:

Common Authentication Patterns

JWT Authentication

Our implementation uses JSON Web Tokens (JWTs) for authentication. Here's how it works:

  1. When a user logs in, we create a JWT containing their user ID and other non-sensitive information
  2. We set this JWT as an HTTP-only cookie, which makes it inaccessible to JavaScript
  3. On subsequent requests, the browser automatically sends this cookie
  4. Our restoreUser middleware verifies the JWT and populates req.user
  5. Protected routes can check req.user to ensure the user is authenticated

Alternative Authentication Methods

While we've implemented JWT-based cookie authentication, there are other common approaches:

Bearer Token Authentication

In this approach, the client includes the token in the Authorization header of each request:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

This is common in mobile apps and APIs that don't rely on cookies.

OAuth 2.0

OAuth allows users to authenticate using third-party providers like Google or Facebook. Our API could be extended to support this by:

  1. Adding routes for initiating OAuth flows
  2. Implementing callbacks to handle provider responses
  3. Creating or retrieving user accounts based on OAuth information

Multi-factor Authentication (MFA)

For higher security applications, we could add MFA by:

  1. Adding fields to the User model for MFA status and secrets
  2. Creating routes for setting up and verifying MFA
  3. Requiring MFA verification during the login process

Extending the API

Password Reset Functionality

A common extension to authentication systems is password reset functionality:


// Route to request a password reset
router.post('/password-reset', async (req, res) => {
  const { email } = req.body;
  
  // Find the user
  const user = await User.findOne({ where: { email } });
  if (!user) {
    // Don't reveal whether a user exists
    return res.json({ message: 'If an account with that email exists, a reset link has been sent.' });
  }
  
  // Generate a secure reset token and save it to the user
  const resetToken = crypto.randomBytes(32).toString('hex');
  const resetTokenExpiry = Date.now() + 3600000; // 1 hour
  
  // Update user with reset token
  await user.update({
    resetToken,
    resetTokenExpiry
  });
  
  // In a real app, you would send an email with the reset link
  console.log(`Reset link: http://localhost:3000/reset-password?token=${resetToken}`);
  
  return res.json({ message: 'If an account with that email exists, a reset link has been sent.' });
});

// Route to reset password with token
router.post('/password-reset/:token', async (req, res) => {
  const { token } = req.params;
  const { password } = req.body;
  
  // Find user with this reset token and valid expiry
  const user = await User.findOne({
    where: {
      resetToken: token,
      resetTokenExpiry: {
        [Op.gt]: Date.now()
      }
    }
  });
  
  if (!user) {
    const err = new Error('Invalid or expired reset token');
    err.status = 400;
    return next(err);
  }
  
  // Update the user's password
  const hashedPassword = bcrypt.hashSync(password);
  await user.update({
    hashedPassword,
    resetToken: null,
    resetTokenExpiry: null
  });
  
  return res.json({ message: 'Password updated successfully' });
});
            

User Profile Management

We could add routes for managing user profiles:


// Get the current user's profile
router.get('/profile', requireAuth, async (req, res) => {
  // req.user is set by the restoreUser middleware
  const user = await User.findByPk(req.user.id, {
    attributes: ['id', 'username', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt']
  });
  
  return res.json({ user });
});

// Update the current user's profile
router.put('/profile', requireAuth, async (req, res) => {
  const { firstName, lastName } = req.body;
  
  // Update user information
  const user = await User.findByPk(req.user.id);
  await user.update({
    firstName,
    lastName
  });
  
  const safeUser = {
    id: user.id,
    username: user.username,
    email: user.email,
    firstName: user.firstName,
    lastName: user.lastName
  };
  
  return res.json({ user: safeUser });
});
            

Email Verification

For enhanced security, we could add email verification:


// Add fields to User model for email verification
// emailVerified: Boolean
// emailVerificationToken: String

// Send verification email on signup
router.post('/', validateSignup, async (req, res) => {
  // ... (existing signup code)
  
  // Generate verification token
  const verificationToken = crypto.randomBytes(32).toString('hex');
  user.emailVerificationToken = verificationToken;
  await user.save();
  
  // In a real app, send an email with the verification link
  console.log(`Verification link: http://localhost:3000/verify-email?token=${verificationToken}`);
  
  // ... (rest of signup code)
});

// Route to verify email
router.get('/verify-email/:token', async (req, res) => {
  const { token } = req.params;
  
  // Find user with this verification token
  const user = await User.findOne({
    where: { emailVerificationToken: token }
  });
  
  if (!user) {
    const err = new Error('Invalid verification token');
    err.status = 400;
    return next(err);

    if (!user) {
      const err = new Error('Invalid verification token');
      err.status = 400;
      return next(err);
    }
    
    // Mark email as verified and clear the token
    await user.update({
      emailVerified: true,
      emailVerificationToken: null
    });
    
    return res.json({ message: 'Email verified successfully' });
  });
              

Advanced Authentication Topics

Role-Based Access Control

For applications that need different permission levels, we could implement role-based access control:

  1. Add a 'role' field to the User model (e.g., 'user', 'admin', 'moderator')
  2. Create middleware to check roles:

  // Middleware to check if user has required role
  const requireRole = (role) => {
    return (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;
        err.title = 'Forbidden';
        err.errors = { message: 'You do not have permission to perform this action' };
        return next(err);
      }
      
      return next();
    };
  };
  
  // Example usage on a route
  router.delete('/users/:userId', requireAuth, requireRole('admin'), async (req, res) => {
    // Only admins can access this route
    // ... route implementation
  });
              

Session Management

For more advanced session management, we might add:


  // Add route to invalidate all sessions
  router.post('/session/invalidate-all', requireAuth, async (req, res) => {
    // Update the user's record with a new token version
    // This would require modifying our JWT verification to check this version
    const user = await User.findByPk(req.user.id);
    await user.update({
      tokenVersion: (user.tokenVersion || 0) + 1
    });
    
    // Clear the current session cookie
    res.clearCookie('token');
    
    return res.json({ message: 'All sessions have been invalidated' });
  });
              

API Rate Limiting

To prevent abuse, we could add rate limiting to authentication endpoints:


  // Using express-rate-limit package
  const rateLimit = require('express-rate-limit');
  
  // Create a limiter for login attempts
  const loginLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 5, // 5 requests per window
    message: {
      message: 'Too many login attempts from this IP, please try again after 15 minutes'
    },
    standardHeaders: true,
    legacyHeaders: false,
  });
  
  // Apply to login route
  router.post('/', loginLimiter, validateLogin, async (req, res, next) => {
    // ... existing login handler
  });
              

Best Practices for API Security

Secure Password Storage

Our implementation uses bcrypt for password hashing, which is a good practice because:

HTTPS

In production, always ensure your API is served over HTTPS to prevent:

Secure HTTP Headers

We've included the helmet middleware, which sets security-related HTTP headers:

Input Validation and Sanitization

We've implemented input validation using express-validator, which helps prevent:

Security Auditing

In a production environment, regularly audit your dependencies for security vulnerabilities using tools like:


  // Run this command regularly to check for vulnerabilities
  npm audit
              

Conclusion

We've successfully built a comprehensive set of API routes for user authentication. Our implementation includes:

We've also explored how to extend this foundation with more advanced features like:

This authentication system follows RESTful principles and modern security best practices, making it a solid foundation for any web application.

Next Steps

From here, you might want to:

Remember that authentication is a critical security component of your application, so always keep up with the latest security best practices and regularly update your dependencies to address any discovered vulnerabilities.