Configure CORS Restrictions
Set up CORS restrictions properly to secure your web app. Learn the core concepts and practical steps, then test your knowledge with a hands-on exercise. Explore troubleshooting and best practices to avoid common security pitfalls.
Focus: configure CORS restrictions properly
Cross-origin resource sharing (CORS) is one of those browser security features that quietly prevents your bank's API from being called by a random phishing site. But when misconfigured—say, using Access-Control-Allow-Origin: * on an authenticated endpoint—you turn that protection into a gaping hole. In this lesson, you'll learn how to configure CORS restrictions properly, understand the mental model behind origins and preflight requests, and finish with a hardened configuration you can use right away.
The problem this lesson solves
CORS is broken by default in exactly the right way: browsers block cross-origin reads by default. The problem is that many developers, in a rush to let their SPA talk to their API, flip CORS wide open with a wildcard. That one line—Access-Control-Allow-Origin: *—tells the browser that any website can read the response. If your endpoint uses cookies or Authorization headers, an attacker on a malicious site can trigger cross-origin requests from your victim's browser, and because the credentials are automatically included, your API can't tell the difference between the victim and the attacker. This leads to CSRF-style attacks, data exfiltration, and account takeover. The pain is real: security scanners flag wildcard CORS, and real-world breaches have started exactly this way. You need to understand why CORS exists, what it protects, and how to configure it as a precise allowlist rather than an open door.
Core concept / mental model
Think of CORS as the browser's bouncer at the door of every cross-origin request. When a web page on https://alpha.com wants to fetch data from https://api.beta.com, the browser checks whether api.beta.com has explicitly allowed alpha.com to read the response. If not, the request the attacker's web page makes will be sent, but the browser hides the response from the attacker's JavaScript, because the bouncer says "not allowed."
An origin is the triple of scheme, host, and port. https://example.com:443 differs from http://example.com:80 even though the host is the same. The browser uses the same-origin policy (SOP) as its baseline: a page can only read responses from its own origin. CORS is a mechanism to relax that policy explicitly.
Here's the mental model:
- CORS is a browser-enforced policy. It does not protect your server from direct requests (curl, scripts, or other servers). It protects your users' browsers from malicious websites reading your API responses while the user is logged in.
- Credentials change everything. If your API uses Access-Control-Allow-Credentials: true, then Access-Control-Allow-Origin cannot be *. The browser requires a specific origin (no wildcard).
- Preflight requests. For non-simple requests (like those with Authorization headers or application/json bodies), the browser first sends an OPTIONS request to see if the actual request is allowed. If the preflight fails, the actual request never happens.
How it works step by step
Let's trace a typical CORS flow so you can debug any issue.
- The browser builds a request from a script on
PageOriginto a URL onAPIOrigin. IfPageOriginandAPIOriginare the same, no CORS check occurs. - If cross-origin, the browser classifies the request. A simple request (GET/POST with standard form content types) is sent directly. A preflighted request (custom headers like
Authorization,Content-Type: application/json) triggers anOPTIONSrequest first. - The server responds to the request (or preflight) with CORS headers:
-
Access-Control-Allow-Origin— which origins can read the response. -Access-Control-Allow-Credentials— whether to include cookies or HTTP auth. -Access-Control-Allow-MethodsandAccess-Control-Allow-Headers— for preflight. -Access-Control-Max-Age— how long the browser caches the preflight result. - The browser checks the headers. If
Access-Control-Allow-Originmatches the page's origin (and credentials are handled correctly), the browser gives the JavaScript access to the response. Otherwise, it logs a CORS error and blocks the response.
Hands-on walkthrough
Let's build a small but realistic example: a Flask API that serves JSON and uses sessions (cookies). We'll start with an insecure configuration and then harden it.
Step 1: Insecure baseline
from flask import Flask, jsonify, request, session
from flask_cors import CORS
app = Flask(__name__)
app.secret_key = "super-secret-key"
CORS(app) # Danger: allows all origins with credentials
@app.route('/api/profile')
def profile():
user_id = session.get('user_id')
if not user_id:
return jsonify({'error': 'not logged in'}), 401
return jsonify({'id': user_id, 'name': 'Alice'})
if __name__ == '__main__':
app.run(port=5000)
The CORS(app) line defaults to Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true. That combination is invalid per the spec (the browser will reject it), but many libraries silently handle it in a dangerous way—or they set the wildcard and credentials to true, which means any site can make credentialed requests. This is a real vulnerability, not just a spec violation.
Step 2: Hardened configuration
from flask import Flask, jsonify, session
from flask_cors import CORS
app = Flask(__name__)
app.secret_key = "super-secret-key"
# Define an explicit allowlist of origins
ALLOWED_ORIGINS = {
"https://app.example.com",
"https://admin.example.com",
}
CORS(
app,
origins=ALLOWED_ORIGINS,
methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
supports_credentials=True,
max_age=3600
)
@app.route('/api/profile')
def profile():
user_id = session.get('user_id')
if not user_id:
return jsonify({'error': 'not logged in'}), 401
return jsonify({'id': user_id, 'name': 'Alice'})
if __name__ == '__main__':
app.run(port=5000)
Now the browser will only allow https://app.example.com and https://admin.example.com to read responses. Any other origin will be blocked by the browser, even though the server still processes the request (CORS does not block the request itself).
Expected output for a preflight request (send with curl):
$ curl -X OPTIONS https://api.example.com/api/profile -H "Origin: https://app.example.com" -H "Access-Control-Request-Method: GET" -D -
HTTP/2 200
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 3600
Notice that Access-Control-Allow-Origin reflects the specific origin, not a wildcard.
Step 3: Using a library vs. middleware
You can also implement CORS manually with a WSGI middleware if you don't want the dependency. Here's a minimal example in Flask:
from flask import Flask, jsonify, request
app = Flask(__name__)
ALLOWED_ORIGINS = {"https://app.example.com"}
@app.after_request
def add_cors_headers(response):
origin = request.headers.get("Origin")
if origin in ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE"
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
response.headers["Access-Control-Max-Age"] = "3600"
return response
@app.route("/api/profile")
def profile():
return jsonify({"id": 1, "name": "Alice"})
if __name__ == "__main__":
app.run()
This gives you fine control and avoids the pitfalls of a library's default configuration.
Compare options / when to choose what
| Approach | Pros | Cons | Best when |
|---|---|---|---|
| Library (flask-cors) | Quick, handles preflight headers automatically | Defaults can be insecure if you forget to set an allowlist | You need a quick start and you'll explicitly configure origins |
| Middleware (manual) | Full control, no hidden behavior | More code to maintain, easy to miss a header | You have strict security requirements or a custom framework |
| CDN / gateway level | Centralized policy, good for microservices | CORS headers must be consistent across services | You have a gateway (e.g., Nginx, AWS API Gateway) that can inject headers |
When to choose what:
- Use a library if you're on a standard framework (Django, Flask, Express) and you will read the docs to set origins explicitly.
- Write middleware if you need dynamic origin validation (e.g., an allowlist stored in a database) or your framework doesn't have a good library.
- Use a gateway if you have many services and want a single place to enforce policy.
Troubleshooting & edge cases
- “Response to preflight request doesn't pass access control check” — The server didn't send the correct
Access-Control-Allow-HeadersorAccess-Control-Allow-Methods. Check that you include all headers and methods your frontend uses. - Wildcard origin with credentials — If you see
Access-Control-Allow-Origin: *andAccess-Control-Allow-Credentials: true, the browser will reject the request. Fix by echoing the specific origin. - CORS errors only in the browser, not in curl — That's normal. CORS is a browser enforcement; curl doesn't enforce it. Don't try to debug CORS with curl alone.
- Serving over HTTPS vs HTTP — Origins are scheme-specific.
http://localhost:3000is different fromhttps://localhost:3000. Ensure your allowlist matches exactly. - Browser cache — Preflight responses are cached based on
Access-Control-Max-Age. If you update CORS config and still see old behavior, add a cache-busting query or wait for the cache to expire. - Edge case:
Originheader missing — Some non-browser clients (like mobile apps) don't sendOrigin. Your server-side logic should not rely on CORS to enforce security; treat CORS as a UX feature, not an access control mechanism. - Regular expressions in origins — Avoid using regex-based matching unless you're absolutely sure. It's easy to accidentally allow
evil-example.comby a miswritten pattern (e.g.,.*example.commatchesnotexample.com). Prefer an exact allowlist.
What you learned & what's next
You now understand that CORS is a browser-side policy that defines which origins can read your API responses. You learned that using * with credentials is a security bed, and that a precise allowlist is the right way to configure CORS restrictions properly. You also saw how to implement CORS with Flask and how to choose between library, middleware, or gateway approaches. Finally, you can diagnose preflight failures and recognize that CORS is not a substitute for server-side authentication.
Next step: In the next lesson, you'll apply this allowlist mindset to other HTTP security headers (like CSP and HSTS) to build a layered defense. By combining CORS restrictions with content security policies and proper authentication, you'll make it significantly harder for attackers to exploit cross-origin holes. Keep your allowlists tight, and remember: when in doubt, add Access-Control-Allow-Origin from a compassionate server-side check, not a blind wildcard.
Practice recap
Now it's your turn: take the insecure Flask example and modify it so that CORS only allows https://myapp.example.com and https://admin.myapp.example.com, with Authorization and Content-Type headers allowed. Then start your server, open your browser at https://myapp.example.com, and confirm the profile endpoint works while a different origin (e.g., https://evil.com) gets blocked by the browser.
Common mistakes
- Using
Access-Control-Allow-Origin: *on endpoints that require cookies or HTTP auth — this combination is invalid and a severe security risk. - Forgetting to restrict
Access-Control-Allow-MethodsandAccess-Control-Allow-Headers— this leaves preflight open to any method or header, expanding the attack surface. - Mixing HTTP and HTTPS origins in the allowlist —
http://localhost:3000andhttps://localhost:3000are different origins, and missing one will cause confusing CORS errors.
Variations
- Write your own CORS middleware to dynamically validate origins against a database or environment-specific config.
- Use a reverse proxy (like Nginx or a service mesh) to inject CORS headers centrally instead of in each application.
- Apply CORS configuration at the API gateway level when you have multiple services, to keep policies consistent.
Real-world use cases
- A customer-facing SaaS that exposes a REST API with cookie-based sessions, requiring CORS to allow only the authenticated SPA origin.
- A mobile app backend that serves data to a web dashboard — CORS configured to allow the dashboard's exact domain while the mobile app bypasses CORS via no Origin header.
- A microservices architecture where frontend calls multiple internal APIs — a gateway enforces a centralized CORS policy to prevent cross-origin abuse.
Key takeaways
- CORS is a browser-enforced policy, not a server-side access control — your API still processes unauthorized requests.
- Never use
Access-Control-Allow-Origin: *withAccess-Control-Allow-Credentials: true. - Always maintain an explicit allowlist of origins, not a wildcard or regex.
- Preflight requests must include all allowed methods and headers your frontend uses.
- Test CORS in a real browser environment, not with curl, because curl ignores CORS entirely.
- Configure CORS alongside other security headers (CSP, HSTS) and robust authentication to build defense in depth.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.