CSRFSecurity · NestJS

When the browser sends the login cookie by itself, any page can make it send one: cross-site request forgery. Protect the routes that change state with a double-submit token that only your own page can present, bound to the session it was issued for.

What you will learn

Read the theory for CSRF

All Security lessons

All NestJS courses

loading types…

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.

  1. In csrf/csrf.ts, bind tokens to the session: the access_token cookie identifies it, and a visitor without one is anonymous. Read the token back from the x-csrf-token header.
  2. 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 /cats is 403, header or not: the middleware is mounted before cookieParser() and never sees the cookie half; or the header name differs between the page and getCsrfTokenFromRequest.
  • A token from before signing in still works after: the session identifier is a constant. Bind it to the session.
  • GET /cats is 403: the protection was mounted on a method it should ignore; ignoredMethods defaults to GET, HEAD and OPTIONS, and a GET that needs protection is a GET that should have been a POST.
  • POST /auth/login is 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.
  • SameSite first; then a double-submit token: cookie plus header, which a foreign page cannot read.
  • csrf-csrf binds each token to a session identifier and answers 403 invalid csrf token.
  • Mount after cookie-parser, on the routes that change state; never protect a GET, never let a GET change 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()).