Files
What you'll learn
- Explain a CSRF attack step by step, and why it needs credentials the browser attaches on its own (cookies) and cannot touch a bearer-token API
- Describe the double-submit cookie pattern: a token in a cookie and the same token in a header, which a foreign page cannot read
- Configure csrf-csrf with a secret, a session identifier, a cookie and a header, and mount it after cookie-parser on the routes that change state
- Read the 403 the middleware answers and know why a token from before sign-in stops working after
CSRF
Lessons 1 to 7 kept the token in the response body, and the page stored it and added Authorization: Bearer to each request by hand. Many apps prefer to put the token in an httpOnly cookie instead: a script on the page cannot read it, so a cross-site scripting bug cannot steal it, and the browser sends it with every request by itself. That second property is convenient, and it is a hole. The browser sends the cookie with every request to the API, including one that a page on evil.example made it send.
The attack
John is signed in to the shelter, cookie in place. He opens another tab and visits a page that contains <form action="https://api.shelter.example/cats/7" method="POST"> and a script that submits it, or an image tag pointing at a GET that changes something. The browser attaches John's cookie, because that is what cookies are for. The API sees a valid session and a well-formed request and deletes cat 7. John saw nothing. This is cross-site request forgery: the attacker never learns the token; they only make John's browser use it.
Three things had to be true: the credential is sent automatically (a cookie, or Basic auth), the request changes something, and the foreign page could construct it. A bearer token sent in a header fails the first: a foreign page cannot add a header to a request it makes to another origin without a CORS preflight the API would refuse (lesson 7), so a bearer-token API needs nothing from this lesson. Cookie-based APIs do.
Defences, in layers
SameSite cookies are the first layer, and this app's login sets sameSite: 'lax': the browser withholds the cookie on cross-site POSTs and on subresource loads, and sends it only on top-level navigations, which are GETs. Every current browser honours it, and it stops the form above. It does not cover a GET that changes state (never write one), older clients, or subdomains you do not control.
The second layer is a CSRF token, and the pattern the docs recommend is the double-submit cookie, implemented by csrf-csrf. The API hands the page a token and also sets it in a cookie; the page sends it back in a header on every change; the middleware checks that header and cookie carry the same token. A foreign page can make the browser send the cookie, but it cannot read the cookie to fill the header, because that would be reading another origin's cookie. csrf-csrf goes one step further: the token is an HMAC under a secret over a random value and a session identifier of your choosing, so a token issued for one session (anonymous, or another user) does not validate for a different one.
csrf-csrf
doubleCsrf() takes the configuration once and returns the pieces:
const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => 'a secret from configuration',
getSessionIdentifier: (req) => req.session?.id ?? 'anonymous',
cookieName: 'psifi.x-csrf-token',
getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'],
});
generateCsrfToken(req, res) mints a token and sets the cookie; a route hands it to the page. doubleCsrfProtection is Express middleware that ignores GET, HEAD and OPTIONS and checks everything else, answering 403 with invalid csrf token when the header is missing, differs from the cookie, or was issued for another session. Because it reads cookies, it must be registered after cookie-parser. Where to mount it is a design choice: on the whole app it also guards the login route, which the page cannot fetch a token for before signing in (a real problem, solved by issuing anonymous tokens); mounted on a path, app.use('/cats', doubleCsrfProtection), it guards the routes that change state and nothing else. Middleware runs before guards, so a request with no token is refused before the API even checks who is signed in.
Your task
The cats API signs in with a cookie; changing a cat needs a CSRF token.
- In
csrf/csrf.ts, bind tokens to the session: theaccess_tokencookie identifies it, and a visitor without one isanonymous. Read the token back from thex-csrf-tokenheader. - In
main.ts, mount the protection on the cats routes, after the cookies are parsed.
GET /csrf/token and the cookie-reading guard are already there. After Run, fetch a token in the request panel, then POST /cats with and without the x-csrf-token header; the panel keeps the cookies as a browser would.
When it fails
- Every
POST /catsis 403, header or not: the middleware is mounted beforecookieParser()and never sees the cookie half; or the header name differs between the page andgetCsrfTokenFromRequest. - A token from before signing in still works after: the session identifier is a constant. Bind it to the session.
GET /catsis 403: the protection was mounted on a method it should ignore;ignoredMethodsdefaults toGET,HEADandOPTIONS, and aGETthat needs protection is aGETthat should have been aPOST.POST /auth/loginis 403: the protection is mounted on the whole app; sign-in comes before the page has a session to get a token for.- The token in the header is right and the answer is still 403 after signing in again: the session identifier changed with the new cookie; fetch a fresh token after every sign-in.
Remember
- CSRF needs a credential the browser attaches on its own; a bearer header is safe, a cookie is not.
SameSitefirst; then a double-submit token: cookie plus header, which a foreign page cannot read.csrf-csrfbinds each token to a session identifier and answers 403invalid csrf token.- Mount after
cookie-parser, on the routes that change state; never protect aGET, never let aGETchange state.
Stuck? Show a hint
csrf.ts: getSessionIdentifier: (req: Request) => req.cookies?.access_token ?? 'anonymous'; getCsrfTokenFromRequest: (req: Request) => req.headers['x-csrf-token'] as string | undefined. main.ts: import { doubleCsrfProtection } from './csrf/csrf'; app.use('/cats', doubleCsrfProtection) after app.use(cookieParser()).
Press Run tests to start the app. Its log appears here.Graded endpoints
Reading is public and never CSRF-checked: no cookie, no token, a 200
Public too; the 404 is the handler's, so the guard let the request through
Middleware runs before guards: with no token the CSRF check refuses first, before anyone asks who is signed in
Issued for the anonymous session; the cookie half is set alongside. Kept, to try later
The token goes into an httpOnly cookie, which the request panel keeps like a browser would
No Authorization header: the guard read the token from the cookie the browser sent by itself. Exactly the property an attacker relies on
Bound to john's cookie now; the page would fetch this after signing in
Signed in, cookie sent, and still refused: the cookie alone is what a forged request would carry
The token from before signing in was bound to another session identifier, so it no longer validates
Cookie and header agree and match the session: only a page that could read the token can do this
GET changes nothing, so it is never checked; a protected GET would break every link to the site
Unchecked as well
Every method that changes state is checked, DELETE included
The same token serves the whole session