CORS Explained: Security Implications and Best Practices

CORS Explained: Security Implications and Best Practices

Deniz Birlik
Deniz Birlik
·8 min read

Have you ever encountered the dreaded "Access to fetch at 'https://api.example.com' from origin 'https://your-app.com' has been blocked by CORS policy" error? If you're a web developer, chances are you've run into this security feature more than once. Today, we'll demystify CORS, understand why it exists, and learn how to implement it properly in your applications.

Understanding CORS: The Basics

Cross-Origin Resource Sharing (CORS) is a security feature implemented by web browsers that controls how web pages in one domain can request and interact with resources from another domain. It's an evolution of the Same-Origin Policy (SOP), which is a fundamental security concept in web applications.

What Constitutes a Cross-Origin Request?

A request is considered cross-origin when it involves:

  • Different domains (e.g., app.com vs api.com)
  • Different subdomains (e.g., app.com vs api.app.com)
  • Different ports (e.g., localhost:3000 vs localhost:8080)
  • Different protocols (e.g., http vs https)

The Anatomy of CORS

Let's look at how CORS works under the hood:

// Frontend code making a cross-origin request
fetch('https://api.example.com/data', {
    method: 'GET',
    headers: {
        'Content-Type': 'application/json'
    }
}).then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error('Error:', error));

When this request is made, the browser automatically adds the Origin header:

GET /data HTTP/1.1
Host: api.example.com
Origin: https://your-app.com
Content-Type: application/json

The server must respond with appropriate CORS headers:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://your-app.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400

CORS Headers Explained

Essential CORS Headers

  1. Access-Control-Allow-Origin The most crucial CORS header, specifying which origins can access the resource:
// Express.js example
app.use((req, res, next) => {
    // Allow specific origin
    res.header('Access-Control-Allow-Origin', 'https://your-app.com');

    // Or allow multiple origins
    const allowedOrigins = ['https://app1.com', 'https://app2.com'];
    const origin = req.headers.origin;
    if (allowedOrigins.includes(origin)) {
        res.header('Access-Control-Allow-Origin', origin);
    }

    next();
});
  1. Access-Control-Allow-Methods Specifies allowed HTTP methods:
app.use((req, res, next) => {
    res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    next();
});
  1. Access-Control-Allow-Headers Defines which headers can be used in the actual request:
app.use((req, res, next) => {
    res.header(
        'Access-Control-Allow-Headers',
        'Content-Type, Authorization, X-Requested-With'
    );
    next();
});

Preflight Requests

One of the most important aspects of CORS is the preflight request. For certain types of requests, the browser sends an OPTIONS request first to check if the actual request is allowed.

When is a Preflight Required?

A preflight request is sent when:

  • Using methods other than GET, POST, or HEAD
  • Including custom headers
  • Using content types other than:
  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

Here's how to handle preflight requests:

app.options('/api/data', (req, res) => {
    // Handle preflight request
    res.header('Access-Control-Allow-Origin', 'https://your-app.com');
    res.header('Access-Control-Allow-Methods', 'PUT, POST, DELETE');
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.header('Access-Control-Max-Age', '86400'); // 24 hours
    res.sendStatus(204);
});

  app.put('/api/data', (req, res) => {
      // Handle actual request
      // Your API logic here
  });

Common CORS Pitfalls and Solutions

1. Wildcard Origins in Production

Don't do this:

app.use((req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*'); // Dangerous in production
    next();
});

Do this instead:

const allowedOrigins = [
    'https://your-app.com',
    'https://staging.your-app.com'
];

  app.use((req, res, next) => {
      const origin = req.headers.origin;
      if (allowedOrigins.includes(origin)) {
          res.header('Access-Control-Allow-Origin', origin);
      }
      next();
  });

2. Handling Credentials

When dealing with authenticated requests, you need special CORS configurations:

// Frontend
fetch('https://api.example.com/data', {
    credentials: 'include', // Sends cookies
});

  // Backend
  app.use((req, res, next) => {
      res.header('Access-Control-Allow-Credentials', 'true');
      // Must specify exact origin when credentials are allowed
      res.header('Access-Control-Allow-Origin', 'https://your-app.com');
      next();
  });

3. Dynamic Origins

For applications with multiple environments:

function isValidOrigin(origin) {
    // Check against your allowed patterns
    const allowedPatterns = [
        /^https://.*.your-app.com$/,
        /^https://your-app.com$/,
        /^http://localhost:[0-9]+$/
    ];

    return allowedPatterns.some(pattern => pattern.test(origin));
}

  app.use((req, res, next) => {
      const origin = req.headers.origin;
      if (isValidOrigin(origin)) {
          res.header('Access-Control-Allow-Origin', origin);
      }
      next();
  });

Best Practices for CORS Implementation

  1. Never Use Wildcards in Production
  • Always specify allowed origins explicitly
  • Use environment variables for configuration
  1. Implement Proper Error Handling
app.use((err, req, res, next) => {
    if (err.name === 'UnauthorizedError') {
        res.status(401).json({
            error: 'Invalid token',
            details: process.env.NODE_ENV === 'development' ? err : undefined
        });
    }
    next(err);
});
  1. Cache Preflight Results
  • Use Access-Control-Max-Age header
  • Choose appropriate cache duration
  1. Security Headers
  • Implement additional security headers
app.use(helmet()); // Uses helmet middleware for security headers

  app.use((req, res, next) => {
      res.header('Strict-Transport-Security', 'max-age=31536000');
      res.header('X-Content-Type-Options', 'nosniff');
      res.header('X-Frame-Options', 'DENY');
      next();
  });

Testing CORS Configuration

When developing and testing your CORS setup, it's crucial to have a reliable way to verify your configuration. Tools like Webhook Simulator can help you test your endpoints and CORS settings in a controlled environment. You can sign up for free to test your webhook endpoints and ensure your CORS configuration works correctly with various request types and headers.

Here's a testing checklist:

  • Verify preflight requests
  • Test with credentials
  • Check different HTTP methods
  • Validate custom headers
  • Test error scenarios

Conclusion

CORS is a crucial security feature that, when implemented correctly, helps protect both your users and your application. While it may seem complicated at first, understanding its principles and following best practices will help you build more secure web applications.

Remember:

  • Always be explicit about allowed origins
  • Properly handle preflight requests
  • Implement appropriate security headers
  • Test thoroughly in different environments
  • Monitor and log CORS-related issues

By following these guidelines and best practices, you can ensure your application's CORS configuration is both secure and functional. Happy coding!