# Sec-Fetch-Site Header: Block Cross-Site Requests

> Sec-Fetch-Site says whether a request is same-origin, same-site, cross-site or user-initiated. Resource isolation policy for Express and nginx, plus CSRF use.

Source: https://howhttpworks.com/headers/sec-fetch-site
Last reviewed: 2026-10-04

> **TL;DR:** `Sec-Fetch-Site` is a request header the browser adds to every HTTPS request, saying whether it came from your own origin, your own site, another site (`cross-site`), or a direct user action (`none`). Servers use it to drop cross-site requests that are not plain page navigations, which blocks most CSRF and cross-site data leaks with a few lines of middleware.

## What the browser sends

```http
POST /transfer HTTP/1.1
Host: bank.example
Origin: https://evil.example
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: navigate
Sec-Fetch-Dest: document
Cookie: session=abc123
Content-Type: application/x-www-form-urlencoded
```

That is what a victim's browser sends when a hidden form on `evil.example` auto-submits to your site. The cookie is attached (depending on `SameSite`), and the `Origin` header is there too, but `Sec-Fetch-Site` gives you a one-token answer instead of making you parse and compare origins.

It is a structured-field token, defined in section 2.3 of the W3C Fetch Metadata spec. It is a forbidden request header, so scripts cannot set it.

## Values

| Value | Meaning |
| --- | --- |
| `same-origin` | Same scheme, host, and port as the target URL. |
| `same-site` | Same scheme and registrable domain, but a different host or port, for example `app.example.com` calling `api.example.com`. |
| `cross-site` | Anything else, including http to https on the same domain. |
| `none` | Not initiated by a page: the user typed the URL, used a bookmark, dragged a link, or the browser opened it on its own. |

Two details that trip people up:

- **Redirects count.** The browser evaluates the whole redirect chain. If a request started on `evil.example`, bounced through `example.com`, and landed on `example.com`, the final request is `cross-site`. If the chain only ever touched the same site it stays `same-site`, and `same-origin` requires every hop to match.
- **Scheme matters.** Fetch Metadata uses schemeful same-site, so `http://example.com` to `https://example.com` is `cross-site`.

## Which browsers send it

From MDN's compatibility data: Chrome and Edge 76, Firefox 90, Safari 16.4 (March 2023). MDN marks it widely available since March 2023. Anything older, and every non-browser client, sends nothing. That is why every sane policy starts with "if the header is absent, allow".

It is also only sent to potentially trustworthy URLs: HTTPS, and `localhost` for development. Behind a TLS-terminating load balancer this is fine, because the browser decides based on the URL it requested, not what reaches your app.

## Resource isolation policy

The pattern recommended by web.dev: reject cross-site requests unless they are plain navigations. Public endpoints that are supposed to be embedded or called cross-origin get an explicit exemption.

```text
allow if Sec-Fetch-Site is absent                    # old browsers, curl, webhooks
allow if Sec-Fetch-Site is same-origin, same-site or none
allow if mode is navigate, method is GET, and dest is not object or embed
allow if the path is on your public cross-origin list
otherwise reject with 403
```

### Express

```javascript
const SAME = new Set(['same-origin', 'same-site', 'none'])
const PUBLIC_PATHS = new Set(['/favicon.png', '/api/public/status'])

function resourceIsolation(req, res, next) {
  const site = req.get('Sec-Fetch-Site')
  if (!site || SAME.has(site)) return next()

  const isSimpleNavigation =
    req.get('Sec-Fetch-Mode') === 'navigate' &&
    req.method === 'GET' &&
    !['object', 'embed'].includes(req.get('Sec-Fetch-Dest'))
  if (isSimpleNavigation || PUBLIC_PATHS.has(req.path)) return next()

  res.set('Cache-Control', 'no-store')
  res.status(403).type('text/plain').send('Cross-site request blocked')
}

app.use(resourceIsolation) // before sessions, auth, body parsing
```

Put it first. web.dev's advice is to reject before authentication or any other processing so the response time does not leak information.

### nginx

nginx has no `if` that can combine four variables cleanly, so build one key with `map`. Regex entries in a `map` are tried in the order written, and the first match wins.

```nginx
# $http_sec_fetch_site is empty when the header is absent
map "$http_sec_fetch_site|$http_sec_fetch_mode|$http_sec_fetch_dest|$request_method" $fetch_blocked {
    default                                                      1;
    "~^\|"                                                       0;  # header absent
    "~^(same-origin|same-site|none)\|"                           0;
    "~^cross-site\|navigate\|(document|iframe|frame)\|GET$"      0;  # simple navigation
}

server {
    listen 443 ssl;

    location / {
        if ($fetch_blocked) { return 403; }
        proxy_pass http://app;
    }

    location = /api/public/status {
        proxy_pass http://app;   # intentionally cross-origin
    }
}
```

`if` inside `location` is fragile in general, but `if (...) { return ...; }` is the one use that is safe. Include `Sec-Fetch-Site` in `Vary` if a CDN in front of this caches the responses, otherwise a cached 200 can be served to a request that should have been rejected.

## CSRF defence-in-depth

Rejecting `cross-site` on `POST`, `PUT`, `PATCH`, and `DELETE` is the part of the policy that matters for CSRF. OWASP's CSRF cheat sheet describes it as a lightweight way to block obvious cross-site requests, with the caveat that browsers without the header need a fallback such as an `Origin` check or a token. Treat it as a layer in front of your CSRF tokens and `SameSite` cookies, not a replacement.

Things the policy breaks if you do not plan for them:

- **Cross-site form POST callbacks.** SAML POST binding and OpenID `response_mode=form_post` end with the identity provider posting a form to your callback. That request is `cross-site`, `navigate`, `POST`. Exempt that path.
- **Webhooks from browsers' point of view.** Server-to-server webhooks carry no header and pass. A browser-based payment return that POSTs back to you does not.
- **Public APIs called with CORS.** A `fetch()` from another origin arrives as `cross-site` with mode `cors`. If the API is meant for that, exempt it, and keep [CORS](https://howhttpworks.com/guides/cors) headers on it.
- **Hotlinked assets.** Images, fonts, and scripts you want third parties to embed arrive as `cross-site` and `no-cors`.

## Debugging

Check what your own browser sends. In DevTools, open the Network panel, pick a request, and look under Request Headers. From the command line, send what a browser would:

```bash
curl -i https://example.com/api/transfer -X POST \
  -H 'Sec-Fetch-Site: cross-site' -H 'Sec-Fetch-Mode: cors' -H 'Sec-Fetch-Dest: empty'
```

`curl` lets you set `Sec-*` headers because the restriction exists only in browser scripts, which is exactly why the header proves nothing about non-browser clients.

For the resource side of the same idea, see [Cross-Origin-Resource-Policy](https://howhttpworks.com/headers/cross-origin-resource-policy), which makes the response declare who may load it. `Sec-Fetch-Site` is the request-side version, and it is more flexible because the server can decide per request.

## Related

- [Sec-Fetch-Mode](https://howhttpworks.com/headers/sec-fetch-mode), [Sec-Fetch-Dest](https://howhttpworks.com/headers/sec-fetch-dest), [Sec-Fetch-User](https://howhttpworks.com/headers/sec-fetch-user)
- [Origin](https://howhttpworks.com/headers/origin) and [CORS guide](https://howhttpworks.com/guides/cors)
- [403 Forbidden](https://howhttpworks.com/status-codes/403)
