Introduction

Welcome back! In our previous lesson, we explored the basics of Cross-Origin Resource Sharing (CORS) and its significance in web development. Now, we're going to dive deeper into a crucial aspect of CORS: preflight requests. These requests play a vital role in ensuring secure cross-origin communication. Think of them as a security check before allowing access to resources. By the end of this lesson, you'll understand what preflight requests are, when they occur, and how to handle them effectively in your TypeScript REST API. Let's get started! 🚀

Understanding Preflight Requests

Preflight requests are a part of the CORS mechanism that browsers use to determine if a cross-origin request is safe to send. They are triggered when a request uses methods other than simple methods like GET, HEAD, or POST (with certain content types), or when custom headers are included. To be more specific, POST requests are only considered "simple" if they use one of the following content types: application/x-www-form-urlencoded, multipart/form-data, or text/plain. If a POST request uses application/json—which is common in modern APIs—it will trigger a preflight request. This is an important detail that explains why many seemingly normal API requests may involve a preflight check.

Here's how preflight requests work:

  • When you make a "non-simple" cross-origin request, the browser first sends an OPTIONS request to the server
  • This preflight request checks if the actual request is allowed based on origin, method, and headers
  • The server responds with specific CORS headers indicating what's permitted
  • Only if the preflight is successful will the browser send the actual request

Imagine you're entering a secure building; a preflight request is like the security guard checking your credentials before letting you in. Without proper handling of these preflight requests, certain cross-origin requests will be blocked by browsers.

Why Preflight Requests Matter

To understand why we need to handle preflight requests properly, let's look at a common scenario:

Suppose your frontend (running on http://localhost:3000) needs to make a PUT request to your API (running on http://localhost:8000) with a custom Authorization header. Before sending the actual PUT request, the browser will automatically send an OPTIONS request to check if:

  1. The server allows requests from http://localhost:3000 (origin)
  2. The server allows PUT methods
  3. The server accepts the Authorization header

If your server doesn't properly respond to this OPTIONS request with the appropriate CORS headers, the browser will block the actual PUT request, and you'll see an error like:

Access to fetch at 'http://localhost:8000/api/resource' from origin 'http://localhost:3000' 
has been blocked by CORS policy: Response to preflight request doesn't pass 
access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Setting Up Preflight Handling: Basic Configuration

Let's implement proper preflight request handling in our TypeScript REST API:

import express from 'express';
import cors, { CorsOptions } from 'cors';

const app = express();

// Define CORS options with preflight configuration
const corsOptions: CorsOptions = {
  origin: 'http://localhost:3000', // Your frontend origin
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
  credentials: true,
  maxAge: 600 // Cache preflight response for 10 minutes
};

// Apply CORS middleware to handle preflight requests
app.use(cors(corsOptions));

// Your routes here
app.get('/api/resources', (req, res) => {
  res.json({ message: 'This is a GET endpoint' });
});

app.put('/api/resources/:id', (req, res) => {
  res.json({ message: 'This is a PUT endpoint' });
});

app.listen(8000, () => {
  console.log('Server running on port 8000');
});

When a browser sends a preflight request to this server, the cors middleware automatically responds with the following headers:

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,PATCH,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization,X-Requested-With
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600

The browser uses these headers to determine if the actual request is allowed. The maxAge option tells browsers how long (in seconds) they can cache the preflight response, reducing the number of preflight requests and improving performance.

Note: However, not all browsers respect the maxAge setting equally. For example, some versions of Safari have been known to ignore this header, resulting in more frequent preflight requests than expected.

Setting Up Preflight Handling: Route-Specific CORS Policies

Sometimes, you might want different CORS policies for different routes. Here's how to create middleware for specific route types:

import express, { Router, Request, Response, NextFunction } from 'express';
import cors, { CorsOptions } from 'cors';

// Create different CORS configurations for different route types
export const createPreflightHandler = (allowedOrigins: string | string[], 
                                      allowedMethods: string[] = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
                                      allowedHeaders: string[] = ['Content-Type', 'Authorization']) => {
  
  const options: CorsOptions = {
    origin: allowedOrigins,
    methods: allowedMethods,
    allowedHeaders: allowedHeaders,
    maxAge: 600, // Cache preflight response for 10 minutes
    credentials: true
  };
  
  return cors(options);
};

const app = express();

// Create routers for different API sections
const publicRouter = express.Router();
const authRouter = express.Router();
const adminRouter = express.Router();

// Apply different CORS policies to different routes
publicRouter.use(createPreflightHandler(
  ['http://localhost:3000', 'https://public-app.example.com'],
  ['GET', 'OPTIONS']
));

authRouter.use(createPreflightHandler(
  'http://localhost:3000', 
  ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
));

adminRouter.use(createPreflightHandler(
  'https://admin.example.com',
  ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']
));

// Define routes on each router
publicRouter.get('/resources', (req, res) => {
  res.json({ message: 'Public resources' });
});

authRouter.put('/user/:id', (req, res) => {
  res.json({ message: 'User updated' });
});

// Register routers with the app
app.use('/api/public', publicRouter);
app.use('/api/auth', authRouter);
app.use('/api/admin', adminRouter);

app.listen(8000, () => {
  console.log('Server running on port 8000');
});

This implementation provides fine-grained control over which origins, methods, and headers are allowed for different parts of your API. Each route type can have its own preflight handling configuration.

Setting Up Preflight Handling: Adding Preflight Logging and Diagnostics
Manually Handling Preflight Requests Without the CORS Package

While the cors package handles preflight requests automatically, understanding how to handle them manually provides deeper insight:

import express from 'express';

const app = express();

// Manual preflight handler without cors package
app.options('*', (req, res) => {
  // Set CORS headers
  res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
  res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS');
  res.header('Access-Control-Allow-Headers', 'Content-Type,Authorization');
  res.header('Access-Control-Max-Age', '600');
  res.header('Access-Control-Allow-Credentials', 'true');
  
  // Pre-flight requests need a 204 response
  res.status(204).end();
});

// Add CORS headers to all other requests
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'http://localhost:3000');
  res.header('Access-Control-Allow-Credentials', 'true');
  next();
});

// Your routes
app.get('/api/resources', (req, res) => {
  res.json({ message: 'Resources retrieved' });
});

app.listen(8000, () => {
  console.log('Server running on port 8000');
});

This manual approach gives you complete control over how preflight requests are handled, but requires more maintenance than using the cors package.

Common Pitfalls and Misconceptions

When handling preflight requests, there are several common pitfalls to avoid:

  1. Ignoring OPTIONS requests: Some developers forget to handle OPTIONS requests, causing preflight requests to fail. Express with the cors middleware handles this automatically, but if you're building custom middleware, you need to respond to OPTIONS requests correctly.

  2. Misunderstanding preflight caching: Setting an appropriate maxAge value can significantly improve performance by reducing redundant preflight requests. However, setting it too high might cause issues if you need to change CORS policies.

  3. Forgetting credentials: If your API uses cookies or authentication headers, you need to set credentials: true and ensure your Access-Control-Allow-Origin is not set to a wildcard (*).

  4. Insufficient method allowance: Remember that the preflight request is checking what methods are allowed. If you don't include all methods your API needs in Access-Control-Allow-Methods, certain operations will fail.

  5. Incorrectly responding to manual OPTIONS requests: If you handle OPTIONS requests manually (without the cors package), you must include all necessary CORS headers: Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers. Additionally, you should respond with a 204 (No Content) status code rather than 200, as shown in our manual example. Failing to include required headers or returning the wrong status code will cause the browser to reject the actual request.

Conclusion and Next Steps

In this lesson, we explored preflight requests, a crucial aspect of the CORS mechanism. We learned how browsers use preflight requests to ensure secure cross-origin communication and how to properly configure our TypeScript REST API to handle these requests. We implemented both automatic and manual preflight handling systems with route-specific configurations and diagnostic logging to help troubleshoot issues.

Understanding and correctly implementing preflight request handling is essential for building secure, well-functioning APIs that can be accessed by web applications across different origins. In the upcoming practice exercises, you'll have the opportunity to apply what you've learned and strengthen your understanding of preflight requests. In our next lesson, we'll continue to enhance the security of your TypeScript REST API with more advanced CORS configurations. Keep up the great work! 🌟

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal