Skip to content
On this page

Web Security

To build secure web apps, it's essential to understand how browsers handle cross-origin HTTP requests.

Same-Origin Policy (SOP)

All browsers enforce the same-origin policy, which blocks one origin from accessing resources on another origin. Origins can be:

In simple terms: if two origins differ, direct access is forbidden by default.

However, there are many exceptions.

Get Requests are always allowed

The same-origin policy does not restrict embed tags like this:

html
<img src="https://dashboard.example.com/post-message/hello">

Even if the image doesn't exist, the browser still sends the request directly to the server.
This is why state-changing endpoints must never use GET, because embedded tags like <img> can trigger such requests without restrictions from any website.

Also, GET requests don't trigger a CORS preflight — more on that below.

Exception: Some actions, like email confirmation or account invitation, are triggered by clicking a link in an email.
These usually use GET requests with a secure token in the URL: https://yourapp.com/confirm/asdhakjfg1y91hiqsdjad. Because the token is random and user-specific, this is considered safe — even though the request is a GET.

The CORS Protocol

CORS (Cross-Origin Resource Sharing) is a protocol that allows controlled communication between different origins.

As we’ve seen, the Same-Origin Policy (SOP) blocks communication between different origins by default.
CORS provides a way for the server to explicitly allow such requests.

Before certain types of cross-origin requests are made, the browser must first ask the server for permission — this is known as a preflight request.

Preflight Requests

For non-simple requests (e.g. PUT, DELETE, or requests with custom headers), the browser sends a CORS preflight request — an OPTIONS request with the Origin header.

The server replies with headers like Access-Control-Allow-Origin to approve or deny the request.

Only if the response explicitly allows the origin, the browser proceeds with the actual request.
See MDN: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests

Note: while many requests include the Origin header, not all are preflighted.
Only requests that are not "simple" (per the CORS spec) require a preflight.

Example

If a server does not respond with Access-Control-Allow-Origin, the browser blocks the request — before it reaches the server logic.

This protects against cross-origin abuse in modern web apps.

What CSRF Tokens Are and Why They Exist

CSRF (Cross-Site Request Forgery) is a type of attack where a malicious website (or email) tricks a logged-in user into submitting a request to another site — typically without their knowledge.

Because browsers automatically attach cookies for the target site (e.g. session cookies), the request may look valid to the server — even though it was triggered from an external page.

A CSRF token is a secret value stored in the user’s session and also embedded in the frontend (e.g. in a form or header).
When a state-changing request is made, the server checks that the token in the request matches the one in the session.
This ensures the request came from your frontend — not a malicious site.

CSRF protection is especially important for POST, PUT, PATCH, or DELETE requests.

Why CSRF Tokens Are Still Needed (Even with CORS and SOP)

You might think CORS or the Same-Origin Policy (SOP) would make CSRF tokens unnecessary — but that’s not the case. Some simple requests (like form POSTs with basic headers) do not trigger a preflight, and are allowed by default. Browsers allow simple POST requests from forms across origins by default (to preserve legacy behavior).

This is not an oversight, but a deliberate decision.
Form submissions existed before CORS and were already allowed to send data cross-origin.
The CORS spec assumes that servers were already protecting against CSRF, so it doesn’t require a preflight for such requests. See also here:

The motivation is that the element from HTML 4.0 (which predates cross-site XMLHttpRequest and fetch) can submit simple requests to any origin, so anyone writing a server must already be protecting against cross-site request forgery (CSRF). Under this assumption, the server doesn't have to opt-in (by responding to a preflight request) to receive any request that looks like a form submission, since the threat of CSRF is no worse than that of form submission. However, the server still must opt-in using Access-Control-Allow-Origin to share the response with the script.

Unfortunately, a plain <form action="POST"> creates a simple request!

Because of this, a malicious site can trigger a simple POST to your server, and the browser will include valid cookies — unless the server checks for a CSRF token.

In short: CORS doesn’t block form submissions, and preflight is skipped — which is why CSRF tokens are still necessary.

Example: Attack via Email

Imagine a user is logged in to yourapp.com in their browser.
They receive a malicious email that contains an HTML form:

html
<form action="https://yourapp.com/delete-account" method="POST">
  <input type="hidden" name="confirm" value="yes">
  <button type="submit">Click here to win a prize!</button>
</form>

If the user clicks the button while reading the email in a webmail client (e.g. Gmail), their browser will:

  1. Open a new tab to yourapp.com.
  2. Send the POST request with their valid session cookies.
  3. If your server doesn't check a CSRF token, it may accept and process the request — as if the user had clicked a button on your own site.

Why This Works

  • The request is sent by the browser (so cookies are included).
  • It’s a simple request, so no CORS preflight happens.
  • SOP doesn’t stop it, because it's a regular POST request — and those are allowed by default

Why CSRF Tokens Help

CSRF tokens fix this by requiring a secret value (only known to your frontend) in the request — either in a hidden form field or a header.
A malicious site or email cannot access this token, so the forged request will be rejected by your server.

Is checking origin of request enough?

Since the POST request is sending the Origin along in the header and browser disallow modifying it, you might think: "Can’t I just check the Origin header?
In theory, yes — but in practice:

  • The Origin header can be spoofed by scripts (e.g. in CLI tools).
  • Old or buggy browsers may send incorrect origin headers.
  • Browser plugins may modify or forge headers.

From wikipedia:

Various other techniques have been used or proposed for CSRF prevention historically. Verifying that the request's headers contain [...] HTTP Origin header. However, this is insecure – a combination of browser plugins and redirects can allow an attacker to provide custom HTTP headers on a request to any website, hence allowing a forged request

Use CSRF tokens, not just Origin checks — unless you have a very specific reason.

Problem with CSRF tokens

For things like contact forms or long multi-step forms, CSRF tokens stored in the session can expire — especially if the user waits a long time before submitting. If a session expires, the user loses everything — a terrible UX.

Instead, you can generate a form-specific token when the form is first shown, and:

  • Store it in the database or a Redis cache (e.g. keyed by token string or user ID)
  • Include the token in the form (e.g. as a hidden input)
  • Validate the token on submission and delete it after use

Alternatives:

  • Use reCAPTCHA v3 for contact forms (no user interaction) and/or throtteling.
  • Extend session lifetime (but not scalable approach).
  • Use cookie-stored sessions that persist on the client side (and don’t expire easily).