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

Source: https://skills101.in/learn/web-security/csrf-and-cors — Skills101 free tutorial (Web Security (OWASP Top 10)). Updated 2026-11-09.

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](https://skills101.in/learn-img/web-security/csrf-flow.svg)

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:

```html
<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>
```

4. 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):

```python
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")))
```

```text
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.

| Defence | Stops CSRF? | Notes |
|---|---|---|
| SameSite=Lax/Strict cookies | yes, most cases | set explicitly; keep GET read-only |
| CSRF tokens | yes | framework built-ins |
| Origin header check | yes | extra layer for APIs and forms |
| Re-authentication for critical actions | yes | e.g. ask for password before changing email |
| "We use POST" | **no** | forms can POST cross-site |
| HTTPS | **no** | CSRF 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](https://skills101.in/learn-img/web-security/cors.svg)

```http
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:

```python
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"))
```

```text
{'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 quiz and free certificate: https://skills101.in/learn/web-security/csrf-and-cors