Skills101

Web Security (OWASP Top 10) · Lesson 5 of 9 · Updated ·

CSRF and CORS — Forged Requests, SameSite Cookies, CSRF Tokens and What CORS Really Protects

Browsers automatically attach your cookies to requests sent to your site — even when another site starts the request. That convenience is the root of Cross-Site Request Forgery (CSRF). And CORS, which many developers think is a security shield, is actually a controlled way to relax a browser rule. Let's untangle both.

1. CSRF: making the victim's browser act for you

CSRF: a student logged in to the learning site visits a malicious page; a hidden form on that page posts to the learning site, the browser attaches the session cookie, and the email change goes through

  1. Priya is logged in to learn.example (her session cookie is in the browser).
  2. She opens a link to evil.example.
  3. That page contains a hidden form that auto-submits:
<form action="https://learn.example/account/email" method="POST">
  <input type="hidden" name="email" value="attacker@evil.example">
</form>
<script>document.forms[0].submit()</script>
  1. The browser sends the POST with Priya's cookie. The server sees a valid session and changes her email. The attacker then uses "forgot password" and owns the account.

The attacker never saw the cookie — they just borrowed the browser. CSRF targets state-changing actions: change email/password, transfer, delete, enrol, post.

2. Defence 1: SameSite cookies

SameSite=Lax (Chrome and Edge treat cookies with no SameSite attribute as Lax, but not every browser does — so always set it explicitly) means the cookie is not sent on cross-site POSTs, iframes or background requests — only on top-level links (GET). This alone stops the attack above.

Rule that makes Lax work: GET requests must never change data. If /delete?id=5 deletes something, a simple link or <img src> can trigger it.

3. Defence 2: CSRF tokens

For defence in depth (older browsers, subdomain attacks, Strict requirements), add a CSRF token: a secret value the server puts in each form and checks on submit. The attacker's page can't read your pages (same-origin policy), so it can't know the token.

A common design binds the token to the session using an HMAC (a keyed hash):

import hmac, hashlib

SERVER_SECRET = b"keep-this-in-an-env-variable"

def make_token(session_id):
    return hmac.new(SERVER_SECRET, session_id.encode(), hashlib.sha256).hexdigest()[:32]

def check_token(session_id, token):
    expected = make_token(session_id)
    return hmac.compare_digest(expected, token)      # constant-time comparison

priya_session = "sess-7f3a9c"
form_token = make_token(priya_session)               # put into <input type="hidden" name="csrf">

print("valid form:      ", check_token(priya_session, form_token))
print("attacker guess:  ", check_token(priya_session, "0" * 32))
print("token from other session:", check_token(priya_session, make_token("sess-other")))
valid form:       True
attacker guess:   False
token from other session: False

hmac.compare_digest compares in constant time so attackers can't guess the token byte by byte from response timings. Frameworks do all of this for you — turn it on rather than writing your own: Django {% csrf_token %}, Spring Security (on by default), Laravel @csrf, Rails protect_from_forgery. Next.js Server Actions check the request's origin automatically.

4. Defence 3: check Origin / Referer

Browsers send an Origin header on cross-site POSTs. The server can reject state-changing requests whose Origin isn't its own domain. Cheap and effective as an extra layer.

DefenceStops CSRF?Notes
SameSite=Lax/Strict cookiesyes, most casesset explicitly; keep GET read-only
CSRF tokensyesframework built-ins
Origin header checkyesextra layer for APIs and forms
Re-authentication for critical actionsyese.g. ask for password before changing email
"We use POST"noforms can POST cross-site
HTTPSnoCSRF works over HTTPS too

5. The same-origin policy and CORS

Origin = scheme + host + port. https://skills101.in and https://api.skills101.in are different origins.

The browser's same-origin policy (SOP): JavaScript on one origin can send requests to another, but can't read the responses — unless the other server allows it with CORS (Cross-Origin Resource Sharing) headers.

CORS: JavaScript on app.example asks api.example for data; the browser sends the request with an Origin header and only lets the page read the response if api.example replies with Access-Control-Allow-Origin naming app.example

GET /courses HTTP/1.1
Origin: https://app.example

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Credentials: true
Vary: Origin

For "non-simple" requests (e.g. PUT, JSON bodies, custom headers) the browser first sends a preflight OPTIONS request asking permission.

Key insight: CORS is enforced by browsers, to protect users. It does not stop curl, Postman or a script — and it does not stop CSRF (the forged form above doesn't need to read the response).

6. The dangerous CORS mistake

Reflecting any origin while allowing credentials:

def cors_headers_bad(request_origin):
    return {"Access-Control-Allow-Origin": request_origin,      # echoes whatever site asked
            "Access-Control-Allow-Credentials": "true"}

ALLOWED_ORIGINS = {"https://skills101.in", "https://app.skills101.in"}

def cors_headers_good(request_origin):
    if request_origin in ALLOWED_ORIGINS:
        return {"Access-Control-Allow-Origin": request_origin,
                "Access-Control-Allow-Credentials": "true", "Vary": "Origin"}
    return {}                                                   # browser will block the read

print(cors_headers_bad("https://evil.example"))
print(cors_headers_good("https://evil.example"))
print(cors_headers_good("https://app.skills101.in"))
{'Access-Control-Allow-Origin': 'https://evil.example', 'Access-Control-Allow-Credentials': 'true'}
{}
{'Access-Control-Allow-Origin': 'https://app.skills101.in', 'Access-Control-Allow-Credentials': 'true', 'Vary': 'Origin'}

With the bad version, evil.example can call your API with the victim's cookies and read the answers — their profile, orders, everything. Always use an allow-list. (Browsers refuse Access-Control-Allow-Origin: * together with credentials, which is why people "fix" the error by reflecting the origin — the worst possible fix.)

Common mistakes

  • Changing data on GET requests.
  • Disabling the framework's CSRF protection "because the form stopped working".
  • Thinking CORS protects the API from attackers (it protects browser users; servers must still authenticate and authorise).
  • Reflecting the Origin header with credentials allowed.

Quick recap

  • CSRF = another site makes the user's browser perform an action with their cookies.
  • Defences: SameSite cookies, CSRF tokens, Origin checks, re-auth for critical actions; GET must be read-only.
  • SOP blocks reading cross-origin responses; CORS selectively allows it.
  • CORS: allow-list origins; never reflect any origin with credentials.

Practice what you learned

📝 Quiz: CSRF and CORS5 questions · instant answers & explanations · +10 XP · counts towards your free certificateTake the quiz →