Skip to content

The browser blocked my front end from calling my own API

  • Frameworks · Foundations
  • 20 min read, about 1 h 10 min with practice
  • Concept
  • Asked often: medium
  • Covers Fetch WHATWG Fetch standard, Django 5.2, Python 3.11+
  • Reviewed 2026-11

Your notes front end runs at https://app.example.com. The API runs at https://api.example.com. On your laptop, curl gets the notes fine. In the browser, the console says:

Access to fetch at 'https://api.example.com/notes' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

A teammate says “just add Access-Control-Allow-Origin: *”. The list loads. Then saving a note, which sends the login cookie, fails with a new CORS error. And a security review asks a harder question: could any website now read your users’ notes?

Write cors_headers(method, origin, asked_headers): the CORS headers the API adds to a response.

  • Input: the request’s method, its Origin header (or None), and for a preflight, the headers the page asks to send.
  • Output: a dict of response headers.
  • The rules: only our front ends may read answers, with cookies: https://app.example.com and http://localhost:5173. A preflight (OPTIONS) also needs the allowed methods and headers.
  • The hard part: our app must work with cookies, while every other site stays blocked.

Example: cors_headers("GET", "https://app.example.com") gives {"Access-Control-Allow-Origin": "https://app.example.com", "Access-Control-Allow-Credentials": "true", "Vary": "Origin"}.

Hints open one at a time:

Hint 1 of 3

The API answered both. curl just showed the answer. The browser chose not to show it to the page.

Hint 2 of 3

The browser protects the user. A page from one site shouldn’t read what another site shows that user, with their cookies.

Hint 3 of 3

So the API must tell the browser which other sites it trusts. That is what the CORS headers say.

An origin is scheme, host and port. https://app.example.com and https://api.example.com are different origins, and so are http:// and https:// of the same host. The browser’s same-origin rule says a page’s JavaScript may send requests anywhere, but may only read answers from its own origin.

CORS (Cross-Origin Resource Sharing) is how a server opens that up. The browser adds Origin: https://app.example.com to the request. If the answer says Access-Control-Allow-Origin: https://app.example.com, the page may read it. With cookies, the answer must also say Access-Control-Allow-Credentials: true, and the origin can’t be *.

Some requests get a preflight. Before a PUT, a DELETE, a JSON body or a custom header, the browser first sends an OPTIONS request: “may this origin send this method with these headers?”. Only if the answer says yes does the real request go.

CSRF is the attack CORS doesn’t stop. CSRF (Cross-Site Request Forgery) is another site making the user’s browser send a request, with the user’s cookies. A plain HTML form posting to your API needs no preflight. The browser sends it, cookie and all. CORS only stops the evil page from reading the answer, and the attacker didn’t need to read it: the email is already changed.

Three guards against CSRF:

  • SameSite cookies. SameSite=Lax keeps the cookie off most cross-site requests, form posts included.
  • A CSRF token. Your own pages put a secret value in every form. Another site can’t read it, so it can’t send it. Django checks this for you.
  • An Origin check. Refuse a change whose Origin header isn’t one of yours.
Our app, an evil page and the API

CORS: our app calls the API

Parts:

  • Our app’s page (the request is here)
  • Evil page
  • Browser
  • Notes API

Arrows:

  • Our app’s page sends requests to Browser
  • Evil page sends requests to Browser
  • Browser sends requests to Notes API

Step 1 of 8. Our page at https://app.example.com runs fetch("https://api.example.com/notes/3", {method: "PUT", body: JSON}). The API is on another origin.

Three versions of cors_headers. The tests check each one the way a browser would.

Okay

Allow-Origin: *

The idea: Add Access-Control-Allow-Origin: * to every response.

For a public API with no logins, like a weather feed, it is the right answer.

def cors_headers(method: str, origin: str | None, asked_headers: str = "") -> dict[str, str]:
"""Okay: let every site read every response."""
return {"Access-Control-Allow-Origin": "*"}
Who can read
Every site
With cookies
Fails
Why
The star means any origin may read the answer, as long as no cookies were sent.

Where it breaks: a logged-in request sends cookies, and browsers refuse * with cookies, so saving a note fails. And for anything private, “every site may read” is the wrong promise.

Better

An allow list, with cookies

The idea: If the Origin is one of ours, send it back with Allow-Credentials: true. Otherwise, send no CORS headers.

The waste in Okay: it trusted everyone, and so could trust no one with cookies.

Vary: Origin tells caches that the answer depends on the Origin. Without it, a cache could serve our app’s answer to another site.

Highlighted lines are new in this rung.

ALLOWED = {"https://app.example.com", "http://localhost:5173"}
def cors_headers(method: str, origin: str | None, asked_headers: str = "") -> dict[str, str]:
"""Better: only our own front ends, and with cookies."""
if origin not in ALLOWED:
return {}
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
Who can read
Our front endswas Every site
With cookies
Works for GET and formswas Fails
Why
The browser sees its own origin named, and credentials allowed. Other sites get no header, so they are blocked.
What changed from the previous rung
ALLOWED = {"https://app.example.com", "http://localhost:5173"}
def cors_headers(method: str, origin: str | None, asked_headers: str = "") -> dict[str, str]:
"""Okay: let every site read every response."""
return {"Access-Control-Allow-Origin": "*"}
"""Better: only our own front ends, and with cookies."""
if origin not in ALLOWED:
return {}
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}

Where it breaks: the preflight. A PUT with JSON first sends OPTIONS, and the answer doesn’t list the method or the headers. So the browser never sends the PUT.

Best

An allow list, and an answer to the preflight

The idea: Like Better, and for OPTIONS also list the allowed methods and the asked-for headers, if each one is allowed, and how long to remember it.

The waste in Better: it answered the real request, but not the question the browser asks first.

A header the API doesn’t expect, like X-Admin, gets no yes. Max-Age: 600 lets the browser skip the preflight for ten minutes.

Highlighted lines are new in this rung.

ALLOWED = {"https://app.example.com", "http://localhost:5173"}
METHODS = "GET, POST, PUT, PATCH, DELETE"
HEADERS = {"content-type", "authorization", "x-request-id"}
def cors_headers(method: str, origin: str | None, asked_headers: str = "") -> dict[str, str]:
"""Best: our own front ends, with cookies, and an answer to the preflight."""
if origin not in ALLOWED:
return {"Vary": "Origin"}
out = {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
if method == "OPTIONS": # the preflight: "may I send this?"
asked = {h.strip().lower() for h in asked_headers.split(",") if h.strip()}
if not asked <= HEADERS:
return {"Vary": "Origin"}
out["Access-Control-Allow-Methods"] = METHODS
out["Access-Control-Allow-Headers"] = ", ".join(sorted(asked))
out["Access-Control-Max-Age"] = "600"
return out
Who can read
Our front ends
With cookies
Works for every methodwas Works for GET and forms
Why
The preflight now says yes to our methods and headers, so PUT, PATCH and DELETE with JSON go through.
What changed from the previous rung
ALLOWED = {"https://app.example.com", "http://localhost:5173"}
METHODS = "GET, POST, PUT, PATCH, DELETE"
HEADERS = {"content-type", "authorization", "x-request-id"}
def cors_headers(method: str, origin: str | None, asked_headers: str = "") -> dict[str, str]:
"""Better: only our own front ends, and with cookies."""
"""Best: our own front ends, with cookies, and an answer to the preflight."""
if origin not in ALLOWED:
return {}
return {
return {"Vary": "Origin"}
out = {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
if method == "OPTIONS": # the preflight: "may I send this?"
asked = {h.strip().lower() for h in asked_headers.split(",") if h.strip()}
if not asked <= HEADERS:
return {"Vary": "Origin"}
out["Access-Control-Allow-Methods"] = METHODS
out["Access-Control-Allow-Headers"] = ", ".join(sorted(asked))
out["Access-Control-Max-Age"] = "600"
return out

When Best isn’t best: don’t write this yourself in a real app. Use django-cors-headers, Flask-CORS or FastAPI’s CORSMiddleware, and give them the allow list. Better still, serve the front end and the API from one origin, like example.com and example.com/api, so there is nothing to allow.

Write cors_headers(method, origin, asked_headers) from the task. Press Run tests to check your code.

  • For an origin not in ALLOWED, or no origin, give back only {"Vary": "Origin"}.
  • For an allowed origin, give back the origin, Access-Control-Allow-Credentials: true and Vary: Origin.
  • For OPTIONS, also check the asked-for headers against HEADERS, case-insensitively. If one isn’t allowed, give back only {"Vary": "Origin"}. Otherwise add Allow-Methods, Allow-Headers (the asked ones, lower case, sorted) and Max-Age: 600.

CORS headers for our own front ends

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: cors_headers('GET', 'https://app.example.com') → {'Access-Control-Allow-Origin': 'https://app.example.com', 'Access-Control-Allow-Credentials': 'true', 'Vary': 'Origin'}
  2. Not run yet: Another site: no CORS headers → {'Vary': 'Origin'}
  3. Not run yet: A preflight from our app, for a PUT with JSON → {'Access-Control-Allow-Origin': 'https://app.example.com', 'Access-Control-Allow-Credentials': 'true', 'Vary': 'Origin', 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE', 'Access-Control-Allow-Headers': 'authorization, content-type', 'Access-Control-Max-Age': '600'}
  4. Not run yet: A preflight asking for a header we don’t take → {'Vary': 'Origin'}
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayWho can readWith cookiesGoodNot so goodUse it when
Okay: *Every siteFailsOne linePrivate data open to every site; no cookiesPublic data with no login
Better: allow listOur front endsGET and forms onlySafe, simplePreflights fail, so no PUT or JSONAPIs with only simple requests
Best: allow list + preflightOur front endsEvery methodSafe, completeMore to get right; use a libraryAny API a front end on another origin calls
  • CORS doesn’t protect your server. curl and other servers ignore it. It only tells browsers what pages may read.
  • Check origins exactly. origin.endswith("example.com") also trusts evilexample.com. The third practice problem shows this.
  • null is not safe. Some sandboxed pages send Origin: null. Never put "null" on the allow list.
  • A failed preflight shows as a CORS error. If your OPTIONS route needs a login, or crashes, the browser reports CORS, not the real error. Let preflights through without a login.
  • Turn CSRF checks off only for token APIs. An API that uses Authorization: Bearer headers, not cookies, can’t be sent by a plain form, so CSRF doesn’t apply. Cookie-based APIs need the guard.

Questions to ask first

  • Does the API use cookies, or tokens in a header?
  • Which origins host our front ends, in each environment?
  • Could the front end and the API share one origin instead?

A 60-second walkthrough

“The browser lets a page send requests anywhere, but only read answers from its own origin: scheme, host and port. CORS is how a server opens that up. I’d keep an exact allow list of our front ends. For a matching Origin, I send it back in Access-Control-Allow-Origin with Allow-Credentials true and Vary Origin. For preflights, I list the methods and headers we accept. Never star with cookies, and never echo any origin. CORS only stops reading, though. CSRF is a form on another site posting with the user’s cookie. So I’d also use SameSite=Lax cookies and the framework’s CSRF tokens. And I’d check the Origin header on changes.”

Likely follow-ups

  • Why does curl work and the browser not? CORS is enforced by the browser to protect the user. curl doesn’t apply it.
  • What triggers a preflight? A method other than GET, HEAD or POST, a header beyond a few simple ones, or a Content-Type like application/json. The first practice problem writes the rule.
  • Does a JWT in localStorage need CSRF protection? No, a form can’t add the header. But any script on the page can read the token, which is the XSS risk.
  • How does Django stop CSRF? CsrfViewMiddleware checks a secret token in a cookie against the one in each form or X-CSRFToken header, and checks the Origin.

Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.

Write needs_preflight(method, headers). A request skips the preflight only if all of these are true:

  • The method is GET, HEAD or POST.
  • Every header is one of Accept, Accept-Language, Content-Language or Content-Type.
  • A Content-Type, if there is one, is application/x-www-form-urlencoded, multipart/form-data or text/plain. Ignore anything after a ;.

Example: needs_preflight("POST", {"Content-Type": "application/json"}) is True.

Hint 1 of 3

These are what a plain HTML form could send. Anything a form can’t send gets a preflight.

Hint 2 of 3

Header names are case-insensitive. Compare them in lower case.

Hint 3 of 3

"text/plain; charset=utf-8".split(";")[0].strip() is "text/plain".

Does it need a preflight?

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: A POST with JSON → True
  2. Not run yet: A plain form post → False
  3. Not run yet: A GET with an Authorization header → True
  4. Not run yet: needs_preflight('DELETE', {}) → True
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Anything but GET

The idea: A GET skips the preflight; everything else needs one.

It is right for DELETE and PUT.

def needs_preflight(method: str, headers: dict[str, str]) -> bool:
"""Okay: anything other than GET needs a preflight."""
return method != "GET"
Looks at
The method
Wrong on
Forms, headers
Why
It guesses from the method alone.

Where it breaks: a plain form POST needs no preflight, so Okay overstates. And a GET with an Authorization header does need one.

Better

The simple methods

The idea: GET, HEAD and POST skip the preflight.

The waste in Okay: it forgot that forms can POST.

Highlighted lines are new in this rung.

SIMPLE_METHODS = {"GET", "HEAD", "POST"}
def needs_preflight(method: str, headers: dict[str, str]) -> bool:
"""Better: GET, HEAD and POST are simple; everything else needs a preflight."""
return method.upper() not in SIMPLE_METHODS
Looks at
The method
Wrong on
Headers, Content-Typewas Forms, headers
Why
It knows all three simple methods, but not the header rules.
What changed from the previous rung
SIMPLE_METHODS = {"GET", "HEAD", "POST"}
def needs_preflight(method: str, headers: dict[str, str]) -> bool:
"""Okay: anything other than GET needs a preflight."""
return method != "GET"
"""Better: GET, HEAD and POST are simple; everything else needs a preflight."""
return method.upper() not in SIMPLE_METHODS

Where it breaks: a POST with JSON does need a preflight, and Better says no. That is exactly the request most front ends send.

Best

Methods, headers and the content type

The idea: A simple method, only simple headers, and a form-like Content-Type.

The waste in Better: half the rule.

Highlighted lines are new in this rung.

SIMPLE_METHODS = {"GET", "HEAD", "POST"}
SIMPLE_HEADERS = {"accept", "accept-language", "content-language", "content-type"}
SIMPLE_TYPES = {"application/x-www-form-urlencoded", "multipart/form-data", "text/plain"}
def needs_preflight(method: str, headers: dict[str, str]) -> bool:
"""Best: a simple method, simple headers, and a form-like Content-Type skip the preflight."""
if method.upper() not in SIMPLE_METHODS:
return True
for name, value in headers.items():
if name.lower() not in SIMPLE_HEADERS:
return True
if (
name.lower() == "content-type"
and value.split(";")[0].strip().lower() not in SIMPLE_TYPES
):
return True
return False
Looks at
Method, headers, typewas The method
Wrong on
Nothing in the taskwas Headers, Content-Type
Why
It is the rule in the Fetch standard: what a form could send skips the preflight.
What changed from the previous rung
SIMPLE_METHODS = {"GET", "HEAD", "POST"}
SIMPLE_HEADERS = {"accept", "accept-language", "content-language", "content-type"}
SIMPLE_TYPES = {"application/x-www-form-urlencoded", "multipart/form-data", "text/plain"}
def needs_preflight(method: str, headers: dict[str, str]) -> bool:
"""Better: GET, HEAD and POST are simple; everything else needs a preflight."""
return method.upper() not in SIMPLE_METHODS
"""Best: a simple method, simple headers, and a form-like Content-Type skip the preflight."""
if method.upper() not in SIMPLE_METHODS:
return True
for name, value in headers.items():
if name.lower() not in SIMPLE_HEADERS:
return True
if (
name.lower() == "content-type"
and value.split(";")[0].strip().lower() not in SIMPLE_TYPES
):
return True
return False

When Best isn’t best: the full standard has a few more details, like a size limit on header values. In practice, read the browser’s console: it says what triggered the preflight.

Write csrf_ok(method, cookie_token, form_token). This is the “double submit” check: the page sends a token both in a cookie and in the form. Safe methods (GET, HEAD, OPTIONS, TRACE) always pass. Any other method passes only if both tokens are there, not empty, and equal. Compare them with hmac.compare_digest.

Example: csrf_ok("POST", "abc", None) is False: the form left the token out.

Hint 1 of 3

An attacker controls the form. Whatever is missing, they will leave out.

Hint 2 of 3

Safe methods change nothing, so they don’t need the check, and links keep working.

Hint 3 of 3

hmac.compare_digest takes the same time whatever the strings are, so timing gives nothing away.

Check the CSRF token

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: csrf_ok('POST', 'abc', 'abc') → True
  2. Not run yet: The form left the token out → False
  3. Not run yet: csrf_ok('POST', 'abc', 'abd') → False
  4. Not run yet: A GET needs no token → True
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Compare only if both are there

The idea: If both tokens are there, they must match. If one is missing, let it pass.

A form with a wrong token is caught.

def csrf_ok(method: str, cookie_token: str | None, form_token: str | None) -> bool:
"""Okay: if both tokens are there, they must match."""
if cookie_token and form_token:
return cookie_token == form_token
return True
Stops
Wrong tokens
Lets through
Missing tokens
Why
It checks what it gets, and assumes the rest.

Where it breaks: the attacker just leaves the token out, and passes.

Better

Both must be there and match

The idea: Require a non-empty cookie token equal to the form token, on every request.

The waste in Okay: “not there” was treated as “fine”.

Highlighted lines are new in this rung.

def csrf_ok(method: str, cookie_token: str | None, form_token: str | None) -> bool:
"""Better: both tokens must be there and match, on every request."""
return bool(cookie_token) and cookie_token == form_token
Stops
Wrong and missing tokenswas Wrong tokens
Lets through
Nothing; blocks GETs toowas Missing tokens
Why
A missing token now fails.
What changed from the previous rung
def csrf_ok(method: str, cookie_token: str | None, form_token: str | None) -> bool:
"""Okay: if both tokens are there, they must match."""
if cookie_token and form_token:
return cookie_token == form_token
return True
"""Better: both tokens must be there and match, on every request."""
return bool(cookie_token) and cookie_token == form_token

Where it breaks: a plain GET from a link has no form token, so every page view fails. And == stops at the first wrong character, which can leak timing.

Best

Skip safe methods, compare in constant time

The idea: Safe methods pass. Others need both tokens, non-empty, equal by hmac.compare_digest.

The waste in Better: it guarded reads, which change nothing.

Highlighted lines are new in this rung.

import hmac
SAFE = {"GET", "HEAD", "OPTIONS", "TRACE"}
def csrf_ok(method: str, cookie_token: str | None, form_token: str | None) -> bool:
"""Best: skip safe methods; else both must be there and match, in constant time."""
if method.upper() in SAFE:
return True
if not cookie_token or not form_token:
return False
return hmac.compare_digest(cookie_token, form_token)
Stops
Every forged changewas Wrong and missing tokens
Lets through
Only readswas Nothing; blocks GETs too
Why
Only requests that change things need the guard, and the compare gives nothing away.
What changed from the previous rung
import hmac
SAFE = {"GET", "HEAD", "OPTIONS", "TRACE"}
def csrf_ok(method: str, cookie_token: str | None, form_token: str | None) -> bool:
"""Better: both tokens must be there and match, on every request."""
return bool(cookie_token) and cookie_token == form_token
"""Best: skip safe methods; else both must be there and match, in constant time."""
if method.upper() in SAFE:
return True
if not cookie_token or not form_token:
return False
return hmac.compare_digest(cookie_token, form_token)

When Best isn’t best: use your framework’s check. Django’s CsrfViewMiddleware also masks the token and checks the Origin. And keep GET safe, or this check has a hole.

Write trusted(origin, allowed). It says whether an Origin header is exactly the allowed origin: the same scheme, host and port. Host names are case-insensitive, and https://x means port 443.

Example: trusted("https://app.example.com.evil.net", "https://app.example.com") is False.

Hint 1 of 3

Checking with in or startswith can be tricked by a host that only begins with ours.

Hint 2 of 3

urllib.parse.urlsplit gives .scheme, .hostname (in lower case) and .port.

Hint 3 of 3

When .port is None, use 443 for https and 80 for http. Then compare the three parts.

Is this origin ours?

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: trusted('https://app.example.com', 'https://app.example.com') → True
  2. Not run yet: A host that only starts like ours → False
  3. Not run yet: http, not https → False
  4. Not run yet: Port 443 written out → True
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Ours appears in it

The idea: allowed in origin.

It accepts our own origin.

def trusted(origin: str, allowed: str) -> bool:
"""Okay: the allowed site appears somewhere in the origin."""
return allowed in origin
Tricked by
Any URL that holds ours
Rejects good
:443, capitals
Why
Text anywhere in the origin counts.

Where it breaks: https://app.example.com.evil.net contains ours, and passes.

Better

Starts with ours

The idea: origin.startswith(allowed).

The waste in Okay: it let our text sit anywhere.

Highlighted lines are new in this rung.

def trusted(origin: str, allowed: str) -> bool:
"""Better: the origin starts with the allowed site."""
return origin.startswith(allowed)
Tricked by
Hosts that start like ourswas Any URL that holds ours
Rejects good
Capitalswas :443, capitals
Why
Our text must come first, which stops some tricks.
What changed from the previous rung
def trusted(origin: str, allowed: str) -> bool:
"""Okay: the allowed site appears somewhere in the origin."""
return allowed in origin
"""Better: the origin starts with the allowed site."""
return origin.startswith(allowed)

Where it breaks: https://app.example.com.evil.net still starts with ours. An attacker can buy that name.

Best

Parse both, compare exactly

The idea: Split each into scheme, host and port, fill in the default port, and compare the three.

The waste in Better: it compared text, when an origin has parts.

Highlighted lines are new in this rung.

from urllib.parse import urlsplit
DEFAULT_PORTS = {"http": 80, "https": 443}
def parts(url: str) -> tuple[str, str, int]:
u = urlsplit(url)
return u.scheme, (u.hostname or ""), u.port or DEFAULT_PORTS.get(u.scheme, 0)
def trusted(origin: str, allowed: str) -> bool:
"""Best: compare scheme, host and port exactly, after parsing both."""
return parts(origin) == parts(allowed)
Tricked by
Nothing in the taskwas Hosts that start like ours
Rejects good
Nothing in the taskwas Capitals
Why
An origin is exactly three parts, so compare exactly those.
What changed from the previous rung
from urllib.parse import urlsplit
DEFAULT_PORTS = {"http": 80, "https": 443}
def parts(url: str) -> tuple[str, str, int]:
u = urlsplit(url)
return u.scheme, (u.hostname or ""), u.port or DEFAULT_PORTS.get(u.scheme, 0)
def trusted(origin: str, allowed: str) -> bool:
"""Better: the origin starts with the allowed site."""
return origin.startswith(allowed)
"""Best: compare scheme, host and port exactly, after parsing both."""
return parts(origin) == parts(allowed)

When Best isn’t best: keep a set of allowed origins and look them up, rather than parse on every request. Django’s CSRF_TRUSTED_ORIGINS and the CORS libraries do this for you.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Multiple choicecurl gets your API’s answer, but the browser shows a CORS error. Who blocked it?
  2. Spot the bugWhat is wrong with this CORS code?
    origin = request.headers.get("Origin")
    response.headers["Access-Control-Allow-Origin"] = origin
    response.headers["Access-Control-Allow-Credentials"] = "true"
  3. Pick the patternA cookie-based site has a form that changes the user’s email. Which guard stops another site from posting it?
  4. Multiple choiceWhich request gets a preflight?
  5. Fill in the answerThe cookie attribute that keeps it off most cross-site requests: SameSite=____

Add these to your review deck. Each card comes back a little later every time you get it right.

  • An origin is scheme + host + port. Pages may send anywhere, but read only their own origin’s answers.
  • CORS: echo the Origin only if it is on an exact allow list; add Allow-Credentials: true and Vary: Origin.
  • Never * with cookies; never echo any origin.
  • Preflight (OPTIONS): list the methods and headers you accept, and let it through without a login.
  • CORS stops reading, not sending. Stop CSRF with SameSite cookies, CSRF tokens and an Origin check.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started