Request Protection & CSRF
Learning Objectives
- You can protect cookie-authenticated mutations against cross-site request forgery.
- You can order identity, request-context and permission checks before a protected write.
- You can distinguish request protection, authorization and operational abuse controls.
Our notes workflow now has an authenticated account, resource permissions and rules for handling data. The browser sends its session cookie with matching requests. Could another page use that behavior to trigger an unwanted change?
A request can carry a logged-in browser’s cookie and still be unwanted. An attacker-controlled page may trigger a state-changing request without knowing the session token. Cross-site request forgery (CSRF) exploits these automatically attached credentials.
CORS (cross-origin resource sharing) controls which origins can read responses and whether some cross-origin requests may proceed; it is not authorization. An ordinary HTML form submission can reach a server even when the page cannot read the response. The OWASP CSRF prevention guidance describes origin and token defenses.
We will combine an exact-origin check with a session-bound token check.
The server requires the browser’s Origin header to match the configured UI origin.
It also stores a second, independently generated,
unguessable value with each session: a CSRF token. It is separate from the
opaque session token in the cookie. Application JavaScript reads the CSRF token
from a permitted session response and sends it explicitly in the X-CSRF-Token
header; the browser does not attach this header automatically.
Follow a legitimate request and an unwanted one
Let’s return to the logout flow from Chapter 5.3. When a signed-in user selects Log out, the application sends POST /api/auth/logout with the session-bound X-CSRF-Token. The browser attaches its cookie and Origin. The server checks the session, origin and token, then invalidates the session and returns 204. HttpOnly prevents JavaScript from reading the cookie, but the browser can still send it.
An untrusted page can try the same POST, and the browser may attach cookies where its policy allows. For example, https://preview.example.test and https://app.example.test can be same-site but different origins: SameSite alone is not an exact-origin policy. The untrusted page cannot read the session’s CSRF token through the permitted-origin endpoint. Its form has neither the allowed Origin nor the matching header. The exact-origin check already rejects this request; it would also fail the separate token check. This example uses both defenses together.
| Request | Cookie | Origin/token context | Outcome |
|---|---|---|---|
| User selects Log out in the trusted application. | Current session | Configured UI origin and matching token | Invalidate the session; return 204. |
| Untrusted page submits an unwanted form where cookies can accompany it. | May be current | Other origin and no matching token | Reject before invalidating the session. |
| Raw caller sends a protected preferences PATCH with a forged UI origin but no current session. | Missing/invalid | Origin alone is insufficient | Reject as unauthenticated. |
Connect the cookie, session and token
To follow these requests, keep track of how the two tokens travel:
| Value | How the application receives it | How it reaches the API |
|---|---|---|
| Opaque session token | The browser stores it from Set-Cookie; HttpOnly hides it from page JavaScript. | The browser adds the Cookie header. |
| CSRF token | Application JavaScript reads it from the session JSON response. | Application JavaScript adds X-CSRF-Token. |
A resolver reads the cookie and looks up the session on the server. We will practice the request checks in a preferences handler, where changing a theme gives us a small protected write to follow. The handler uses this interface:
type CurrentSession = { userId: number; csrfToken: string; expiresAtMs: number;};
type ResolveSession = (request: Request) => Promise<CurrentSession | null>;The resolver returns null for an unrecognized cookie. The handler checks session expiry and takes the account ID and expected CSRF token from the returned server state, never from the mutation body.
Our earlier account view selected the user fields. For request protection, we
read one more field from the session response:
csrfToken. The full response from GET /api/session is
{"user":{"id":7,"displayName":"Example user"},"csrfToken":"token-placeholder"}
for a current session, or {"user":null,"csrfToken":null} when anonymous.
Here, both tokens are generated when login creates the session. GET retrieves
that session’s existing CSRF token; it does not generate a new token on each read.
Keep the readable token with the confirmed client account state, update it from
the session response, and clear it when that state becomes unknown or anonymous.
The opaque session token stays in the HttpOnly cookie.
We can now follow a protected mutation from the browser to the server:
- After login, the application requests GET
/api/session, including credentials when the API is on another permitted origin. - The server returns the session’s CSRF token as JSON with
Cache-Control: no-store, which tells browser and intermediary caches not to store the response. Browser origin rules and restrictive CORS configuration prevent untrusted origins from reading it. - The application sends that token in
X-CSRF-Tokenon the mutation; the browser supplies the cookie and Origin. - The server resolves the session again, compares the request’s Origin with the configured UI origin and compares
X-CSRF-Tokenwith that session’s token. A missing,"null"or different Origin fails this policy.
A token does not prove that the user intended an action. An injected script running in the trusted origin could read the token and send authenticated requests, so the XSS protections from Chapter 5.5 still matter.
Keep GET read-only. Logout needs mutation protection. Registration and login need their own origin policy because a new visitor has no authenticated session from which to obtain a CSRF token.
For a resource write such as editing a note, we resolve identity, check request context, parse input, then check permission and current state before writing. We stop at the first failed check.
Figure 1 traces this combined policy and shows why a current cookie and matching request context are still insufficient: the operation must also satisfy the resource’s rules.
Why is GET kept read-only?
0 / 5 points
The preferences handler allows GET without a CSRF token. What must remain true?
Apply the policy to a small handler
The preferences exercise lets us concentrate on the request checks before a theme change. It supplies the handler, schema and dependencies. Complete the session/expiry and exact-origin/token guards at their existing call sites. Cookie resolution, input parsing, GET and response formatting are already implemented.
Trace a permitted PATCH from session resolution through theme parsing to the write. Then keep the session, origin and body valid, but use a wrong token. Where should the request stop, and what should happen to the stored theme?
Protect a preferences mutation
0 / 20 points
Files in the editor
Edit only isCurrentSession and hasMutationContext in src/routes/preferences-handler.ts. Keep the complete handler below them and src/schemas/preferences-schema.ts unchanged. The handler, repository/session interfaces, schema, JSON parsing, response messages and method dispatch are supplied.
Task
Complete the two guards:
isCurrentSession(session, nowMs)accepts a non-null session only whilenowMs < session.expiresAtMs. A session at its expiry time is already expired. Keep the supplied type-predicate signature so the handler can use the accepted session.hasMutationContext(request, session, allowedOrigin)accepts only the exact configured Origin and a nonempty session CSRF token matchingX-CSRF-Token. Missing,null, different origins and missing, empty or incorrect tokens must fail. The trusted session supplies the comparison token; a caller cannot choose that value.
Read the supplied handler to see where these decisions apply. It resolves the session first and returns 401 if it is missing or expired. A valid GET reads only that user’s theme; unsupported methods return 405. PATCH then checks the origin/token before parsing JSON or writing. A rejected context returns 403 with no write. After successful context checks, the supplied strict schema accepts only {theme:"light"|"dark"}; a valid PATCH writes for the session’s user ID and returns empty 204.
Observe one permitted PATCH and how a wrong token stops the same path before storage changes.
Independent checks award 5 points for an accepted mutation through the supplied handler, 10 for origin/token rejection without a write, and 5 for session/expiry rejection. The grader sends raw requests and supplies the repository, resolver, origin and clock.
Check and submit
Choose Submit solution below the editor. All editor files are submitted together. Keep the supplied paths, exported names and handler.
Middleware can share these checks across mutation handlers. A protected mutation with a valid session but no CSRF token should return 403 and leave storage unchanged; checking both detects writing before reporting an error.
Login attempt limits in each API process
Our request and permission checks decide whether a change may proceed. We also need to consider repeated work: even a rejected login can require a password check. Authorization decides whether an operation is permitted. Abuse controls limit repeated attempts or concurrent work, such as login attempts and password checks. They control resource use, not permission to edit data.
A limiter can count attempts for an account over a period, such as one minute, and reject further work with a retry response. Each running API process has its own memory: its counters reset on restart and are not shared with other processes. Identical settings therefore do not establish a combined limit.
Two in-memory limiters
0 / 5 points
Two separately running copies of the API each keep their own attempt counter. Each allows three attempts for the same account in one minute. During that minute, a caller sends three requests to each copy. Why might all six be accepted?
Server configuration and session-store failures
A failed session lookup must not fall back to an ID in a public request header. Keep database credentials on the server, out of client bundles, errors and logs.
Configure the allowed origin and cookie settings on the server. In production, Secure limits cookie transport to HTTPS; combine it with HttpOnly and an appropriate SameSite policy. Local HTTP examples need different settings. If the session store is unavailable, fail the operation without exposing internal details or selecting another account.
We now have several checks before a write. In Chapter 5.8, we will test allowed and denied requests to find out which rule a request reaches and whether the stored state changes. The next chapter considers provider-based sign-in as an alternative to local credentials.
Check Your Understanding
- Why can a request reach the server even when CORS prevents reading its response?
- Why must GET remain read-only under this policy?
- What must a CSRF rejection leave unchanged?
- Why does an attempt limit not replace authorization?