Marouane Souda
⬅ Go back to blog
What Are CSRF Attacks and How To Prevent Them in Node.js

Published on:

What Are CSRF Attacks and How To Prevent Them in Node.js

Imagine you've built a new SaaS product that became a huge success. Traffic is booming, and everything seems fine... until one day, it's not.

You checked your emails like every morning. One message catches your eye. It looks like it’s from your payment provider’s support team, and it sounds urgent, so you clicked on the link. It loads quickly and redirects to your homepage. Strange, but nothing seems off.

You brush it off and head to your admin dashboard to check yesterday's stats and earnings, but your login fails. Username or password invalid.

What you don’t realize is that the link didn’t just lead to your homepage. It was a phishing page with a hidden form. The moment you visited it, it silently submitted a request to your server to change your username and password, using your active session.

You didn’t log in, but your browser did it for you, just not the way you intended.

That was a Cross-Site Request Forgery (CSRF) attack.

By exploiting your authenticated session, the attacker locked you out of your own admin account. No brute force or any stolen credentials were necessary. All it took was one careless click, and your browser did the rest.

And just like that, you lost control of the SaaS business you worked so hard to build.

What is Cross-Site Request Forgery?

Cross-Site Request Forgery, or CSRF for short, is a dangerous type of cybersecurity attacks. It leverages your server's trust in your browser, tricking it into performing actions on your behalf, without your consent.

If you’re logged in, a CSRF attack can use your authenticated session to:

  • Transfer money from your account.
  • Change your login details (email or password).
  • Steal your data.
  • Tamper with application settings or infrastructure.
  • Even take over the entire system, especially if you're a privileged admin (like what happened to you earlier).

The impact depends entirely on what your session has access to. The more powerful your account, the more damaging the attack.

How Do Attackers Carry Out CSRF?

CSRF often involves social engineering, usually in the form of a phishing email or deceptive link that gets you to visit a malicious page.

While phishing is the preferred method, attackers also use:

  • Malicious ads (malvertising) embedded on legitimate websites.
  • Compromised third-party widgets or scripts.

What all those cases have in common is that your browser unknowingly sends an authenticated request to a trusted site, one that you are logged into.

To really get how CSRF works, you first need to understand how cookies operate.

What You Need to Know About Cookies

When you authenticate to a website, the server responds by setting a cookie (called a session cookie) in your browser, typically containing a session ID or an authentication token. From that point on, every time you send a request to that site, your browser automatically includes the cookie, so you don’t have to log in again.

The important thing you have to know is that your browser sends cookies with every request to the domain they belong to, without asking you.

Let me clarify the last statement: you browser stores cookies of each website you visit, but only sends the cookies relevant to each website. For example, when you visit Facebook, only Facebook's cookies are sent, not other websites' cookies. This information is key, so make sure to remember it.

This is what makes cookies so critical to understanding Cross-Site Request Forgery (CSRF). Because they are automatically included with every request, if you're logged into a website, any request your browser makes to that site will carry your credentials, even if you didn’t intentionally make the request.

Now that you understand how cookies work behind the scenes, you're ready to dive into how CSRF abuses this mechanism, and how to defend against it.

How Does Cross-Site Request Forgery work?

Most modern web applications use cookies to keep you logged in. When you sign in, the server sends a session cookie to your browser, a small token that identifies you. Every time you return to the website, your browser automatically includes that cookie with each request, so you don’t have to authenticate yourself again.

But here's the deal: your server has absolutely no way of knowing for sure whether it was you who made the request. As long as the request includes a valid session cookie, the server assumes it came from you.

This is the crux of CSRF.

An attacker lures you into visiting a malicious page (via phishing, ads, or a link), which silently sends a request to your app's server, most often from a hidden form (and for a very good reason, you'll see why later). Since you're already logged in, your browser includes your session cookie, and the server accepts the request as legitimate.

And the worst part is that you never see it happen, and when you realize something’s wrong, it's often far too late to do anything about it. The damage has already been done.

What are Cross-Site Requests?

A cross-site request, as the name suggests, is a request that was made from one site but sent to a different site, meaning the origin of the request differs from the destination server.

For example, a request from evil.com to example.com's server. If the request was initiated from example.com to its own server, it would be a same-site request.

In order for two URLs to be same-site, they must share the same:

  • Scheme (also called protocol), like http or https.
  • Domain (registrable domain, like yoursaas.io). Subdomains don't count.

So, http://yoursaas.io and https://yoursaas.io are different sites because they have different schemes (http and https), but https://yoursaas.io and https://admin.yoursaas.io are the same site because they share the same scheme and domain (https and yoursaas.io), and like I mentioned before, the subdomain (admin.) doesn't matter here.

About the Public Suffix List

The only exception to this rule are domains listed on the Public Suffix List. The PSL is a registry of domain suffixes under which users can directly register subdomains.

The most famous example of this is wordpress.com, where site1.wordpress.com and site2.wordpress.com are entirely separate sites, often owned by different users.

Because of this, browsers treat such subdomains as cross-site, not same-site, even though they share a common parent domain.

How Do Cross-Site Requests Differ From Cross-Origin Requests?

You may have heard of cross-origin requests, especially in the context of Cross-Origin Resource Sharing or Same-Origin Policy, but cross-origin and cross-site are two different terms with key differences.

Two URLs are said to be of same origin if they have the same:

  • Protocol (http, https).
  • Domain name (example.com).
  • subdomain (www).
  • Port (:80, :443, etc. Might be implied, like https meaning port 443 and http meaning port 80).

The key difference is that the subdomain and port must also match for two URLs to be same-origin, but only the protocol and domain need to match for them to be same-site. As you can see, the rules for origins are tighter.

For example, https://www.example.com:8080 and https://www.example.com:3000 are of different origins because they have different ports, 8080 and 3000, but they are same-site, since they share the same scheme (https) and domain (example.com).

Therefore, even if two URLs are same-site, they aren't necessarily same-origin.

Here is a summary table using the URL https://yoursaas.io for comparision:

Request Origin Same-Site? Same-Origin? Explanation
https://yoursaas.io/dashboard ✅ Yes ✅ Yes Different path, but same protocol, domain, and port. It's exactly the same site and origin.
https://yoursaas.io/settings ✅ Yes ✅ Yes Different path, but still same site and origin.
https://yoursaas.io:3000/ ✅ Yes ❌ No Same domain, but different port means different origin but same-site.
https://app.yoursaas.io/ ✅ Yes ❌ No Different subdomain means different origin but same-site.
http://yoursaas.io/ ❌ No ❌ No Same domain, but different scheme (HTTPHTTPS) means different site and origin.
https://evil.com/ ❌ No ❌ No Completely different site and origin.

Never forget! in CSRF, we're dealing with cross-site requests, not cross-origin requests.

Why Is Same-Origin Policy Not The Best Defense Against CSRF Attacks?

The browser distinguishes between two types of cross-origin JavaScript requests: "simple" and "complex".

Basically, complex requests require a preflight request beforehand to ensure they're safe to send, but simple requests do not. I explained in detail what makes a request "simple" or "complex" to a browser in my post about Same-Origin policy and Cross-Origin Resource Sharing, which I highly recommend you check out.

With that being said, here are the 3 reasons why you should never depend on Same-Origin Policy alone for protection again CSRF.

1. It Only Works on Complex Requests Via Preflight

Examples for cross-origin requests that the browser considers to be "complex" are PUT, PATCH, and DELETE requests. Complex requests, as I mentioned earlier, always trigger a preflight.

This preflight mechanism acts as an additional security layer: it gives the server a chance to reject unauthorized cross-origin requests before any sensitive action is taken.

But simple requests, like some cross-origin POST requests, aren't preflighted. So, if the browser proceeds with the request, the damage will have been done.

Why wasn't the request blocked from the start? This leads us to the next and second reason why Same-Origin Policy is not good for CSRF prevention.

2. It Doesn't Block The Request, Only Access To The Response Body

Under the Same-Origin Policy, the browser prevent JavaScript from reading the response body of a cross-origin requests, but it does not block the request itself from being sent.

And that’s the problem: the attacker doesn’t care about the response. Their goal is to make the request, like changing a password or transferring money. As long as the forged request goes through, the damage is already done, even if they can’t see the result.

3. It Doesn't Work on Non-JavaScript Cross-Origin Requests.

This is the most important reason. All that stuff I said about simple, complex, and preflight requests only applies to JavaScript requests, like when using fetch and XMLHttpRequest. It doesn't work with cross-origin requests using <a> tags, <img> tags, or forms.

Simply put, a malicious page on a shady website can’t fetch() your server with DELETE unless CORS allows it, but it can submit a form or create an <img src="...">, which sends cookies (your session cookie), tricking your browser, and Same-Origin Policy will happily allow it.

This is the reason why hidden forms are the most popular choice for carrying out CSRF attacks — They are not subject to the Same-Origin Policy.

This is because <a>, <form>, and <img> can't access the response body, so SOP allows them. But that's fine in CSRF, because the attacker has no need for the response, as long as his/her malicious request was successful.

The fact that hidden forms are the go-to method of attack for CSRF is why POST endpoints are the primary targets, since forms traditionally only support GET and POST requests, and GET requests, typically used for retrieving data (not making changes), can't perform sensitive actions worth attacking

What makes CSRF Different Than Other Cybersecurity Attacks?

What makes CSRF attacks unique to other types of cybersecurity attacks is who performs the malicious action.

In typical attacks, like XSS or SQL injection, the attacker actively exploits a vulnerability to send malicious code or queries to the server. But in a CSRF attack, it’s the victim’s browser that sends the request, not the attacker.

The attacker doesn't break into your account or guess your password. They simply trick your browser (which is already logged in to the trusted application) into making a forged request, such as transferring money or changing your email, without your knowledge.

Because the browser automatically includes authentication credentials, the server assumes the request is legitimate.

How to Defend Against CSRF Attacks?

There are 2 main ways to protect your server from CSRF: SameSite cookies, and anti-CSRF tokens. While anti-CSRF tokens remain the most reliable defense, using SameSite cookies provides strong additional protection. For the best security, it’s recommended to implement both methods together.

SameSite Cookies

The SameSite is an optional attribute for cookies that determines whether they can be sent in cross-site requests. It was created specifically to tackle CSRF attempts, and it takes one of three values: Strict, Lax, or None.

SameSite=Strict

To completely stop cookies from being sent with cross-site requests, choose Strict. It shuts down all CSRF attempts, offering the strongest CSRF protection. On the flip slide, it can negatively affect user experience, like when logging in from a third-party <a> link.

Imagine if Facebook has set its login session cookie with:

Set-Cookie: c_user=...; SameSite=Strict; Secure

And your friend sent you a Facebook link on WhatsApp. Even though you're logged in to Facebook on your browser, because the link is coming from another site (WhatsApp), this is a cross-site navigation, so the browser doesn't send the session cookie with the request due to SameSite=Strict. Facebook treats you as not logged in, and redirects you to the login page, even though you're already logged in on that browser.

Can you imagine how inconvenient that could be if it was the default internet behaviour?

SameSite=Lax

Lax is the default value for SameSite in modern browsers. It works like Strict, meaning the browser won't send cookies with cross-site requests, except for two cases:

  • You go through an anchor link <a> from a different site.
  • The request is safe (GET, HEAD).

Lax offers us a good balance between security and usability for most cases. It prevents many CSRF attempts while still allowing typical user navigation patterns. It's how you're able to log in to Facebook from a link on WhatsApp.

SameSite=None

None means the cookie can be sent with any cross-site request, which offers us absolutely no protection against CSRF.

For this reason, the cookie with the SameSite attribute set to None must have the Secure attribute, which means it must be sent only over HTTPS, never HTTP.

Anti-CSRF tokens

This is the best and recommended way to prevent CSRF attacks.

The idea is simple: attach a secret, unpredictable token to every state-changing request (like POST, PUT, DELETE). This token is tied to the user’s session and validated by the server.

Here is a longer explanation: when a user visits your site, the server gives them a CSRF token, either in a hidden form field (rendered in the page), a cookie, or in a JavaScript variable.

This token lives inside your site, and cannot be read by other sites, because the Same-Origin Policy prevents malicious websites from reading content, cookies, or JavaScript from a different origin.

Because the attacker cannot access this token (due to same-origin restrictions), they can’t forge valid requests, and any request without a valid token is rejected.

Anti-CSRF tokens are implemented in 2 patterns: Synchronizer Token Pattern and Double Submit Cookie Pattern. While they differ in where the token is stored and how it’s validated, both follow the same core principle: the server must verify that each state-changing request includes a valid, secret token.

I'm going to explain them, and then walk you through the steps of implementing each one in Node.js.

Synchronizer Token Pattern

Also known as classic CSRF token, the token is generated, stored on the server, and sent to the client, where it will be embedded in forms as a hidden input (<input type="hidden" value="csrf-token-123abc" />) or included as a custom header (X-Csrf-Token: csrf-token-123abc).

This makes it more suitable for apps where the frontend and the backend are tightly coupled, like in monolithic SSR apps using Django, Rails, Laravel, etc. Since the server already controls the HTML rendering and the session, it can inject the CSRF token directly into the page, like in a <meta> tag or a hidden form field, before serving it to the client.

When the client makes a cross-site request, the token will be included, and the server will compare it with the stored token. This makes it very secure, but since the server must store a unique CSRF token for each active session, it can increase memory usage and add some overhead, especially in applications with many concurrent users.

Double Submit Cookie Pattern

The key difference between this and the Synchronizer Token Pattern is that the server doesn't store the CSRF token. Instead, it sets it in a cookie, and the client-side JavaScript reads it and sends the value back in a custom header (X-Csrf-Token: csrf-token-123abc).

Now, here is core idea: the server compares the value in the request header with the value stored in the request cookie. Since the server didn't store anything, it only has the cookie and the header values to work with.

We have saved server-side storage and bandwidth overhead, but we are vulnerable if the attacker can read cookies.

Because it depends on client-side JavaScript to read and forward the token, this pattern is more suited for client-side rendering, like with React.js, Vue.js or Angular.js, since there is no need to store CSRF secrets on the server.

Tokens can be retrieved once and reused globally, so this pattern works well in fully decoupled frontend/backend architectures (like Next.js frontend + Express backend).

Note About X-Csrf-Token Header

Notice that both patterns involve the use of this custom header, which will make the cross-site request "complex", triggering a preflight request. This adds another layer of protection thanks to SOP and CORS.

Very Important to Keep In Mind

Never store or send the CSRF token only in a cookie, since that completely defeats its purpose.

The whole point of CSRF protection is to make sure that the request came intentionally from you, not just happened to have your browser send a valid session cookie.

But if you also store the CSRF token in a cookie, it will be automatically included in every request by the browser, just like the session cookie.

This means an attacker can exploit both, without needing access to your JavaScript. It's like putting a padlock to your door for security, and handing the thief the key to the lock.

Configure CSRF Tokens in Node.js

If you're building web applications in Node.js, especially if you're using a library like Express.js, CSRF protection can be easily implemented thanks to mature and well-supported libraries, so you won't have to roll-out your own CSRF protection.

Keep in mind that while I’m using Node.js for the implementation examples, the same principles apply across all web frameworks (Django, Laravel, Ruby-on-rails...). The underlying logic of CSRF protection remains the same.

For this tutorial, I will use two NPM packages, one for each pattern: csrf-sync for the Synchronizer Pattern, and csrf-csrf for the Double Submit Cookie Pattern. They are actively maintained by the same organization, Psifi Solutions, and have clear documentation. They both abstract away the complexities of CSRF protection.

Lastly, I will assume you're working in a decoupled architecture, with a separate frontend and backend. For the frontend, I’ll use Next.js (App Router), which is well suited for both client- and server-rendered use cases.

Synchronizer Token Pattern

For this pattern, I recommend you use the csrf-sync library. It implements the Synchronizer pattern.

I chose this library because csurf, the most widely used, is deprecated and hasn't been updated for years. When I checked the GitHub repository for csurf, the last meaningful commit I found was made in .

Anyway, here is the full setup in Express.js.

1. Backend

import express from 'express';
import session from 'express-session';
import cors from 'cors';
import { csrfSync } from 'csrf-sync';

const app = express();
app.use(express.json());

// Session middleware (required for csrf-sync)
app.use(session({
  secret: process.env.VERY_SECRET_KEY,
  resave: false,
  saveUninitialized: true,
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
  },
}));

// Setup CORS for your frontend origin
app.use(cors({
  origin: 'http://localhost:3000',
  methods: 'GET,PUT,PATCH,POST,DELETE',
  // Include 'X-Csrf-Token' so the request wouldn't be blocked
  allowedHeaders: ['Content-Type', 'Accept', 'Origin', 'X-Csrf-Token'],
  // Enable credentials so cookies are included in cross-origin requests
  credentials: true,
}));

const {
  generateToken, // Use this in your routes to generate, store, and get a CSRF token.
  csrfSynchronisedProtection, // This is the default CSRF protection middleware.
} = csrfSync();

// Endpoint to get CSRF token (client calls this to get the token)
app.get('/csrf-token', (req, res) => {
  res.status(200).json({ csrfToken: generateToken(req) });
});

// Middleware to generate CSRF token and store secret in session
// Anything registered after it is protected
app.use(csrfSynchronisedProtection);

// Protected POST endpoint
app.post('/submit', (req, res) => {
  // At this point, CSRF validation was successful thanks to `csrfSynchronisedProtection` middleware.
  res.status(201).json({ message: 'CSRF token verified. Data accepted.' });
});

app.listen(4000, () => {
  console.log('Server running at http://localhost:4000');
});

2. Frontend (Next.js, app router, version 15+)

To begin, we need to fetch the CSRF token from our server (using the /csrf-token endpoint), and store it in a global state so we can access it anywhere from our frontend and not have to fetch it before every request, saving us time and bandwidth.

If your frontend and backend are part of the same application (not decoupled), you can also inject the CSRF token directly into the page during server-side rendering. One common approach is to include it in a <meta> tag inside the <head>:

<meta name="csrf-token" content={csrfToken} />

This method works well for tightly coupled apps where the server renders HTML responses and embeds dynamic data into the page.

However, in decoupled applications, like in our case, where the frontend (Next.js) and backend (Express.js) are deployed separately, this approach isn’t viable. In that case, you’ll need to rely on client-side global state management to store and distribute the token.

You can choose any global state management library you like. For this example, I will use Context API.

1. context/CsrfContext.tsx
'use client';

import { createContext, useContext, useEffect, useState, ReactNode } from 'react';

type CsrfContextType = {
  csrfToken: string | null;
};

const CsrfContext = createContext<CsrfContextType>({ csrfToken: null });

export function useCsrf() {
  return useContext(CsrfContext);
}

export default function CsrfProvider({ children }: { children: ReactNode }) {
  const [csrfToken, setCsrfToken] = useState<string | null>(null);

  useEffect(() => {
    fetch('http://localhost:4000/csrf-token', {
      credentials: 'include',
    })
      .then(res => res.json())
      .then(data => setCsrfToken(data.csrfToken))
      .catch(err => console.error('CSRF fetch failed:', err));
  }, []);

  return (
    <CsrfContext.Provider value={{ csrfToken }}>
      {children}
    </CsrfContext.Provider>
  );
}
2. app/page.tsx
'use client';

import CsrfProvider from './context/CsrfContext';
import SubmitForm from './components/SubmitForm';

export default function HomePage() {
  return (
    <CsrfProvider>
      <main className="p-6">
        <h1 className="text-2xl font-bold mb-4">CSRF Context Demo</h1>
        <SubmitForm />
      </main>
    </CsrfProvider>
  );
}
3. components/SubmitForm.tsx
'use client';

import { useState } from 'react';
import { useCsrf } from '../context/CsrfContext';

export default function SubmitForm() {
  const { csrfToken } = useCsrf();
  const [message, setMessage] = useState<string | null>(null);

  const handleSubmit = async () => {
    if (!csrfToken) {
      setMessage('Missing CSRF token');
      return;
    }

    const res = await fetch('http://localhost:4000/submit', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': csrfToken,
      },
      credentials: 'include', // Needed for the cookies to be sent with the request
      body: JSON.stringify({ test: 'value' }),
    });

    const result = await res.json();
    setMessage(result.message || result.error);
  };

  return (
    <div>
      <h2 className="text-xl font-semibold">Submit Protected Request</h2>
      <button
        onClick={handleSubmit}
        className="mt-2 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
      >
        Submit
      </button>
      {message && <p className="mt-4 text-gray-800">{message}</p>}
    </div>
  );
}

Double Submit Cookie Pattern

We'll use csrf-csrf library for this. It's made specifically for the Double Submit Cookie Pattern.

1. Backend

import express from 'express';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import crypto from 'crypto';
import cors from 'cors';

const app = express();

// CORS configuration
app.use(cors({
  origin: 'http://localhost:3000',
  methods: 'GET,PUT,PATCH,POST,DELETE',
  // Include 'X-Csrf-Token' so the request wouldn't be blocked
  allowedHeaders: ['Content-Type', 'Accept', 'Origin', 'X-Csrf-Token'],
  // Enable credentials so cookies are included in cross-origin requests
  credentials: true,
}));

app.use(cookieParser());
app.use(express.json());

app.use(session({
  secret: process.env.VERY_SECRET_KEY,
  resave: false,
  saveUninitialized: true,
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
  },
}));

// Middleware to set CSRF token cookie if not present
const {
  generateCsrfToken, // Use this in your routes to provide a CSRF token.
  doubleCsrfProtection, // This is the default CSRF protection middleware.
} = doubleCsrf({
  getSecret: (req) => 'return some cryptographically pseudorandom secret here',
  getSessionIdentifier: (req) => req.session.id // return the requests unique identifier
});

// Endpoint to get CSRF token (client calls this to get the token)
app.get('/csrf-token', (req, res) => {
  res.status(200).json({ csrfToken: generateCsrfToken(req, res) });
});

// Middleware to generate CSRF token and store secret in session
// Any non `GET` route registered after it is protected
app.use(doubleCsrfProtection);

// Endpoint to test protected route
app.post('/submit', (req, res) => {
  // At this point, CSRF validation was successful thanks to `doubleCsrfProtection` middleware.
  res.status(201).json({ message: 'CSRF token verified. Data accepted.' });
});

app.listen(4000, () => {
  console.log('Server running on http://localhost:4000');
});

2. Frontend (Next.js, app router, version 15+)

This time, since the token is available anywhere in our frontend, we don't need global state like in the Synchronizer pattern.

components/SubmitForm.tsx
'use client';

import { useState } from 'react';

// Helper to extract the CSRF token from the cookie
function getCsrfTokenFromCookie() {
  return document.cookie
    .split('; ')
    .find(row => row.startsWith('CSRF-TOKEN='))
    ?.split('=')[1];
}

export default function SubmitForm() {
  const [message, setMessage] = useState<string | null>(null);

  const handleSubmit = async () => {
    const csrfToken = getCsrfTokenFromCookie();

    if (!csrfToken) {
      setMessage('Missing CSRF token');
      return;
    }

    const res = await fetch('http://localhost:4000/submit', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-XSRF-TOKEN': csrfToken,
      },
      credentials: 'include', // Needed for the cookies to be sent with the request
      body: JSON.stringify({ test: 'value' }),
    });

    const result = await res.json();
    setMessage(result.message || result.error);
  };

  return (
    <div>
      <h2 className="text-xl font-semibold">Submit with Double Submit Cookie</h2>
      <button
        onClick={handleSubmit}
        className="mt-2 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
      >
        Submit
      </button>
      {message && <p className="mt-4 text-gray-800">{message}</p>}
    </div>
  );
}
app/page.tsx
import SubmitForm from './components/SubmitForm.tsx';

export default function HomePage() {
  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold mb-4">Double Submit Cookie Pattern Demo</h1>
      <SubmitForm />
    </main>
  );
}

Important Takeways

  • Always apply CSRF protection only to state-changing operations (POST, PUT, DELETE, etc.), and not on GET requests.
  • Send the CSRF token in a custom header X-Csrf-Token to make the request "complex", thus causing the browser to send a preflight check. The more protection, the better, assuming you've configured CORS correctly.