---
title: 'Request Forgery'
source: 'https://academia.sh/en/courses/frontend-quality/request-forgery'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# Request Forgery

The attack class born from cookies being attached automatically; the decision table of the SameSite and Secure attributes, a session-bound request token, origin header checks, and method discipline.

The previous lesson ended on a question: if the same-origin policy blocks reading the
response, what happens to the write request? The answer is that it is not blocked. A
cross-origin request reaches the server, the server processes it, the side effect happens;
only the response body is not delivered to the page that started the request.

This gap is not a problem on its own. The problem arises when the user's identity is
attached to the request automatically. If the North Slope Measurement Station interface
carries its session in a cookie, the browser attaches that cookie without looking at where
the request was started from. This lesson builds the consequence of that behavior and two
independent layers of defense.

## Ambient Authority

As defined in the How the Internet Works course, a cookie travels with a request
automatically. This is the behavior that keeps a session alive: the user does not restate
their identity on every page. The same behavior produces a side effect. Who started the
request does not affect whether the cookie gets attached; the browser looks at the target
address, not the origin.

This is called **ambient authority**: the authority is attached not to the code making the
request but to where the request is going. **Cross-site request forgery** is this
authority being triggered from another site's page. While the user is signed into the
measurement station, a page opens in another tab; that page starts a write request to an
endpoint address on the measurement station. Because the request carries the user's
cookie, it looks legitimate on the server side.

The attack does not require script. A form element can be submitted; an image element can
call an address. The response not being readable is not the attacker's concern; the goal
is not to read, it is to make something happen.

## First Layer: Cookie Attributes

The first defense sits in the cookie itself. The `SameSite` attribute determines whether
the cookie is attached to cross-origin requests, and it takes three values. The code below
computes the decision table for these values across request types.

```js
// samesite.mjs — cookie attributes produce a decision table by request type
const SAFE_METHODS = new Set(['GET', 'HEAD']);

function isSent(cookie, request) {
  if (cookie.secure && request.scheme !== 'https') return ['no', 'a Secure cookie is not sent over an unencrypted scheme'];
  if (request.sameSite) return ['yes', 'same-site request'];
  switch (cookie.sameSite) {
    case 'Strict':
      return ['no', 'Strict excludes every cross-origin request'];
    case 'Lax':
      return request.topLevelNavigation && SAFE_METHODS.has(request.method)
        ? ['yes', 'Lax passes a safe method on top-level navigation']
        : ['no', 'Lax only passes top-level, safe navigation'];
    case 'None':
      return cookie.secure ? ['yes', 'None sends cross-origin'] : ['no', 'None requires Secure'];
    default:
      return ['no', 'unrecognized value'];
  }
}

const requests = [
  { name: 'same-site call', sameSite: true, topLevelNavigation: false, method: 'GET', scheme: 'https' },
  { name: 'outside link (GET)', sameSite: false, topLevelNavigation: true, method: 'GET', scheme: 'https' },
  { name: 'outside form (POST)', sameSite: false, topLevelNavigation: true, method: 'POST', scheme: 'https' },
  { name: 'outside frame', sameSite: false, topLevelNavigation: false, method: 'GET', scheme: 'https' },
  { name: 'outside script call', sameSite: false, topLevelNavigation: false, method: 'POST', scheme: 'https' },
];

const cookies = [
  { name: 'Strict+Secure', sameSite: 'Strict', secure: true },
  { name: 'Lax+Secure', sameSite: 'Lax', secure: true },
  { name: 'None+Secure', sameSite: 'None', secure: true },
  { name: 'None, no Secure', sameSite: 'None', secure: false },
];

const header = ['request type'.padEnd(24), ...cookies.map((c) => c.name.padEnd(18))].join('').trimEnd();
console.log(header);
for (const request of requests) {
  const cells = cookies.map((cookie) => isSent(cookie, request)[0].padEnd(18));
  console.log(request.name.padEnd(24) + cells.join('').trimEnd());
}

console.log('\nreasoning example (Lax+Secure):');
for (const request of requests) {
  const [decision, reason] = isSent(cookies[1], request);
  console.log(' ', request.name.padEnd(24), decision.padEnd(6), reason);
}
```

```
$ node samesite.mjs
request type            Strict+Secure     Lax+Secure        None+Secure       None, no Secure
same-site call          yes               yes               yes               yes
outside link (GET)      no                yes               yes               no
outside form (POST)     no                no                yes               no
outside frame           no                no                yes               no
outside script call     no                no                yes               no

reasoning example (Lax+Secure):
  same-site call           yes    same-site request
  outside link (GET)       yes    Lax passes a safe method on top-level navigation
  outside form (POST)      no     Lax only passes top-level, safe navigation
  outside frame            no     Lax only passes top-level, safe navigation
  outside script call      no     Lax only passes top-level, safe navigation
```

The table makes three rules visible. `Strict` excludes every cross-origin request; its
protection is the broadest, but a user who arrives through a link from another site meets
the page without a session. `Lax` passes the cookie only on top-level navigation and only
with a safe method; it filters out an outside form's `POST` request and a frame load.
`None` sends the cookie in every case and therefore requires the `Secure` attribute; if the
attribute is missing, the cookie is never sent at all.

Alongside this table, two more attributes are an inseparable part of a session cookie.
`HttpOnly` keeps the cookie from being read by script; once the script-execution defense
built in the first lesson is bypassed, it makes stealing the session directly harder.
`Secure` keeps the cookie from being sent over an unencrypted connection.

## Where SameSite Falls Short

The attribute is a strong mitigation, but it does not stand on its own as a sufficient
defense.

First, "same site" and "same origin" are different measures. The same-site measure is
computed over the registrable domain; `report.example.test` and
`measurements.example.test` are different origins but the same site. Losing control of one
subdomain means requests sent through that domain count as same-site. To narrow this risk,
the cookie name is given a `__Host-` prefix: this prefix requires the cookie to belong only
to the exact host it was set for and keeps its domain scope from being widened.

Second, the behavior applied when the attribute is not declared depends on the
implementation. Some implementations treat an undeclared cookie as if it carried a
restrictive value; others do not. This is why the attribute is not left to the default; it
is written explicitly.

Third, the `Lax` value passes a top-level `GET` navigation. If a state-changing operation
is done with `GET`, this defense does not cover that operation. Method discipline is
therefore part of the defense.

## Second Layer: Session-Bound Token

The second defense proves that the request was really started from the application's own
page. The server generates an unpredictable **request token** for each session, embeds it
in the page, and expects it back on submission. Binding the token to the session is
critical: without that binding, an attacker could place a token valid for their own
session into another user's request.

```js
// token.mjs — generation and validation of a session-bound request token
import { createHmac, timingSafeEqual } from 'node:crypto';

const SERVER_SECRET = Buffer.from('7b2a0f5c9d1e4a8b6c3f0d2e5a7b9c1d', 'utf8');

function generateToken(sessionId, salt) {
  const signature = createHmac('sha256', SERVER_SECRET).update(`${sessionId}.${salt}`).digest('base64url');
  return `${salt}.${signature}`;
}

function validateToken(sessionId, token) {
  if (typeof token !== 'string' || !token.includes('.')) return false;
  const [salt] = token.split('.');
  const expected = Buffer.from(generateToken(sessionId, salt));
  const given = Buffer.from(token);
  return expected.length === given.length && timingSafeEqual(expected, given);
}

function originCheck(headers, expected) {
  if (headers['sec-fetch-site'] === 'same-origin') return true;
  if (headers.origin) return headers.origin === expected;
  return false;
}

const SESSION = 'session-4417';
const valid = generateToken(SESSION, 'a1b2c3');
console.log('generated token:', valid);

const attempts = [
  ['correct session, correct token', SESSION, valid],
  ["another session's token", 'session-9002', valid],
  ['signature altered', SESSION, `${valid.slice(0, -1)}X`],
  ['no token', SESSION, undefined],
];

console.log('\ntoken validation:');
for (const [name, session, value] of attempts) {
  console.log(' ', name.padEnd(32), validateToken(session, value) ? 'valid' : 'rejected');
}

const EXPECTED_ORIGIN = 'https://measurements.example.test';
const headerSets = [
  ['from own page', { origin: EXPECTED_ORIGIN, 'sec-fetch-site': 'same-origin' }],
  ['no header, signal present', { 'sec-fetch-site': 'same-origin' }],
  ['sent from outside', { origin: 'https://other.example.test', 'sec-fetch-site': 'cross-site' }],
  ['neither present', {}],
];

console.log('\norigin check:');
for (const [name, headers] of headerSets) {
  console.log(' ', name.padEnd(30), originCheck(headers, EXPECTED_ORIGIN) ? 'accepted' : 'rejected');
}
```

```
$ node token.mjs
generated token: a1b2c3.aGkJnbGB2GshtSxoyRjHtnj6VB-7HH0c9Fm7K5ARq_I

token validation:
  correct session, correct token   valid
  another session's token          rejected
  signature altered                rejected
  no token                         rejected

origin check:
  from own page                  accepted
  no header, signal present      accepted
  sent from outside              rejected
  neither present                rejected
```

The token carries a signature generated with the server secret, and the signature covers
the session ID; when another session's token is presented, the signature does not match.
The comparison is done with `timingSafeEqual`: a character-by-character short-circuiting
comparison turns the length of a correct prefix into a measurable timing difference.

How the token is carried is also part of the rule. If it is carried only in a cookie, it
has no value against the attack, because the cookie is also attached automatically. The
token has to live somewhere other than a cookie: a hidden form field, or a request header
the application adds explicitly. The Authentication in the Browser lesson in the
Application Architecture course built the other side of this distinction; in a design that
carries the session in memory with a token rather than a cookie, and attaches it to the
request explicitly, ambient authority never arises in the first place, so this attack class
disappears on its own. The cost is that the session has to be re-established on every page
reload.

## Third Layer: Origin Check

The last section of the output shows the third layer. The server checks the origin of
every state-changing request. If the `Origin` header is present, it is compared against
the expected origin. For cases where this header is missing, the fetch metadata the
browser adds is used: the `Sec-Fetch-Site` header states whether the request was started
from the same origin, the same site, or from outside.

The last line of the check matters: a request where neither header is present is not
accepted. Treating the absence as "no problem" makes the check something that can be
bypassed by deleting a header. These are headers the browser writes, not application code,
and the browser cannot be made to not write them; there is no reason to interpret their
absence as safe.

## Method Discipline

Underneath all these layers sits one assumption: state-changing operations are not done
with safe methods. The **safe method** concept defined in the How the Internet Works
course turns into a security requirement here. If an endpoint that deletes a measurement
record can be called with `GET`, even an image element triggers that endpoint; the `Lax`
cookie behavior and most origin checks do not cover this case.

As a rule, write operations are done with the `POST`, `PUT`, `PATCH`, or `DELETE` methods,
and the server explicitly enforces method checking on write endpoints.

## Summary

- Request forgery arises from a cookie being attached without regard to who started the
  request; the response not being readable does not stop the side effect from happening.
- The `SameSite` attribute is the first layer: `Strict` excludes every cross-origin
  request, `Lax` excludes every request outside top-level, safe navigation, and `None`
  requires `Secure`.
- The same-site measure is broader than the same-origin measure; the subdomain risk is
  narrowed with the `__Host-` prefix and a second layer.
- The request token is bound to the session, carried somewhere other than a cookie, and
  validated with a constant-time comparison.
- The `Origin` and `Sec-Fetch-Site` headers are the third layer; a request where neither is
  present is rejected, and state-changing operations are not exposed to safe methods.

## Next Step

This lesson's defenses were built one at a time around specific headers and attributes. On
the surface of that same response, there is one more group that is not specific to any
single attack class but reduces the impact of all of them: headers that keep the
connection from staying unencrypted, that limit whether the document can be framed by
another page, and that narrow the address information that leaks out. The next lesson
takes up this group, its gaps, and a configuration check that finds them.
