The browser blocked my front end from calling my own API
See the problem
Section titled “See the problem”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?
Define the task
Section titled “Define the task”Write cors_headers(method, origin, asked_headers): the CORS headers the API adds to a response.
- Input: the request’s method, its
Originheader (orNone), 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.comandhttp://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"}.
Pause and think
Section titled “Pause and think”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.
Understand the idea
Section titled “Understand the idea”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=Laxkeeps 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
Originheader isn’t one of yours.
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.
Climb the ladder
Section titled “Climb the ladder”Three versions of cors_headers. The tests check each one the way a browser would.
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.
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.
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 outWhen 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 it yourself
Section titled “Write it yourself”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: trueandVary: Origin. - For
OPTIONS, also check the asked-for headers againstHEADERS, case-insensitively. If one isn’t allowed, give back only{"Vary": "Origin"}. Otherwise addAllow-Methods,Allow-Headers(the asked ones, lower case, sorted) andMax-Age: 600.
CORS headers for our own front ends
Ctrl + Enter runs- 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'} - Not run yet:
Another site: no CORS headers→{'Vary': 'Origin'} - 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'} - Not run yet:
A preflight asking for a header we don’t take→{'Vary': 'Origin'} - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Who can read | With cookies | Good | Not so good | Use it when |
|---|---|---|---|---|---|
Okay: * | Every site | Fails | One line | Private data open to every site; no cookies | Public data with no login |
| Better: allow list | Our front ends | GET and forms only | Safe, simple | Preflights fail, so no PUT or JSON | APIs with only simple requests |
| Best: allow list + preflight | Our front ends | Every method | Safe, complete | More to get right; use a library | Any API a front end on another origin calls |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- CORS doesn’t protect your server.
curland other servers ignore it. It only tells browsers what pages may read. - Check origins exactly.
origin.endswith("example.com")also trustsevilexample.com. The third practice problem shows this. nullis not safe. Some sandboxed pages sendOrigin: null. Never put"null"on the allow list.- A failed preflight shows as a CORS error. If your
OPTIONSroute 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: Bearerheaders, not cookies, can’t be sent by a plain form, so CSRF doesn’t apply. Cookie-based APIs need the guard.
Say it in the interview
Section titled “Say it in the interview”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?
CsrfViewMiddlewarechecks a secret token in a cookie against the one in each form orX-CSRFTokenheader, and checks the Origin.
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.
1. Does it need a preflight? (easy)
Section titled “1. Does it need a preflight? (easy)”Write needs_preflight(method, headers). A request skips the preflight only if all of these are true:
- The method is
GET,HEADorPOST. - Every header is one of
Accept,Accept-Language,Content-LanguageorContent-Type. - A
Content-Type, if there is one, isapplication/x-www-form-urlencoded,multipart/form-dataortext/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- Not run yet:
A POST with JSON→True - Not run yet:
A plain form post→False - Not run yet:
A GET with an Authorization header→True - Not run yet:
needs_preflight('DELETE', {})→True - Not run yet: Extra test
- 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
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.
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_METHODSWhere it breaks: a POST with JSON does need a preflight, and Better says no. That is exactly the request most front ends send.
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 FalseWhen 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.
2. Check the CSRF token (medium)
Section titled “2. Check the CSRF token (medium)”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- Not run yet:
csrf_ok('POST', 'abc', 'abc')→True - Not run yet:
The form left the token out→False - Not run yet:
csrf_ok('POST', 'abc', 'abd')→False - Not run yet:
A GET needs no token→True - Not run yet: Extra test
- 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
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.
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_tokenWhere 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.
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.
3. Is this origin ours? (medium)
Section titled “3. Is this origin ours? (medium)”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- Not run yet:
trusted('https://app.example.com', 'https://app.example.com')→True - Not run yet:
A host that only starts like ours→False - Not run yet:
http, not https→False - Not run yet:
Port 443 written out→True - Not run yet: Extra test
- 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
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.
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.
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.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- 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: trueandVary: 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.
Go deeper
Section titled “Go deeper”- MDN: CORS and the same-origin policy.
- OWASP’s CSRF prevention cheat sheet.
- Django: CSRF protection. FastAPI: CORS.
- Next: the top OWASP risks, the attacks frameworks help you block.
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