Front-end Engineering Lab

CORS Advanced

Advanced Cross-Origin Resource Sharing patterns and security

CORS Advanced

Cross-Origin Resource Sharing (CORS) controls which origins can access your API. Misconfigured CORS is a common security vulnerability.

The Same-Origin Policy

Without CORS, browsers block cross-origin requests:

// On https://app.example.com
fetch('https://api.different.com/data')
  .then(res => res.json())
  .catch(err => {
    // ❌ CORS error: Blocked by Same-Origin Policy
  });

CORS Basics

Simple Request

GET /data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com

Response:
Access-Control-Allow-Origin: https://app.example.com

Preflight Request

For non-simple requests (PUT, DELETE, custom headers):

OPTIONS /data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: X-Custom-Header

Response:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 86400

CORS Headers

Access-Control-Allow-Origin

// ❌ DANGEROUS: Allows all origins
res.setHeader('Access-Control-Allow-Origin', '*');

// ✅ GOOD: Specific origin
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');

// ✅ GOOD: Multiple origins (dynamic)
const allowedOrigins = [
  'https://app.example.com',
  'https://admin.example.com',
];

const origin = req.headers.origin;
if (origin && allowedOrigins.includes(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin);
}

Access-Control-Allow-Credentials

// Allow cookies/auth headers
res.setHeader('Access-Control-Allow-Credentials', 'true');

// ❌ DANGEROUS: Cannot use '*' with credentials
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');  // Won't work!

// ✅ GOOD: Specific origin with credentials
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
res.setHeader('Access-Control-Allow-Credentials', 'true');

Access-Control-Allow-Methods

res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');

Access-Control-Allow-Headers

res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');

Access-Control-Expose-Headers

// Expose custom headers to client
res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count, X-Page-Number');

Access-Control-Max-Age

// Cache preflight for 24 hours
res.setHeader('Access-Control-Max-Age', '86400');

Next.js Implementation

API Route

// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server';

const allowedOrigins = [
  'https://app.example.com',
  'https://admin.example.com',
  process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : null,
].filter(Boolean) as string[];

export async function GET(request: NextRequest) {
  const origin = request.headers.get('origin');
  
  const response = NextResponse.json({ data: 'Hello' });
  
  if (origin && allowedOrigins.includes(origin)) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.set('Access-Control-Allow-Credentials', 'true');
  }
  
  return response;
}

export async function OPTIONS(request: NextRequest) {
  const origin = request.headers.get('origin');
  
  const response = new NextResponse(null, { status: 204 });
  
  if (origin && allowedOrigins.includes(origin)) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    response.headers.set('Access-Control-Max-Age', '86400');
    response.headers.set('Access-Control-Allow-Credentials', 'true');
  }
  
  return response;
}

Middleware

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const allowedOrigins = [
  'https://app.example.com',
  'https://admin.example.com',
];

export function middleware(request: NextRequest) {
  const origin = request.headers.get('origin');
  const isAllowedOrigin = origin && allowedOrigins.includes(origin);

  // Handle preflight
  if (request.method === 'OPTIONS') {
    const response = new NextResponse(null, { status: 204 });
    
    if (isAllowedOrigin) {
      response.headers.set('Access-Control-Allow-Origin', origin);
      response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
      response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
      response.headers.set('Access-Control-Max-Age', '86400');
      response.headers.set('Access-Control-Allow-Credentials', 'true');
    }
    
    return response;
  }

  const response = NextResponse.next();
  
  if (isAllowedOrigin) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.set('Access-Control-Allow-Credentials', 'true');
  }
  
  return response;
}

export const config = {
  matcher: '/api/:path*',
};

Express.js

import express from 'express';
import cors from 'cors';

const app = express();

// Option 1: Simple (allows all origins)
app.use(cors());

// Option 2: Specific origins
const corsOptions = {
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
  optionsSuccessStatus: 200,
};

app.use(cors(corsOptions));

// Option 3: Dynamic origin validation
const corsOptions = {
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://app.example.com',
      'https://admin.example.com',
    ];
    
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  exposedHeaders: ['X-Total-Count'],
  maxAge: 86400,
};

app.use(cors(corsOptions));

Advanced Patterns

Origin Validation with Regex

function isAllowedOrigin(origin: string): boolean {
  const allowedPatterns = [
    /^https:\/\/.*\.example\.com$/,  // Any subdomain
    /^https:\/\/app-\d+\.example\.com$/,  // Versioned subdomains
  ];
  
  return allowedPatterns.some(pattern => pattern.test(origin));
}

// Use in middleware
const origin = request.headers.get('origin');
if (origin && isAllowedOrigin(origin)) {
  response.headers.set('Access-Control-Allow-Origin', origin);
}

Conditional CORS

// Different CORS rules for different routes
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const origin = request.headers.get('origin');
  
  let allowedOrigins: string[] = [];
  
  if (pathname.startsWith('/api/public')) {
    // Public API: Allow more origins
    allowedOrigins = ['https://app.example.com', 'https://partner.com'];
  } else if (pathname.startsWith('/api/admin')) {
    // Admin API: Strict
    allowedOrigins = ['https://admin.example.com'];
  }
  
  const response = NextResponse.next();
  
  if (origin && allowedOrigins.includes(origin)) {
    response.headers.set('Access-Control-Allow-Origin', origin);
  }
  
  return response;
}

CORS with Authentication

export async function POST(request: NextRequest) {
  const origin = request.headers.get('origin');
  const authToken = request.headers.get('authorization');
  
  // Validate origin
  if (!origin || !allowedOrigins.includes(origin)) {
    return new NextResponse('Forbidden', { status: 403 });
  }
  
  // Validate auth
  if (!authToken || !await validateToken(authToken)) {
    return new NextResponse('Unauthorized', { status: 401 });
  }
  
  const response = NextResponse.json({ success: true });
  response.headers.set('Access-Control-Allow-Origin', origin);
  response.headers.set('Access-Control-Allow-Credentials', 'true');
  
  return response;
}

Rate Limiting per Origin

const rateLimits = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(origin: string): boolean {
  const now = Date.now();
  const limit = rateLimits.get(origin);
  
  if (!limit || now > limit.resetAt) {
    rateLimits.set(origin, { count: 1, resetAt: now + 60000 });
    return true;
  }
  
  if (limit.count >= 100) {  // 100 requests per minute
    return false;
  }
  
  limit.count++;
  return true;
}

export async function GET(request: NextRequest) {
  const origin = request.headers.get('origin');
  
  if (!origin || !checkRateLimit(origin)) {
    return new NextResponse('Too Many Requests', { status: 429 });
  }
  
  // Process request...
}

Security Best Practices

1. Never Use Wildcard with Credentials

// ❌ DANGEROUS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');

// ✅ SECURE
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
res.setHeader('Access-Control-Allow-Credentials', 'true');

2. Validate Origin Strictly

// ❌ DANGEROUS: String contains
if (origin?.includes('example.com')) {
  // Allows: https://evil.com?origin=example.com
}

// ✅ SECURE: Exact match or regex
const allowedOrigins = ['https://app.example.com'];
if (origin && allowedOrigins.includes(origin)) {
  // Only allows exact matches
}

3. Limit Exposed Headers

// ❌ Exposes all headers
res.setHeader('Access-Control-Expose-Headers', '*');

// ✅ Only expose necessary headers
res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count, X-Page-Number');

4. Short Preflight Cache

// ❌ Very long cache (1 week)
res.setHeader('Access-Control-Max-Age', '604800');

// ✅ Reasonable cache (24 hours)
res.setHeader('Access-Control-Max-Age', '86400');

Testing CORS

Manual Test

# Test GET request
curl -H "Origin: https://app.example.com" \
     -H "Access-Control-Request-Method: GET" \
     -H "Access-Control-Request-Headers: X-Requested-With" \
     -X OPTIONS \
     https://api.example.com/data

# Check response headers

Automated Test

describe('CORS', () => {
  it('should allow whitelisted origin', async () => {
    const response = await fetch('/api/data', {
      headers: { Origin: 'https://app.example.com' },
    });
    
    expect(response.headers.get('Access-Control-Allow-Origin'))
      .toBe('https://app.example.com');
  });
  
  it('should block non-whitelisted origin', async () => {
    const response = await fetch('/api/data', {
      headers: { Origin: 'https://evil.com' },
    });
    
    expect(response.headers.get('Access-Control-Allow-Origin'))
      .toBeNull();
  });
});

Common Issues

Issue: CORS Error Despite Correct Headers

Cause: Preflight request not handled

// ✅ Always handle OPTIONS
export async function OPTIONS(request: NextRequest) {
  return new NextResponse(null, {
    status: 204,
    headers: {
      'Access-Control-Allow-Origin': 'https://app.example.com',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  });
}

Issue: Credentials Not Sent

// Client side - must include credentials
fetch('https://api.example.com/data', {
  credentials: 'include',  // ← Important!
});

// Server side - must allow credentials
res.setHeader('Access-Control-Allow-Credentials', 'true');

Common Pitfalls

Wildcard with credentials: Security risk
Specific origins with credentials

Weak origin validation: includes() check
Strict validation: exact match or regex

No OPTIONS handler: Preflight fails
Handle OPTIONS for all routes

Exposing all headers: Information leak
Expose only necessary headers

Same CORS for all routes: Too permissive
Different CORS per route sensitivity

CORS is about controlled access—be strict with allowed origins and never use wildcards with credentials!

On this page