The Same-Origin Policy
The Same-Origin Policy (SOP) is a browser security mechanism that restricts how documents and scripts from one origin can interact with resources from another origin. It is the foundation of web security.
Two URLs have the same origin if they share the same protocol, host, and port:
https://example.com:443/page1
| | |
protocol host port
Same origin:
https://example.com/page1 -- same as --> https://example.com/page2
https://example.com:443/page1 -- same as --> https://example.com/api/data
Different origin:
https://example.com vs http://example.com (different protocol)
https://example.com vs https://api.example.com (different host)
https://example.com vs https://example.com:8080 (different port)
What SOP restricts:
+-----------------------------+----------------------------------------------+
| Action | Restriction |
+-----------------------------+----------------------------------------------+
| JavaScript fetch/XHR | Cannot read response from different origin |
| DOM access (iframes) | Cannot access DOM of different origin |
| Cookies | Scoped to origin (with some path/domain |
| | flexibility) |
| localStorage/sessionStorage | Strictly scoped to origin |
+-----------------------------+----------------------------------------------+
What SOP does NOT restrict:
+-----------------------------+----------------------------------------------+
| Action | Why It Is Allowed |
+-----------------------------+----------------------------------------------+
| Embedding images (<img>) | Web would break without cross-origin images |
| Loading scripts (<script>) | CDNs and third-party libraries |
| Loading stylesheets (<link>)| CDN-hosted CSS |
| Form submissions (<form>) | Historical behavior (this enables CSRF) |
| Embedding iframes (<iframe>)| Third-party widgets, ads |
+-----------------------------+----------------------------------------------+
SOP blocks reading cross-origin responses, not sending cross-origin requests. The browser sends the request — the server receives it and responds — but the browser blocks JavaScript from reading the response. This distinction matters for understanding both CORS and CSRF.
What Is CORS?
CORS (Cross-Origin Resource Sharing) is a mechanism that allows servers to explicitly grant permission for cross-origin requests. It relaxes the Same-Origin Policy in a controlled way.
Without CORS, a frontend at https://app.example.com cannot read API responses from https://api.example.com (different origin — different subdomain). CORS lets the API server say "I allow requests from app.example.com."
Frontend (app.example.com) API (api.example.com)
-------------------------- ---------------------
| |
|-- fetch('api.example.com/data') ----->|
| |
|<-- Response with CORS headers --------|
| Access-Control-Allow-Origin: |
| https://app.example.com |
| |
|-- Browser checks headers |
| Origin matches? --> Allow response |
| No match? --> Block response |
CORS Headers Explained
Response Headers (Server Sets These)
+-----------------------------------+----------------------------------------------+
| Header | Purpose |
+-----------------------------------+----------------------------------------------+
| Access-Control-Allow-Origin | Which origins can access the response |
| Access-Control-Allow-Methods | Which HTTP methods are allowed |
| Access-Control-Allow-Headers | Which request headers are allowed |
| Access-Control-Allow-Credentials | Whether cookies/auth can be sent |
| Access-Control-Expose-Headers | Which response headers JS can read |
| Access-Control-Max-Age | How long to cache preflight results |
+-----------------------------------+----------------------------------------------+
Request Headers (Browser Sets These Automatically)
+-----------------------------------+----------------------------------------------+
| Header | Purpose |
+-----------------------------------+----------------------------------------------+
| Origin | The origin making the request |
| Access-Control-Request-Method | Method for the actual request (preflight) |
| Access-Control-Request-Headers | Custom headers for actual request (preflight)|
+-----------------------------------+----------------------------------------------+
Simple Requests vs Preflight Requests
Not all cross-origin requests trigger a preflight. The browser categorizes requests as "simple" or "preflighted."
Simple Requests (No Preflight)
A request is "simple" if all of these are true:
- Method is GET, HEAD, or POST
- Only "simple" headers:
Accept,Accept-Language,Content-Language,Content-Type - Content-Type is only:
application/x-www-form-urlencoded,multipart/form-data, ortext/plain
// This is a SIMPLE request — no preflight
fetch('https://api.example.com/data', {
method: 'GET',
});
// This is also simple
fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'key=value',
});
Browser Server
------- ------
| |
|-- GET /data --------------------------->|
| Origin: https://app.example.com |
| |
|<-- 200 OK ----------------------------|
| Access-Control-Allow-Origin: |
| https://app.example.com |
| |
|-- Browser: origin matches, allow -- |
Preflighted Requests
Any request that does not qualify as "simple" triggers a preflight OPTIONS request. The browser asks the server "will you allow this request?" before sending the actual request.
// This triggers a preflight because of Content-Type: application/json
fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // Not a "simple" content type
'Authorization': 'Bearer token123', // Custom header
},
body: JSON.stringify({ key: 'value' }),
});
Browser Server
------- ------
| |
|-- OPTIONS /data (preflight) --------->|
| Origin: https://app.example.com |
| Access-Control-Request-Method: POST |
| Access-Control-Request-Headers: |
| Content-Type, Authorization |
| |
|<-- 204 No Content --------------------|
| Access-Control-Allow-Origin: |
| https://app.example.com |
| Access-Control-Allow-Methods: |
| GET, POST, PUT, DELETE |
| Access-Control-Allow-Headers: |
| Content-Type, Authorization |
| Access-Control-Max-Age: 86400 |
| |
|-- POST /data (actual request) ------->|
| Origin: https://app.example.com |
| Content-Type: application/json |
| Authorization: Bearer token123 |
| |
|<-- 200 OK ----------------------------|
| Access-Control-Allow-Origin: |
| https://app.example.com |
CORS with Credentials
By default, cross-origin requests do not include cookies or authentication. To send credentials:
- Frontend must set
credentials: 'include' - Server must set
Access-Control-Allow-Credentials: true - Server CANNOT use
Access-Control-Allow-Origin: *with credentials — must specify the exact origin
// Frontend
fetch('https://api.example.com/user', {
method: 'GET',
credentials: 'include', // Send cookies
});
// Server response must include:
// Access-Control-Allow-Origin: https://app.example.com (exact origin, not *)
// Access-Control-Allow-Credentials: true
Implementing CORS
Express with cors Middleware
const express = require('express');
const cors = require('cors');
const app = express();
// Option 1: Allow specific origin
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400, // Cache preflight for 24 hours
}));
// Option 2: Multiple origins
const allowedOrigins = [
'https://app.example.com',
'https://staging.example.com',
'http://localhost:3000',
];
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, server-to-server)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
credentials: true,
}));
// Option 3: Dynamic origin from database/config
app.use(cors({
origin: async (origin, callback) => {
if (!origin) return callback(null, true);
const isAllowed = await db.allowedOrigins.exists(origin);
callback(null, isAllowed);
},
credentials: true,
}));
Manual CORS Implementation (Without Library)
function corsMiddleware(req, res, next) {
const origin = req.headers.origin;
const allowedOrigins = ['https://app.example.com', 'https://staging.example.com'];
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin'); // Important for caching
}
// Handle preflight
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
res.setHeader('Access-Control-Max-Age', '86400');
return res.status(204).end();
}
next();
}
app.use(corsMiddleware);
CORS in Next.js API Routes
// pages/api/data.ts
import type { NextApiRequest, NextApiResponse } from 'next';
const ALLOWED_ORIGINS = [
'https://app.example.com',
'http://localhost:3000',
];
function setCorsHeaders(req: NextApiRequest, res: NextApiResponse) {
const origin = req.headers.origin;
if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
export default function handler(req: NextApiRequest, res: NextApiResponse) {
setCorsHeaders(req, res);
if (req.method === 'OPTIONS') {
return res.status(204).end();
}
if (req.method === 'GET') {
return res.json({ message: 'Hello from the API' });
}
res.status(405).json({ error: 'Method not allowed' });
}
CORS in Next.js Middleware
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const ALLOWED_ORIGINS = [
'https://app.example.com',
'http://localhost:3000',
];
export function middleware(request: NextRequest) {
const origin = request.headers.get('origin');
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin);
// Handle preflight
if (request.method === 'OPTIONS') {
const response = new NextResponse(null, { status: 204 });
if (isAllowed) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
}
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
response.headers.set('Access-Control-Max-Age', '86400');
return response;
}
// Handle actual request
const response = NextResponse.next();
if (isAllowed) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
response.headers.set('Vary', 'Origin');
}
return response;
}
export const config = {
matcher: '/api/:path*',
};
Common CORS Errors and How to Fix Them
Error 1: "No 'Access-Control-Allow-Origin' header"
Access to fetch at 'https://api.example.com/data' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Cause: Server does not include CORS headers in the response.
Fix: Add Access-Control-Allow-Origin header on the server.
// Server must respond with:
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
Error 2: "Wildcard with credentials"
Access to fetch at 'https://api.example.com/data' has been blocked:
The value of the 'Access-Control-Allow-Origin' header must not be the
wildcard '*' when the request's credentials mode is 'include'.
Cause: Server returns Access-Control-Allow-Origin: * but the request includes credentials.
Fix: Use the specific origin instead of wildcard.
// WRONG
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// RIGHT
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
res.setHeader('Access-Control-Allow-Credentials', 'true');
Error 3: "Method not allowed"
Access to fetch has been blocked: Method PUT is not allowed by
Access-Control-Allow-Methods in preflight response.
Cause: Server's Access-Control-Allow-Methods does not include the requested method.
Fix: Add the method to the allowed list.
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH');
Error 4: "Header not allowed"
Access to fetch has been blocked: Request header field authorization
is not allowed by Access-Control-Allow-Headers in preflight response.
Cause: Server's Access-Control-Allow-Headers does not include the custom header.
Fix: Add the header to the allowed list.
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
Error 5: "Preflight response has invalid HTTP status code 405"
Cause: Server does not handle OPTIONS requests.
Fix: Add an OPTIONS handler.
if (req.method === 'OPTIONS') {
res.status(204).end();
return;
}
Debugging CORS
Browser DevTools
1. Open DevTools (F12)
2. Go to Network tab
3. Find the failed request
4. Check if there is an OPTIONS request before it (preflight)
5. Click the request and check:
- Request Headers: Origin header
- Response Headers: Access-Control-* headers
6. If no CORS headers in response -> server issue
7. If headers present but wrong -> misconfiguration
Debugging Utility
// Diagnostic tool — paste in browser console
async function diagnoseCors(url) {
console.log(`Diagnosing CORS for: ${url}`);
console.log(`Current origin: ${window.location.origin}`);
console.log('---');
// Test simple request
try {
const res = await fetch(url, { method: 'GET' });
console.log('Simple GET succeeded');
console.log('Allow-Origin:', res.headers.get('access-control-allow-origin'));
console.log('Allow-Credentials:', res.headers.get('access-control-allow-credentials'));
} catch (err) {
console.error('Simple GET failed:', err.message);
}
// Test preflight
try {
const res = await fetch(url, {
method: 'OPTIONS',
headers: {
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'Content-Type, Authorization',
},
});
console.log('Preflight status:', res.status);
console.log('Allow-Methods:', res.headers.get('access-control-allow-methods'));
console.log('Allow-Headers:', res.headers.get('access-control-allow-headers'));
console.log('Max-Age:', res.headers.get('access-control-max-age'));
} catch (err) {
console.error('Preflight failed:', err.message);
}
// Test with credentials
try {
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
});
console.log('Credentialed GET succeeded');
} catch (err) {
console.error('Credentialed GET failed:', err.message);
}
}
// Usage
diagnoseCors('https://api.example.com/data');
Security Considerations
Do Not Use Wildcard Origin
// DANGEROUS — Allows any website to read your API responses
app.use(cors({ origin: '*' }));
// Even more dangerous with credentials
// (browsers block this, but the intent is wrong)
app.use(cors({ origin: '*', credentials: true }));
Using * means any website can make requests to your API and read the responses. This is acceptable for truly public APIs (public data, no auth), but dangerous for anything with user data.
Do Not Reflect the Origin Header
// DANGEROUS — Reflects any origin, effectively same as wildcard
app.use(cors({
origin: (origin, callback) => {
callback(null, origin); // Always returns the requesting origin
},
credentials: true,
}));
// This allows any origin with credentials — worst combination
Always Validate Origins
// SAFE — Allowlist of trusted origins
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
app.use(cors({
origin: (origin, callback) => {
if (!origin || ALLOWED_ORIGINS.has(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed'));
}
},
credentials: true,
}));
The Vary Header
When your CORS response depends on the Origin header (which it usually does), include Vary: Origin. Without it, a CDN or proxy might cache a response with one origin's CORS headers and serve it to another origin.
res.setHeader('Vary', 'Origin');
CORS Proxy Patterns (Development Only)
During development, you might need to access third-party APIs that do not have CORS headers. Use a proxy — never disable CORS.
Next.js API Route as Proxy
// pages/api/proxy/[...path].ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { path } = req.query;
const targetPath = Array.isArray(path) ? path.join('/') : path;
const targetUrl = `https://third-party-api.com/${targetPath}`;
try {
const response = await fetch(targetUrl, {
method: req.method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.API_KEY}`, // Server-side secret
},
...(req.method !== 'GET' ? { body: JSON.stringify(req.body) } : {}),
});
const data = await response.json();
res.status(response.status).json(data);
} catch (error) {
res.status(500).json({ error: 'Proxy request failed' });
}
}
Next.js Rewrites (Simpler Proxy)
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/api/external/:path*',
destination: 'https://third-party-api.com/:path*',
},
];
},
};
CORS Checklist
+------+----------------------------------------------+----------+
| # | Check | Status |
+------+----------------------------------------------+----------+
| 1 | CORS origin is an allowlist (not wildcard) | [ ] |
| 2 | OPTIONS preflight handled (returns 204) | [ ] |
| 3 | Credentials configured correctly | [ ] |
| 4 | Vary: Origin header included | [ ] |
| 5 | Only necessary methods in Allow-Methods | [ ] |
| 6 | Only necessary headers in Allow-Headers | [ ] |
| 7 | Max-Age set to reduce preflight frequency | [ ] |
| 8 | No origin reflection (echoing any origin) | [ ] |
| 9 | Expose-Headers set for custom response headers| [ ] |
| 10 | CORS errors tested in staging environment | [ ] |
+------+----------------------------------------------+----------+
Summary
CORS is the browser's mechanism for relaxing the Same-Origin Policy. The server explicitly declares which cross-origin requests are allowed.
Key takeaways:
- Same-Origin Policy blocks cross-origin reads by default. CORS opt-in allows them.
- Simple requests (GET, POST with simple headers) go through without preflight. Everything else triggers an OPTIONS preflight.
- Credentials require an exact origin (no wildcard) and
Access-Control-Allow-Credentials: true. - Never use wildcard origin for APIs that handle user data or authentication.
- Never reflect the origin — it is functionally equivalent to wildcard.
- Include
Vary: Originwhen responses differ by origin (almost always). - Handle OPTIONS requests — missing preflight handling is the #1 cause of CORS errors.
- CORS is browser-enforced — it does not protect your API from non-browser clients (curl, Postman, server-to-server). Always use authentication and authorization on the server regardless of CORS configuration.