CORS and CSP: Web Security Headers Explained
When you start building modern web applications, you quickly discover that browsers enforce a set of security policies that can feel like invisible gatekeepers. Two of the most talked‑about gatekeepers are Cross‑Origin Resource Sharing (CORS) and Content Security Policy (CSP). Both are HTTP response headers, but they solve different problems: CORS governs who can fetch resources from your server, while CSP tells browsers what kind of content your pages are allowed to load and execute. Understanding these headers not only prevents nasty bugs, it also raises the security bar of your entire stack.
What Is CORS?
CORS is a browser‑level mechanism that relaxes the same‑origin policy (SOP) for trusted origins. By default, a script running on https://app.example.com cannot read data from https://api.another.com because they have different origins. CORS lets the server at api.another.com explicitly declare which origins may access its resources, using the Access-Control-Allow-Origin header.
The flow is simple: the browser sends a “preflight” OPTIONS request to the target server, the server replies with CORS headers, and the browser decides whether to allow the actual request. If the headers are missing or don’t match the requesting origin, the browser blocks the response and logs a CORS error in the console.
Key CORS Headers
- Access-Control-Allow-Origin: The single most important header; can be a specific origin or
*for any origin. - Access-Control-Allow-Methods: Lists HTTP methods (GET, POST, PUT, etc.) that are permitted.
- Access-Control-Allow-Headers: Indicates which custom request headers the client may send.
- Access-Control-Allow-Credentials: When set to
true, cookies and HTTP authentication are allowed to be sent.
These headers are additive; you can combine them to create a fine‑grained policy that matches your application’s trust model.
Configuring CORS in a Python Flask API
Flask doesn’t ship with built‑in CORS support, but the flask-cors extension makes it painless. Below is a minimal example that allows only https://dashboard.example.com to access the /data endpoint.
from flask import Flask, jsonify
from flask_cors import CORS
app = Flask(__name__)
# Apply CORS only to the /data route, restrict to a single origin
CORS(app, resources={
r"/data": {"origins": "https://dashboard.example.com"}
})
@app.route('/data')
def get_data():
return jsonify({
"message": "Secure data payload",
"timestamp": "2026-01-31T12:00:00Z"
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
Notice how the CORS call receives a dictionary mapping a URL pattern to an origins value. This pattern‑based approach scales well when you have multiple micro‑services, each with its own trust list.
Testing the CORS Policy
- Open the browser’s DevTools → Network tab.
- Make a fetch request from
https://dashboard.example.comtohttp://localhost:5000/data. - Verify that the response contains
Access-Control-Allow-Origin: https://dashboard.example.com.
If the request originates from any other domain, the browser will block the response and you’ll see a CORS error in the console.
Pro Tip: Never use*together withAccess-Control-Allow-Credentials: true. The spec disallows it because it defeats the purpose of credential protection.
Common CORS Pitfalls
Developers often fall into the trap of “open CORS” by setting Access-Control-Allow-Origin: * on every endpoint. While it removes errors during development, it also opens the door for malicious sites to read sensitive data, especially if you also enable credentials.
Another subtle issue is forgetting to expose custom response headers. By default, browsers only expose a whitelist (e.g., Cache-Control, Content-Type). If your API returns a header like X-User-Id, you must add it to Access-Control-Expose-Headers so the client can read it.
Finally, preflight requests can be expensive. If you’re sending a lot of custom headers or using non‑simple methods (PUT, DELETE), the browser will fire an OPTIONS request before every actual request. Consolidating headers or using simple GET/POST where possible reduces latency.
What Is CSP?
Content Security Policy is a declarative security layer that mitigates cross‑site scripting (XSS), clickjacking, and data injection attacks. Instead of relying on runtime sanitization alone, CSP tells the browser exactly which sources of scripts, styles, images, and other resources are trusted.
A CSP is delivered via the Content‑Security‑Policy header (or its report‑only variant). The policy consists of directives, each controlling a specific type of resource. If a resource violates the policy, the browser blocks it and optionally sends a violation report to a URL you specify.
Core CSP Directives
- default-src: Fallback source list for all resource types that don’t have a specific directive.
- script-src: Controls where JavaScript can be loaded from.
- style-src: Controls CSS source locations.
- img-src: Limits image origins.
- connect-src: Governs AJAX, WebSocket, and EventSource connections.
- frame-ancestors: Replaces the older
X-Frame-Optionsheader to prevent clickjacking.
Each directive can contain keywords like 'self' (same origin), 'none' (block all), 'unsafe-inline' (allow inline scripts), and 'strict-dynamic' (trust scripts loaded by trusted scripts).
Implementing CSP in a Flask App
Flask makes it easy to add response headers via the after_request decorator. Below is a pragmatic CSP that blocks all inline scripts, allows scripts only from the same origin and a trusted CDN, and enables reporting.
from flask import Flask, jsonify, request, make_response
app = Flask(__name__)
CSP_POLICY = (
"default-src 'self'; "
"script-src 'self' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self' https://api.example.com; "
"frame-ancestors 'none'; "
"report-uri /csp-report"
)
@app.after_request
def set_csp(response):
response.headers['Content-Security-Policy'] = CSP_POLICY
return response
@app.route('/csp-report', methods=['POST'])
def csp_report():
# In a real app, log the report to a monitoring service
report = request.get_json()
print('CSP Violation:', report)
return make_response('', 204)
@app.route('/')
def index():
return '''
'''
if __name__ == '__main__':
app.run(debug=True)
The after_request hook injects the CSP header into every response. The /csp-report endpoint receives JSON violation reports, which you can forward to a log aggregation service for later analysis.
Pro Tip: Start with Content-Security-Policy-Report-Only while you fine‑tune your policy. It logs violations without breaking legitimate functionality, making the transition painless.
Testing CSP in the Browser
- Open DevTools → Console. Any CSP violation appears as a warning.
- Use the “Network” tab to verify that the
Content‑Security‑Policyheader is present on every request. - Try injecting an inline script (e.g.,
<script>alert('XSS')</script>) and confirm the browser blocks it.
If you see a “Refused to execute inline script because it violates the following CSP directive” message, your policy is working as intended.
CORS Meets CSP: A Real‑World Scenario
Imagine you’re building a SaaS dashboard that consumes a REST API hosted on a separate subdomain. The API must be reachable from the dashboard (CORS) while the dashboard’s front‑end must protect itself against XSS (CSP). Both headers can coexist without conflict, but you need to align their directives.
First, the API sends a permissive CORS header for the dashboard’s origin:
# In the API server
CORS(app, resources={r"/v1/*": {"origins": "https://dashboard.example.com"}})
Second, the dashboard serves a CSP that only trusts the API domain for connect-src and the CDN for scripts:
CSP_POLICY = (
"default-src 'self'; "
"script-src 'self' https://cdn.jsdelivr.net; "
"connect-src 'self' https://api.example.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"frame-ancestors 'none'; "
)
With this combination, a malicious site cannot read the API response (CORS blocks it) and cannot inject rogue scripts into the dashboard (CSP blocks it). The two headers reinforce each other, creating a layered defense.
Advanced CSP Techniques
Modern browsers support nonce‑based and hash‑based inline script allowances. Instead of disabling all inline scripts, you can generate a random nonce per request, embed it in the HTML, and reference it in the CSP header.
Here’s a quick Flask snippet that adds a nonce to every response and injects it into a template:
import secrets
from flask import Flask, render_template_string, after_this_request
app = Flask(__name__)
@app.route('/')
def home():
nonce = secrets.token_urlsafe(16)
@after_this_request
def add_csp(response):
csp = f"default-src 'self'; script-src 'self' 'nonce-{nonce}';"
response.headers['Content-Security-Policy'] = csp
return response
html = f'''
Nonce‑protected script
'''
return render_template_string(html)
if __name__ == '__main__':
app.run()
The nonce value changes on every request, making it impossible for an attacker to guess and reuse. Browsers will only execute <script> tags that carry the matching nonce.
Pro Tip: Combine nonces withstrict-dynamicfor even tighter control. Whenscript-src 'nonce-…' 'strict-dynamic'is present, any script loaded by a trusted script inherits the trust, eliminating the need for long whitelists.
CSP Reporting and Monitoring
Enabling report-uri (or the newer report-to) turns CSP into a passive monitoring tool. Violation reports are sent as JSON payloads to the endpoint you define, allowing you to spot misconfigurations before they affect real users.
A minimal report handler in Flask might look like this:
from flask import request, jsonify
@app.route('/csp-report', methods=['POST'])
def csp_report():
report = request.get_json()
# Store the report in a file or send to a SIEM
with open('csp_violations.log', 'a') as f:
f.write(json.dumps(report) + '\n')
return jsonify(status='received'), 204
Regularly reviewing csp_violations.log helps you discover rogue third‑party scripts, outdated CDN URLs, or accidental inline event handlers that slipped through code review.
Performance Considerations
Both CORS and CSP add a small amount of overhead to each HTTP response. CORS preflight requests can double the number of round trips for non‑simple requests, while CSP headers increase the size of each response by a few hundred bytes. In high‑traffic environments, you can mitigate this by:
- Caching preflight responses with
Access-Control-Max-Age. - Compressing headers using HTTP/2 or HTTP/3.
- Serving static assets (JS/CSS) from a CDN that already enforces a strict CSP.
These optimizations keep latency low without sacrificing security.
Best‑Practice Checklist
- Never use
*with credentials. RestrictAccess-Control-Allow-Originto explicit origins. - Whitelist only the domains you truly need. A short allowlist reduces attack surface.
- Prefer
nonceorhashover'unsafe-inline'. Inline scripts are a common XSS vector. - Use
Content-Security-Policy-Report-Onlyfirst. Iterate on the policy without breaking users. - Log and monitor CSP violation reports. Treat them as early warnings.
- Set
Access-Control-Max-Ageto a reasonable value (e.g., 86400 seconds). Reduces preflight traffic. - Test in multiple browsers. CSP implementations vary slightly across Chrome, Firefox, and Safari.
Real‑World Example: A Micro‑Frontend Architecture
Many large enterprises adopt micro‑frontends, where each team owns a separate sub‑application served from its own domain (e.g., ui.team1.example.com, ui.team2.example.com). The shell app loads these micro‑apps via iframe or dynamic import() calls.
In such a setup, you can enforce:
- CORS on each micro‑service to allow only the shell’s origin.
- CSP on the shell to restrict
frame-srcto the known micro‑frontend domains. - Individual CSP on each micro‑frontend to lock down its own resources