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

Sponsored

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.

  1. The browser builds a request from a script on PageOrigin to a URL on APIOrigin. If PageOrigin and APIOrigin are the same, no CORS check occurs.
  2. 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 an OPTIONS request first.
  3. 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-Methods and Access-Control-Allow-Headers — for preflight. - Access-Control-Max-Age — how long the browser caches the preflight result.
  4. The browser checks the headers. If Access-Control-Allow-Origin matches 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-Headers or Access-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: * and Access-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:3000 is different from https://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: Origin header missing — Some non-browser clients (like mobile apps) don't send Origin. 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.com by a miswritten pattern (e.g., .*example.com matches notexample.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-Methods and Access-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:3000 and https://localhost:3000 are different origins, and missing one will cause confusing CORS errors.

Variations

  1. Write your own CORS middleware to dynamically validate origins against a database or environment-specific config.
  2. Use a reverse proxy (like Nginx or a service mesh) to inject CORS headers centrally instead of in each application.
  3. 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: * with Access-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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.