🌐 CORS and CSP
"Why is my fetch blocked by CORS?" is one of the most-Googled errors in web development — and Content Security Policy is one of the most powerful defenses you're probably not using yet. Both are browser security mechanisms built on one foundation: the Same-Origin Policy. Master them and you'll debug cross-origin errors in seconds and add a real safety net under your app.
Week 9 · Day 5 (Friday: Security Best Practices) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Define an origin and explain what the Same-Origin Policy restricts
- Describe how CORS selectively relaxes that policy with response headers
- Trace a preflight OPTIONS request and know what triggers one
- Configure the Express
corsmiddleware with an origin allowlist and credentials — safely - Write a Content Security Policy that mitigates XSS using nonces, and deploy it with helmet
- Debug the most common CORS and CSP failures from the browser console
Estimated Time: 70 minutes
Practice: Configure a CORS allowlist and a strict CSP for a small Express API.
In This Lesson
Two Mechanisms, One Foundation
Browsers are hostile territory: every user has dozens of tabs open, any of which might be malicious. To keep sites from interfering with one another, browsers enforce the Same-Origin Policy. CORS and CSP are the two knobs that let you, the developer, adjust that policy deliberately:
- CORS (Cross-Origin Resource Sharing) — relaxes the policy so chosen origins can call your API
- CSP (Content Security Policy) — tightens it so your page only loads resources you approve
📖 One relaxes, one restricts
They pull in opposite directions on purpose. CORS opens a controlled door into your API for trusted front-ends. CSP builds a wall around your page so an injected script has nowhere to phone home. Together they give you fine-grained control over both directions of traffic.
The Same-Origin Policy
An origin is the triple of protocol + host + port. Two URLs share an origin only if all three match exactly. Under the Same-Origin Policy, a script on one origin cannot read data from another origin — it can't read another site's cookies, its DOM, or the body of a cross-origin fetch.
| URL | Origin | Same origin as https://example.com? |
|---|---|---|
https://example.com/page | https://example.com | ✅ Yes |
https://example.com:443/x | https://example.com | ✅ Yes (443 is the default HTTPS port) |
http://example.com/page | http://example.com | ❌ No — protocol differs |
https://api.example.com/x | https://api.example.com | ❌ No — host differs |
https://example.com:8080/x | https://example.com:8080 | ❌ No — port differs |
Some cross-origin actions are allowed without reading the response: loading images, stylesheets, scripts, and iframes, and submitting forms. That's why a <script src> from a CDN works, but a fetch to that CDN's API needs CORS.
💡 A real-world analogy
Think of the browser as an office building. Everyone from Company A (one origin) can walk freely between A's rooms and read A's files. But to step into Company B's offices — to read B's data — you need B to explicitly grant you a badge. CORS is B handing out that badge in the form of response headers.
CORS: Cross-Origin Resource Sharing
When your front-end at app.example.com calls an API at api.example.com, the browser automatically adds an Origin header. The server decides whether to allow the read by echoing back an Access-Control-Allow-Origin header. If it's missing or doesn't match, the browser blocks your JavaScript from seeing the response.
# Browser automatically sends:
GET /api/data HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
# Server chooses to allow this origin:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
{ "data": "readable cross-origin" }
The key CORS response headers:
| Header | Controls |
|---|---|
Access-Control-Allow-Origin | Which origin may read the response |
Access-Control-Allow-Methods | Which HTTP methods are permitted |
Access-Control-Allow-Headers | Which request headers are permitted |
Access-Control-Allow-Credentials | Whether cookies/auth may be sent |
Access-Control-Max-Age | How long the preflight result is cached |
⚠️ The wildcard-plus-credentials trap
You may not combine Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true. The browser forbids it, and rightly so — a wildcard that also sent cookies would let any site make authenticated requests as your users. When you need credentials, you must reflect back a specific, allowlisted origin, never *.
Preflight Requests
For "non-simple" requests — anything using PUT/DELETE/PATCH, a custom header like Authorization, or a JSON content type — the browser first sends a preflight: an automatic OPTIONS request that asks "am I allowed to make the real request?" Only if the server approves does the actual request go out.
# 1. Preflight (sent automatically by the browser)
OPTIONS /api/data HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization
# 2. Server approves the actual request
HTTP/1.1 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
💡 Why preflight exists
Before CORS, the only cross-origin requests JavaScript could make were the "simple" ones a plain HTML form could already send. Preflight lets the browser check permission before sending a potentially state-changing PUT or DELETE — so a hostile page can't fire off a dangerous cross-origin request and hope. Access-Control-Max-Age caches the approval to avoid a round-trip on every call.
CORS in Express
Don't implement CORS by hand — use the well-tested cors middleware. Start restrictive and open up only what you need.
const express = require('express');
const cors = require('cors');
const app = express();
// ❌ Allows EVERY origin — fine for a fully public, cookieless API only
// app.use(cors());
// ✅ Allowlist specific origins, with credentials
app.use(cors({
origin: ['https://app.example.com', 'https://admin.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // cookies allowed — so origin must NOT be '*'
maxAge: 3600 // cache preflight for 1 hour
}));
For dynamic allowlists (subdomains, env-driven config), pass a function. Crucially, reject unknown origins rather than reflecting them blindly:
const allowlist = new Set([
'https://app.example.com',
'https://admin.example.com'
]);
app.use(cors({
origin(origin, callback) {
// Allow non-browser tools (curl, mobile) that send no Origin
if (!origin) return callback(null, true);
if (allowlist.has(origin)) return callback(null, true);
return callback(new Error('Not allowed by CORS'));
},
credentials: true
}));
⚠️ Never reflect the Origin unconditionally
A tempting anti-pattern is res.header('Access-Control-Allow-Origin', req.headers.origin) with no check — effectively a wildcard that also works with credentials. That hands every website access to your authenticated API. Always compare against an allowlist first.
Content Security Policy
Where CORS governs who calls you, CSP governs what your own page is allowed to load and run. It's the single most effective defense-in-depth against XSS: even if an attacker injects a <script>, a good CSP means the browser refuses to execute it.
You deliver it as the Content-Security-Policy HTTP header, a semicolon-separated list of directives, each naming approved sources.
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', [
"default-src 'self'", // fallback: same origin only
"script-src 'self' https://trusted-cdn.com", // where scripts may load from
"style-src 'self'",
"img-src 'self' data:", // allow inline data: images
"connect-src 'self' https://api.example.com", // fetch/XHR/WebSocket targets
"object-src 'none'", // block <object>/<embed>
"frame-ancestors 'self'" // anti-clickjacking
].join('; '));
next();
});
Common directives and source keywords:
| Directive / value | Meaning |
|---|---|
default-src | Fallback for any resource type not otherwise set |
script-src | Valid sources for JavaScript |
connect-src | Valid targets for fetch, XHR, WebSocket |
frame-ancestors | Who may embed you in a frame (clickjacking defense) |
'self' | The page's own origin |
'none' | Nothing is allowed |
'nonce-{random}' | Allow one specific inline script carrying this nonce |
Nonces: safely allowing an inline script
Avoid 'unsafe-inline' — it defeats the whole purpose. When you truly need an inline script, generate a fresh random nonce per request and mark the script with it. An injected script won't know the nonce, so it's blocked.
const crypto = require('crypto');
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
res.setHeader('Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${nonce}'`);
next();
});
// In your template (EJS):
// <script nonce="<%= cspNonce %>">/* allowed — carries the nonce */</script>
Report before you enforce
Roll CSP out safely with report-only mode: the browser reports what would be blocked without breaking anything, letting you tune the policy against real traffic first.
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy-Report-Only',
"default-src 'self'; report-uri /csp-report");
next();
});
app.post('/csp-report', express.json({ type: '*/*' }), (req, res) => {
console.warn('CSP violation:', req.body);
res.status(204).end();
});
CORS + CSP with helmet
In a real app you'll set CORS on the API and CSP (plus a dozen other headers) via helmet. Here they are together:
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const crypto = require('crypto');
const app = express();
// Cross-origin access: allowlist only
app.use(cors({
origin: ['https://app.example.com', 'https://admin.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
// A fresh nonce per request
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
// Baseline secure headers + a tuned CSP
app.use(helmet());
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https://trusted-images.com'],
connectSrc: ["'self'", 'https://api.example.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
objectSrc: ["'none'"],
frameAncestors: ["'self'"],
upgradeInsecureRequests: [] // auto-upgrade http → https
}
}));
app.listen(3000, () => console.log('Secured on :3000'));
✅ React and strict CSP
React auto-escapes text, which already blunts XSS, but strict CSP can clash with dev tooling that uses eval for hot-reload. Keep 'unsafe-eval' to development only, and prefer CSS-in-JS libraries that emit real stylesheets over inline style attributes so you don't need 'unsafe-inline' for styles.
Troubleshooting
Ninety percent of CORS and CSP bugs come down to a handful of messages. Learn to read them.
CORS: credentials not included
// Error: CORS header 'Access-Control-Allow-Origin' does not match / is missing
// Fix — server reflects the exact origin AND allows credentials:
// Access-Control-Allow-Origin: https://app.example.com
// Access-Control-Allow-Credentials: true
// ...and the client explicitly opts in:
fetch('https://api.example.com/data', { credentials: 'include' });
CORS: preflight 404
// The OPTIONS request isn't handled, so preflight fails before the real call.
// The cors() middleware answers OPTIONS automatically when applied globally.
// If you scope CORS per route, also handle preflight for it:
app.options('/api/data', cors());
CSP: blocked inline script
// Error: Refused to execute inline script because it violates the
// Content Security Policy directive "script-src 'self'".
// Fix — add a nonce (NOT 'unsafe-inline'):
// script-src 'self' 'nonce-abc123'
// <script nonce="abc123">...</script>
🔎 Debugging tip: use the right browser tab
For CORS, open the Network tab and inspect the preflight OPTIONS request's response headers — the missing or mismatched header is usually right there. For CSP, the Console tab prints the exact directive that was violated and the resource it blocked. Read the message; it almost always names the fix.
Practice & Quiz
🏋️ Exercise 1: Build a safe CORS allowlist
Goal: Configure cors so requests from https://app.example.com and https://admin.example.com are allowed with credentials and a 1-hour preflight cache, while every other origin is rejected. Allow GET, POST, PUT and the Authorization header.
💡 Hint
Pass an array (or a function) to origin, set credentials: true, list your methods and allowedHeaders, and set maxAge in seconds. Remember: credentials means you can't use *.
✅ Solution
app.use(cors({
origin: ['https://app.example.com', 'https://admin.example.com'],
methods: ['GET', 'POST', 'PUT'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 3600
}));
🏋️ Exercise 2: Write a strict CSP
Goal: Produce a policy string that allows scripts only from your own origin and https://cdn.example.com, styles only from your origin, images from your origin plus data: URIs, blocks all plugins, and prevents framing by other sites.
✅ Solution
const policy = [
"default-src 'self'",
"script-src 'self' https://cdn.example.com",
"style-src 'self'",
"img-src 'self' data:",
"object-src 'none'",
"frame-ancestors 'self'"
].join('; ');
res.setHeader('Content-Security-Policy', policy);
🎯 Quick Quiz
Question 1: Which two URLs share the same origin?
Question 2: What HTTP method does a CORS preflight use?
Question 3: Why can't you use Access-Control-Allow-Origin: * together with credentials?
Best Practices & Pitfalls
✅ Do
- Maintain an explicit origin allowlist; reject unknown origins
- Use the vetted
corsandhelmetmiddleware rather than hand-rolling headers - Start CSP in report-only mode, then enforce once it's clean
- Prefer nonces or hashes over
'unsafe-inline'; setobject-src 'none' - Add
frame-ancestorsto block clickjacking andupgrade-insecure-requests
❌ Don't
- Combine
Access-Control-Allow-Origin: *withcredentials: true - Reflect
req.headers.originback without checking it against an allowlist - Reach for
'unsafe-inline'/'unsafe-eval'to "make CSP errors go away" - Assume CORS is a server firewall — it's enforced by the browser, not your server
⚠️ CORS is not authorization
CORS only controls whether a browser lets JavaScript read a cross-origin response. A curl or server-side client ignores it entirely. Never treat CORS as access control — you still need real authentication and authorization on every endpoint.
Summary
🎉 Key Takeaways
- An origin is protocol + host + port; the Same-Origin Policy blocks cross-origin reads
- CORS relaxes that policy via server response headers, enforced by the browser
- Preflight (an automatic
OPTIONS) checks permission before non-simple requests - Use an origin allowlist; never pair wildcard origin with credentials
- CSP is defense-in-depth against XSS — favour
'self'and nonces, avoid'unsafe-inline' - Deploy both with the
corsandhelmetmiddleware
📚 Additional Resources
- MDN — Cross-Origin Resource Sharing (CORS)
- MDN — Content Security Policy (CSP)
- Express — cors middleware
- Helmet.js documentation
- OWASP — the attacks CSP and same-origin controls help mitigate
🚀 What's Next?
You've controlled who may talk to your API and what your pages may load. The remaining question is how much and how fast — throttling abusive traffic before it takes you offline. Next: Rate limiting and DDoS protection.
🌐 Cross-origin, under control
No more mystery CORS errors, and a real safety net under your front-end. That's two of the web's most confusing topics, demystified.