Lesson 17 / 27
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.
Contents
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.
// 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.
// 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
SameSiteattribute is the first layer:Strictexcludes every cross-origin request,Laxexcludes every request outside top-level, safe navigation, andNonerequiresSecure. - 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
OriginandSec-Fetch-Siteheaders 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.