StackHawk
๏ƒ‰

React CSRF Protection Guide: Examples and How to Enable It

Matt Tanner   |   Aug 12, 2025

LinkedIn
X (Twitter)
Facebook
Reddit
Subscribe To StackHawk Posts

Even developers using token-based authentication (like JWTs) aren’t always safe since CSRF isn’t just a cookie problem. It’s about any action the browser can auto-perform using stored credentials or headers. This can be a tricky concept to grasp, especially for developers building single-page applications with React, Angular, Vue, etc. that interact with APIs and handle user authentication.

This singular security flaw can allow attackers to trick users into performing unintended actions on websites where they’re authenticated, potentially leading to unauthorized fund transfers, data modifications, or account takeovers.

In this comprehensive guide, I’ll help you understand CSRF from the ground up. I’ll set up a sample React app and an Express server to demonstrate how and why CSRF attacks occur. I’ll also show you how you can protect against them in general and specifically in a React application. And at the end, I’ll show you how StackHawk can help prevent and monitor these kinds of attacks!

What is CSRF (Cross-Site Request Forgery)?

CSRF (Cross-Site Request Forgery) is a web security vulnerability that allows an attacker to trick a user into performing unintended actions on a web application in which they’re authenticated. This often involves exploiting the user’s browser to send unauthorized requests, such as changing account details or initiating transactions, without the user’s consent.

The format of a CSRF attack follows this pattern:

Malicious Site โ†’ User’s Browser โ†’ Legitimate Site (with user’s cookies)

CSRF does not completely break the Same-Origin Policy, but it uses to its advantage the fact that browsers automatically include credentials like cookies or session tokens in cross-origin requests. Same-Origin Policy (SOP) prevents reading responses from different origins, but does not block requests from being sent, which is what CSRF abuses.

Here’s what a CSRF-prone request might look like:

// Without CSRF protection โ€” vulnerable

fetch('/api/transfer', {

ย ย method: 'POST',

ย ย credentials: 'include',

ย ย body: JSON.stringify({ amount: 1000, to: 'attacker' })

});

To prevent this, CSRF protection involves issuing a token with each user session and requiring it on all state-changing requests. That way, only legitimate requests from your frontend can be validated:

// With CSRF protection โ€” secure

fetch('/api/transfer', {

ย ย method: 'POST',

ย ย credentials: 'include',

ย ย headers: { 'X-CSRF-Token': csrfToken },

ย ย body: JSON.stringify({ amount: 1000, to: 'recipient' })

});

The CSRF token is typically generated server-side and embedded in your frontend via a meta tag or dedicated API call.

Common CSRF Attack Vectors

CSRF uses different attack vectors to exploit automatic cookie inclusion. Each vector serves a specific purpose and can target different types of requests. Here’s a reference to the most commonly used attack vectors:

Attack VectorDescriptionCommon Targets
Malicious linkHidden form submissions via GET requestsProfile updates, account settings
Embedded formAuto-submitting forms on malicious sitesMoney transfers, password changes
XHR requestJavaScript-based cross-origin requestsAPI endpoints, AJAX calls
Image tagsUsing img src to trigger GET requestsLogout endpoints, simple actions

The anatomy of a CSRF attack involves three key components:

  • An authenticated user – The victim must be logged into the target website
  • A malicious request – An attacker crafts a request that performs an unwanted action
  • Social engineering – The attacker tricks the user into triggering the malicious request

Let’s see how each component works in practice:

Authenticated User Example:

// User logs into banking app

POST /api/login

{ "username": "john", "password": "secret123" }

// Server sets session cookie: sessionId=abc123

Malicious Request Example:

<!-- Hidden in attacker's website -->

<form action="https://bank.com/api/transfer" method="POST">

ย ย <input type="hidden" name="amount" value="1000">

ย ย <input type="hidden" name="to" value="[email protected]">

</form>

Social Engineering Example: “Click here to see funny cat videos!” โ†’ Triggers the malicious form submission

Attack VectorCSRF VulnerableCSRF Protected
Malicious linkโœ— Succeedsโœ“ Blocked
Embedded formโœ— Succeedsโœ“ Blocked
XHR requestโœ— Succeedsโœ“ Blocked

Unlike data theft attacks, CSRF is a “blind attack” and does not return data to the attacker, making it a poor choice for data theft but excellent for unauthorized actions.

Understanding CSRF Attacks

CSRF attacks work by exploiting this automatic cookie inclusion. An attacker creates a malicious website that makes requests to your application. When a victim visits the attacker’s site while authenticated to your application, their browser automatically includes authentication cookies with the malicious requests.

The key insight is that the server cannot distinguish between a legitimate request from your React app and a malicious request from an attacker’s siteโ€”both include the same authentication cookies.

Modern CSRF Browser Protections and Limitations

Modern browsers include built-in CSRF protections, but they have limitations:

// SameSite=Strict - Strongest protection

Set-Cookie: sessionId=abc123; SameSite=Strict

// SameSite=Lax - Balanced approach (Chrome default)

Set-Cookie: sessionId=abc123; SameSite=Laxย ย 

// SameSite=None - Legacy behavior (requires Secure)

Set-Cookie: sessionId=abc123; SameSite=None; Secure

Cross-Origin Resource Sharing Misconceptions

Many developers incorrectly believe Cross-Origin Resource Sharing (CORS) prevents CSRF attacks. CORS is designed to prevent data leaks across origins, not to stop unauthorized actions. That’s why CSRF can succeed even when CORS is configured. Understanding this distinction is crucial:

// CORS allows this cross-origin request

fetch('https://bank.com/api/transfer', {

ย ย method: 'POST',

ย ย mode: 'cors',

ย ย credentials: 'include'ย  // Cookies still sent!

});

CORS controls reading responses, not sending requests. CSRF attacks don’t need to read responses.

CSRF in Development vs Production

Development Environment Risks

Development setups often disable security features that mask CSRF vulnerabilities:

// Development - Permissive and vulnerable

app.use(cors({

ย ย origin: true, ย  ย  ย  ย  ย  // Allows all origins

ย ย credentials: true ย  ย  ย  // Includes cookies

}));

// Production - Restrictive and secure

app.use(cors({

ย ย origin: 'https://myapp.com',ย  // Specific domain only

ย ย credentials: true

}));

Common Development Shortcuts That Introduce Risk

Developers often implement shortcuts during development that persist into production:

// Dangerous: Disabling CSRF for "easier" development

app.use('/api', (req, res, next) => {

ย ย if (process.env.NODE_ENV === 'development') {

ย ย ย ย return next(); // Skip CSRF validation

ย ย }

ย ย validateCSRFToken(req, res, next);

});

// Better: Use test tokens in development

app.use('/api', (req, res, next) => {

ย ย if (process.env.NODE_ENV === 'development' && !req.headers['x-csrf-token']) {

ย ย ย ย req.headers['x-csrf-token'] = 'dev-token-123';

ย ย }

ย ย validateCSRFToken(req, res, next);

});

A better alternative is to either mock CSRF tokens in development environments or allow a static, known-good token instead. Completely bypassing CSRF protection in development risks a chance of it being bypassed in production as well.

How to Protect Against CSRF

Before diving into specific implementation details, it’s essential to understand the fundamental strategies for CSRF protection. These approaches work by breaking the fundamental assumptions that make CSRF attacks possible. Let’s look at what CSRF protection strategies look like when deployed on a NodeJS backend.

Proper HTTP Method Usage

The foundation of CSRF protection starts with proper HTTP method usage. Many CSRF attacks exploit applications that perform state-changing operations using GET requests, which can be triggered by simple image tags or link clicks.

The Problem: Applications that allow state changes via GET requests are vulnerable to simple CSRF attacks:

// VULNERABLE: State change via GET request

app.get('/delete-account', (req, res) => {

ย ย ย ย // This can be triggered by an image tag or malicious link

ย ย ย ย deleteUserAccount(req.session.userId);

ย ย ย ย res.redirect('/goodbye');

});

The Solution: Restrict state-changing operations to appropriate HTTP methods (POST, PUT, DELETE). This follows HTTP specification guidelines and is essential for both security and proper API design:

// SECURE: State change via POST request

app.post('/delete-account', (req, res) => {

ย ย ย ย // This requires a form submission or AJAX call and follows HTTP specs

ย ย ย ย deleteUserAccount(req.session.userId);

ย ย ย ย res.redirect('/goodbye');

});

It’s important to note that GET requests should never mutate state. This is not just for CSRF protection but for HTTP specification compliance. Search engines, browsers prefetching, and other automated tools assume GET requests are safe to repeat.

This simple change eliminates the most basic CSRF attacks, though more sophisticated attacks using JavaScript can still bypass this protection.

Modern browsers support the SameSite cookie attribute, which provides significant CSRF protection with minimal implementation effort:

app.use(session({

ย ย ย ย secret: 'your-secret-key',

ย ย ย ย cookie: {

ย ย ย ย ย ย ย ย sameSite: 'strict',ย  // or 'lax' for more compatibility

ย ย ย ย ย ย ย ย secure: true,ย  ย  ย  ย  // Enable in production with HTTPS

ย ย ย ย ย ย ย ย httpOnly: true ย  ย  ย  // Prevents JavaScript access to session cookie

ย ย ย ย }

}));

SameSite Options:

  • strict: Cookies never sent with cross-site requests (most secure)
  • lax: Cookies sent with top-level navigation but not embedded requests (good balance)
  • none: Cookies are always sent (requires secure: true, use with caution)

While SameSite cookies provide excellent baseline protection, they should not be relied upon as the sole CSRF control:

  • Browser Support: Older browsers (Safari โ‰ค12, Internet Explorer, older Chrome/Firefox versions) may not respect SameSite=strict/lax settings, leaving these users unprotected
  • Mobile Apps: Non-web clients often have limited or inconsistent SameSite support
  • Complex Integrations: Some OAuth flows, embedded widgets, or cross-site authentication may break with strict SameSite settings

Therefore, SameSite should be used as part of a defense-in-depth strategy with fallback token-based validation to remain robust across all client environments, not as a replacement for CSRF tokens.

Referrer Header Validation

Another protective measure involves validating the referrer header to ensure requests originate from your application:

function validateReferrer(req, res, next) {

ย ย ย ย const referer = req.get('Referer');

ย ย ย ย const allowedOrigins = ['https://yourapp.com', 'https://www.yourapp.com'];

ย ย ย ย if (!referer || !allowedOrigins.some(origin => referer.startsWith(origin))) {

ย ย ย ย ย ย ย ย return res.status(403).json({ error: 'Invalid referer' });

ย ย ย ย }

ย ย ย ย next();

}

app.post('/sensitive-action', validateReferrer, (req, res) => {

ย ย ย ย // Protected route

});

However, referrer validation has limitationsโ€”some browsers or privacy tools may strip referrer headers, and the header can be spoofed in certain circumstances.

CSRF in Action: Creating a Vulnerable Express Server

We now understand CSRF from a conceptual perspective and what it entails; the best way to learn is by doing.

Let’s start by creating a server with purposefully vulnerable endpoints added:

You will need Node.js in this demo. I will show you how to install, but having a good foundation will help this demo be successful.

As in starting every application, let’s create a directory to house our files:

mkdir csrf-demo

Then we need to initialize a new npm project:

npm init -y

Install the required dependencies:

npm i express cors cookie-parser express-session

Adding the “cors” dependency will allow more permissive vulnerabilities. 

Note: this is purely for demonstration purposes. Always make sure your production application is as secure as possible with no explicitly added vulnerabilities. If youโ€™re unfamiliar with how to do this, check out our complete guide: Configuring CORS in React.

Next, let’s build out the actual application. First, we will build out our API server using Express. Create an App.js file and add the following contents:

const express = require('express');

const cors = require('cors');

const cookieParser = require('cookie-parser');

const session = require('express-session');

const app = express();

// Middleware setup

app.use(express.json());

app.use(express.urlencoded({ extended: false }));

app.use(cookieParser());

// Session configuration - vulnerable setup

app.use(session({

ย ย secret: 'vulnerable-secret',

ย ย resave: false,

ย ย saveUninitialized: false,

ย ย cookie: {ย 

ย ย ย ย secure: false, // Set to true in production with HTTPS

ย ย ย ย httpOnly: true,

ย ย ย ย maxAge: 24 * 60 * 60 * 1000 // 24 hours

ย ย }

}));

app.use(cors({

ย ย origin: true,ย  // Allow all origins for demo

ย ย credentials: true

}));

// Mock user data

const users = [

ย ย { id: 1, username: 'john', email: '[email protected]', balance: 1000 },

ย ย { id: 2, username: 'alice', email: '[email protected]', balance: 500 }

];

app.get('/', (req, res) => {

ย ย res.send('Welcome to CSRF vulnerable server ๐Ÿšจ');

});

// Login endpoint

app.post('/api/login', (req, res) => {

ย ย const { username } = req.body;

ย ย const user = users.find(u => u.username === username);

ย ย if (user) {

ย ย ย ย req.session.userId = user.id;

ย ย ย ย req.session.username = user.username;

ย ย ย ย res.json({ย 

ย ย ย ย ย ย success: true,ย 

ย ย ย ย ย ย user: { id: user.id, username: user.username, balance: user.balance }ย 

ย ย ย ย });

ย ย } else {

ย ย ย ย res.status(401).json({ success: false, message: 'Invalid credentials' });

ย ย }

});

app.post('/api/transfer', (req, res) => {

ย ย console.log('=== TRANSFER REQUEST ===');

ย ย console.log('Session ID exists:', !!req.session.userId);

ย ย console.log('Username from session:', req.session.username);

ย ย console.log('Request origin:', req.headers.origin);

ย ย console.log('========================');

ย ย if (!req.session.userId) {

ย ย ย ย return res.status(401).json({ success: false, message: 'Not authenticated' });

ย ย }

ย ย const { amount, recipient } = req.body;

ย ย const user = users.find(u => u.id === req.session.userId);

ย ย if (user && user.balance >= amount) {

ย ย ย ย user.balance -= amount;

ย ย ย ย console.log(`Transfer: $${amount} from ${user.username} to ${recipient}`);

ย ย ย ย res.json({ย 

ย ย ย ย ย ย success: true,ย 

ย ย ย ย ย ย message: `Transferred $${amount} to ${recipient}`,

ย ย ย ย ย ย newBalance: user.balanceย 

ย ย ย ย });

ย ย } else {

ย ย ย ย res.status(400).json({ success: false, message: 'Insufficient funds' });

ย ย }

});

// Vulnerable delete endpoint (no CSRF protection)

app.post('/api/delete-account', (req, res) => {

ย ย if (!req.session.userId) {

ย ย ย ย return res.status(401).json({ success: false, message: 'Not authenticated' });

ย ย }

ย ย const userId = req.session.userId;

ย ย const userIndex = users.findIndex(u => u.id === userId);

ย ย if (userIndex !== -1) {

ย ย ย ย const deletedUser = users.splice(userIndex, 1)[0];

ย ย ย ย req.session.destroy();

ย ย ย ย console.log(`Account deleted: ${deletedUser.username}`);

ย ย ย ย res.json({ success: true, message: 'Account deleted successfully' });

ย ย } else {

ย ย ย ย res.status(404).json({ success: false, message: 'User not found' });

ย ย }

});

// Profile endpoint

app.get('/api/profile', (req, res) => {

ย ย if (!req.session.userId) {

ย ย ย ย return res.status(401).json({ success: false, message: 'Not authenticated' });

ย ย }

ย ย const user = users.find(u => u.id === req.session.userId);

ย ย res.json({ user: { id: user.id, username: user.username, email: user.email, balance: user.balance } });

});

app.listen(8080, () => {

ย ย console.log('Vulnerable server running on http://localhost:8080');

});

Alright, almost there! The final step to finalize getting your server up and running is simply running:

node app.js

And then visiting to verify it’s up and running:

http://localhost:8080

Create React App

That will serve as a logging tool to see the internal logs which is something users won’t be able to access, so they will never know something went wrong (the very thing CSRF is good at).

Next, we will want to create a simple front-end React application. You will want to, in another terminal window, run:

npx create-react-app csrf-react-demo

That will build the entire directory structure and provide you with the template to build out the React App.  (Weโ€™ll be utilizing the well-known setup seen here.) 

In order for this demo to work, we will need to be permissive in both our front and backend code. Once the overall structure of the React app is on your machine, navigate to the App.js file under your /src directory. Then, copy and paste the following code:

import { useEffect, useState } from 'react';

import './App.css';

function App() {

ย ย const [user, setUser] = useState(null);

ย ย const [username, setUsername] = useState('');

ย ย const [transferAmount, setTransferAmount] = useState('');

ย ย const [recipient, setRecipient] = useState('');

ย ย const [message, setMessage] = useState('');

ย ย // Check if user is already logged in

ย ย useEffect(() => {

ย ย ย ย checkAuthStatus();

ย ย }, []);

ย ย const checkAuthStatus = async () => {

ย ย ย ย try {

ย ย ย ย ย ย const response = await fetch('http://localhost:8080/api/profile', {

ย ย ย ย ย ย ย ย credentials: 'include',

ย ย ย ย ย ย ย ย headers: {

ย ย ย ย ย ย ย ย ย ย 'Content-Type': 'application/json'

ย ย ย ย ย ย ย ย }

ย ย ย ย ย ย });

ย ย ย ย ย ย if (response.ok) {

ย ย ย ย ย ย ย ย const data = await response.json();

ย ย ย ย ย ย ย ย setUser(data.user);

ย ย ย ย ย ย }

ย ย ย ย } catch (error) {

ย ย ย ย ย ย console.log('Not authenticated');

ย ย ย ย }

ย ย };

ย ย const handleLogin = async (e) => {

ย ย ย ย e.preventDefault();

ย ย ย ย try {

ย ย ย ย ย ย const response = await fetch('http://localhost:8080/api/login', {

ย ย ย ย ย ย ย ย method: 'POST',

ย ย ย ย ย ย ย ย credentials: 'include',

ย ย ย ย ย ย ย ย headers: {

ย ย ย ย ย ย ย ย ย ย 'Content-Type': 'application/json'

ย ย ย ย ย ย ย ย },

ย ย ย ย ย ย ย ย body: JSON.stringify({ username })

ย ย ย ย ย ย });

ย ย ย ย ย ย const data = await response.json();

ย ย ย ย ย ย if (data.success) {

ย ย ย ย ย ย ย ย setUser(data.user);

ย ย ย ย ย ย ย ย setMessage('Login successful!');

ย ย ย ย ย ย ย ย setUsername('');

ย ย ย ย ย ย } else {

ย ย ย ย ย ย ย ย setMessage('Login failed: ' + data.message);

ย ย ย ย ย ย }

ย ย ย ย } catch (error) {

ย ย ย ย ย ย setMessage('Login error: ' + error.message);

ย ย ย ย }

ย ย };

ย ย const handleTransfer = async (e) => {

ย ย ย ย e.preventDefault();

ย ย ย ย try {

ย ย ย ย ย ย const response = await fetch('http://localhost:8080/api/transfer', {

ย ย ย ย ย ย ย ย method: 'POST',

ย ย ย ย ย ย ย ย credentials: 'include',

ย ย ย ย ย ย ย ย headers: {

ย ย ย ย ย ย ย ย ย ย 'Content-Type': 'application/json'

ย ย ย ย ย ย ย ย },

ย ย ย ย ย ย ย ย body: JSON.stringify({ย 

ย ย ย ย ย ย ย ย ย ย amount: parseFloat(transferAmount),

ย ย ย ย ย ย ย ย ย ย recipientย 

ย ย ย ย ย ย ย ย })

ย ย ย ย ย ย });

ย ย ย ย ย ย const data = await response.json();

ย ย ย ย ย ย if (data.success) {

ย ย ย ย ย ย ย ย setUser(prev => ({ ...prev, balance: data.newBalance }));

ย ย ย ย ย ย ย ย setMessage(data.message);

ย ย ย ย ย ย ย ย setTransferAmount('');

ย ย ย ย ย ย ย ย setRecipient('');

ย ย ย ย ย ย } else {

ย ย ย ย ย ย ย ย setMessage('Transfer failed: ' + data.message);

ย ย ย ย ย ย }

ย ย ย ย } catch (error) {

ย ย ย ย ย ย setMessage('Transfer error: ' + error.message);

ย ย ย ย }

ย ย };

ย ย const handleDeleteAccount = async () => {

ย ย ย ย if (!window.confirm('Are you sure you want to delete your account? This cannot be undone!')) {

ย ย ย ย ย ย return;

ย ย ย ย }

ย ย ย ย try {

ย ย ย ย ย ย const response = await fetch('http://localhost:8080/api/delete-account', {

ย ย ย ย ย ย ย ย method: 'POST',

ย ย ย ย ย ย ย ย credentials: 'include',

ย ย ย ย ย ย ย ย headers: {

ย ย ย ย ย ย ย ย ย ย 'Content-Type': 'application/json'

ย ย ย ย ย ย ย ย }

ย ย ย ย ย ย });

ย ย ย ย ย ย const data = await response.json();

ย ย ย ย ย ย if (data.success) {

ย ย ย ย ย ย ย ย setUser(null);

ย ย ย ย ย ย ย ย setMessage('Account deleted successfully');

ย ย ย ย ย ย } else {

ย ย ย ย ย ย ย ย setMessage('Delete failed: ' + data.message);

ย ย ย ย ย ย }

ย ย ย ย } catch (error) {

ย ย ย ย ย ย setMessage('Delete error: ' + error.message);

ย ย ย ย }

ย ย };

ย ย return (

ย ย ย ย <div className="App">

ย ย ย ย ย ย <h1>Banking Demo - Vulnerable to CSRF</h1>

ย ย ย ย ย ย {message && (

ย ย ย ย ย ย ย ย <div className={`message ${message.includes('error') || message.includes('failed') ? 'error' : 'success'}`}>

ย ย ย ย ย ย ย ย ย ย {message}

ย ย ย ย ย ย ย ย </div>

ย ย ย ย ย ย )}

ย ย ย ย ย ย {!user ? (

ย ย ย ย ย ย ย ย <div className="login-section">

ย ย ย ย ย ย ย ย ย ย <h2>Login</h2>

ย ย ย ย ย ย ย ย ย ย <form onSubmit={handleLogin}>

ย ย ย ย ย ย ย ย ย ย ย ย <input

ย ย ย ย ย ย ย ย ย ย ย ย ย ย type="text"

ย ย ย ย ย ย ย ย ย ย ย ย ย ย placeholder="Username (try 'john' or 'alice')"

ย ย ย ย ย ย ย ย ย ย ย ย ย ย value={username}

ย ย ย ย ย ย ย ย ย ย ย ย ย ย onChange={(e) => setUsername(e.target.value)}

ย ย ย ย ย ย ย ย ย ย ย ย ย ย required

ย ย ย ย ย ย ย ย ย ย ย ย />

ย ย ย ย ย ย ย ย ย ย ย ย <button type="submit">Login</button>

ย ย ย ย ย ย ย ย ย ย </form>

ย ย ย ย ย ย ย ย </div>

ย ย ย ย ย ย ) : (

ย ย ย ย ย ย ย ย <div className="dashboard">

ย ย ย ย ย ย ย ย ย ย <div className="user-info">

ย ย ย ย ย ย ย ย ย ย ย ย <h2>Welcome, {user.username}!</h2>

ย ย ย ย ย ย ย ย ย ย ย ย <p>Email: {user.email}</p>

ย ย ย ย ย ย ย ย ย ย ย ย <p>Balance: ${user.balance}</p>

ย ย ย ย ย ย ย ย ย ย </div>

ย ย ย ย ย ย ย ย ย ย <div className="transfer-section">

ย ย ย ย ย ย ย ย ย ย ย ย <h3>Transfer Money</h3>

ย ย ย ย ย ย ย ย ย ย ย ย <form onSubmit={handleTransfer}>

ย ย ย ย ย ย ย ย ย ย ย ย ย ย <input

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย type="number"

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย placeholder="Amount"

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย value={transferAmount}

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย onChange={(e) => setTransferAmount(e.target.value)}

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย required

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย min="1"

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย step="0.01"

ย ย ย ย ย ย ย ย ย ย ย ย ย ย />

ย ย ย ย ย ย ย ย ย ย ย ย ย ย <input

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย type="text"

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย placeholder="Recipient email"

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย value={recipient}

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย onChange={(e) => setRecipient(e.target.value)}

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย required

ย ย ย ย ย ย ย ย ย ย ย ย ย ย />

ย ย ย ย ย ย ย ย ย ย ย ย ย ย <button type="submit">Transfer</button>

ย ย ย ย ย ย ย ย ย ย ย ย </form>

ย ย ย ย ย ย ย ย ย ย </div>

ย ย ย ย ย ย ย ย ย ย <div className="danger-zone">

ย ย ย ย ย ย ย ย ย ย ย ย <h3>Danger Zone</h3>

ย ย ย ย ย ย ย ย ย ย ย ย <buttonย 

ย ย ย ย ย ย ย ย ย ย ย ย ย ย onClick={handleDeleteAccount}

ย ย ย ย ย ย ย ย ย ย ย ย ย ย style={{ backgroundColor: 'red', color: 'white', padding: '10px 20px', border: 'none', borderRadius: '4px' }}

ย ย ย ย ย ย ย ย ย ย ย ย >

ย ย ย ย ย ย ย ย ย ย ย ย ย ย Delete Account

ย ย ย ย ย ย ย ย ย ย ย ย </button>

ย ย ย ย ย ย ย ย ย ย </div>

ย ย ย ย ย ย ย ย </div>

ย ย ย ย ย ย )}

ย ย ย ย </div>

ย ย );

}

export default App;

Then go back up a directory and add a class to your CSS file App.css:

.csrf-info {

ย ย background-color: #e8f5e8;

ย ย padding: 8px 12px;

ย ย border-radius: 4px;

ย ย margin: 10px 0;

ย ย font-family: monospace;

ย ย border-left: 4px solid #28a745;

}

Awesome! With everything in place, we can go ahead and run it using:

npm start

Playing the Part of Attacker: Building the CSRF Attack

At this point, we have a fully functioning front and backend application. Now we can go out and start acquiring clients, right? Sadly, not quite yet. We got into the mind of the developer, and one would think that once it’s set up, there’s nothing else to do. But that false sense of security is what gets most of us developers in trouble (and why everyone should be using a dynamic security scanning tool like StackHawk).

Now, let’s get into the mind of the attacker. The beauty (and conversely, scary) part of a CSRF attack is that it can happen anywhere. As long as the attacker has your authenticated cookies, they could slyly have a website up and running in your browser and utilize those cookies to gain access. Limited and non-data-visible access, but access nonetheless.

So let’s build a quick HTML website that we will host locally and can be placed anywhere in your file system.

Like we did previously, let’s create a new file called attack.html. In that file, we’ll add the following:

<!DOCTYPE html>

<html>

<head>

ย ย ย ย <title>CSRF Attack Demo</title>

</head>

<body>

ย ย ย ย <h1>Click to Attack!</h1>

ย ย ย ย <button onclick="attack()">Execute CSRF Attack</button>

ย ย ย ย <div id="result"></div>

ย ย ย ย <script>

ย ย ย ย ย ย ย ย function attack() {

ย ย ย ย ย ย ย ย ย ย ย ย fetch('http://localhost:8080/api/transfer', {

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย method: 'POST',

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย credentials: 'include', // Ensures browser sends cookies with the request

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย headers: { 'Content-Type': 'application/json' },

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย body: JSON.stringify({

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย amount: 100,

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย recipient: '[email protected]'

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย })

ย ย ย ย ย ย ย ย ย ย ย ย })

ย ย ย ย ย ย ย ย ย ย ย ย .then(response => response.json())

ย ย ย ย ย ย ย ย ย ย ย ย .then(data => {

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย if (data.success) {

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย document.getElementById('result').innerHTML =ย 

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย '<h2 style="color: red;">ATTACK SUCCESSFUL!</h2>' +

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย '<p>Transferred $100 to [email protected]</p>';

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย } else {

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย document.getElementById('result').innerHTML =ย 

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย '<h2 style="color: green;">Attack blocked: ' + data.message + '</h2>';

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย }

ย ย ย ย ย ย ย ย ย ย ย ย })

ย ย ย ย ย ย ย ย ย ย ย ย .catch(error => {

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย document.getElementById('result').innerHTML =ย 

ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย ย '<h2 style="color: green;">Attack failed: ' + error.message + '</h2>';

ย ย ย ย ย ย ย ย ย ย ย ย });

ย ย ย ย ย ย ย ย }

ย ย ย ย </script>

</body>

</html>

Then we need to host this. For this use case, we will simply run in another terminal window:

npx http-server -p 8081 -a localhost

But in most scenarios, attackers are smart and will serve you and the users very convincing front ends. Some, we’ve even seen mimic actual websites from URL to design.

CSRF Attack in Action

Alright, everything is set up, and your final window should look something like this: 

You have your Express server running in one tab, your React app in another tab, and finally, the attack in another tab. Now I will walk through what it should look like as you walk through, clicking through the application: both from a user POV and an attacker POV.

User POV

The user experiences no disruptions while using the applications. And the server shows no malicious logs:

Attackerโ€™s POV

Typically, the attacker would be notified or have some form of automation to detect a user being authenticated via a session cookie. But for this sake, letโ€™s think of the usual image of a hacker in their black hoodie waiting for you to click their website, thenโ€ฆBAM. John logs in and wants to start transferring money. Now the attacker canโ€™t see that data, but they know John is logged in. Time to get to work.

John unknowingly kept the attacker’s website in his browser. In this particular demo, we made the attackerโ€™s website look extremely obvious, but most of the time the attack website would be much more well hidden.

Now John is confused because heโ€™s out $100 and has no clue where it went. And unless he were able to see the backend logs, heโ€™d be coming to you for help. And that could mean youโ€™re out of $100 instead of John.

How to Fix the CSRF Vulnerability

Now that we’ve seen how devastating a CSRF attack can be, let’s implement proper protection to secure our application. We’ll use CSRF tokens to ensure that only legitimate requests from our frontend can be processed by our backend.

Installing Modern CSRF Protection

The popular csurf package has been deprecated, so we’ll use the modern csrf package instead:

npm install csrf

Key Changes to the Express Server

Here are the main changes we need to make to our vulnerable server:

1. Initialize CSRF and add the protection middleware

We need to create a CSRF token generator and implement middleware that validates tokens on all state-changing requests. This middleware will skip validation for login endpoints (since users don’t have tokens yet) and GET requests (which shouldn’t change state anyway).

const csrf = require('csrf');

// Initialize CSRF

const csrfTokens = new csrf();

// CSRF middleware

const csrfProtection = (req, res, next) => {

ย ย // Skip CSRF for login endpoint and GET requests

ย ย if ((req.path === '/api/login' && req.method === 'POST') || req.method === 'GET') {

ย ย ย ย return next();

ย ย }

ย ย // Generate secret if not exists

ย ย if (!req.session.csrfSecret) {

ย ย ย ย req.session.csrfSecret = csrfTokens.secretSync();

ย ย }

ย ย const token = req.headers['x-csrf-token'] || req.body._csrf;

ย ย if (!token || !csrfTokens.verify(req.session.csrfSecret, token)) {

ย ย ย ย return res.status(403).json({ย 

ย ย ย ย ย ย success: false,ย 

ย ย ย ย ย ย message: 'Invalid CSRF token'ย 

ย ย ย ย });

ย ย }

ย ย next();

};

// Apply CSRF protection to API routes

app.use('/api', csrfProtection);

2. Add a CSRF token endpoint

Our frontend needs a way to get CSRF tokens. This endpoint generates a session-specific secret (if one doesn’t exist) and creates a token that can be verified against that secret.

app.get('/api/csrf-token', (req, res) => {

ย ย if (!req.session.csrfSecret) {

ย ย ย ย req.session.csrfSecret = csrfTokens.secretSync();

ย ย }

ย ย const token = csrfTokens.create(req.session.csrfSecret);

ย ย res.json({ csrfToken: token });

});

3. Update the login endpoint to return a CSRF token

Since login establishes a new session, it’s the perfect time to provide the user with their first CSRF token. This eliminates the need for an additional API call after login.

app.post('/api/login', (req, res) => {

ย ย const { username } = req.body;

ย ย const user = users.find(u => u.username === username);

ย ย if (user) {

ย ย ย ย req.session.userId = user.id;

ย ย ย ย req.session.username = user.username;

ย ย ย ย // Generate CSRF secret and token

ย ย ย ย if (!req.session.csrfSecret) {

ย ย ย ย ย ย req.session.csrfSecret = csrfTokens.secretSync();

ย ย ย ย }

ย ย ย ย const csrfToken = csrfTokens.create(req.session.csrfSecret);

ย ย ย ย res.json({ย 

ย ย ย ย ย ย success: true,ย 

ย ย ย ย ย ย user: { id: user.id, username: user.username, balance: user.balance },

ย ย ย ย ย ย csrfToken: csrfToken

ย ย ย ย });

ย ย } else {

ย ย ย ย res.status(401).json({ success: false, message: 'Invalid credentials' });

ย ย }

});

4. Improve session security

The sameSite: ‘lax’ setting provides additional CSRF protection by preventing cookies from being sent with most cross-site requests. This works alongside our token-based approach for defense-in-depth.

app.use(session({

ย ย secret: 'your-secure-secret-key',

ย ย resave: false,

ย ย saveUninitialized: false,

ย ย cookie: {ย 

ย ย ย ย secure: false, // Set to true in production with HTTPS

ย ย ย ย httpOnly: true,

ย ย ย ย sameSite: 'lax',

ย ย ย ย maxAge: 24 * 60 * 60 * 1000

ย ย }

}));

5. Restrict CORS to your frontend only

Instead of allowing all origins, we now only allow requests from our React app. This prevents attackers from making requests from their own domains, though CSRF tokens are still the primary protection mechanism.

app.use(cors({

ย ย origin: 'http://localhost:3000',

ย ย credentials: true

}));

Key Changes to the React Application

1. Add CSRF token state and fetching

The React app needs to manage CSRF tokens and fetch them when needed. We’ll store the current token in state and provide a function to get fresh tokens from the server.

const [csrfToken, setCsrfToken] = useState('');

const fetchCSRFToken = async () => {

ย ย try {

ย ย ย ย const response = await fetch('http://localhost:8080/api/csrf-token', {

ย ย ย ย ย ย credentials: 'include',

ย ย ย ย ย ย headers: { 'Content-Type': 'application/json' }

ย ย ย ย });

ย ย ย ย if (response.ok) {

ย ย ย ย ย ย const data = await response.json();

ย ย ย ย ย ย setCsrfToken(data.csrfToken);

ย ย ย ย }

ย ย } catch (error) {

ย ย ย ย console.log('Failed to fetch CSRF token');

ย ย }

};

useEffect(() => {

ย ย checkAuthStatus();

ย ย fetchCSRFToken();

}, []);

2. Update your login handler to store the CSRF token

When users log in, we’ll store the CSRF token that comes back in the response. This saves us from making an additional API call after login.

setCsrfToken(data.csrfToken);

3. Include CSRF tokens in protected requests

All state-changing requests (transfers, account deletion) must include the CSRF token in the X-CSRF-Token header. The server will validate this token against the user’s session.

headers: {

ย ย 'Content-Type': 'application/json',

ย ย 'X-CSRF-Token': csrfToken

}

4. Add visual indication of CSRF protection

This gives users (and developers) confidence that CSRF protection is active. In a real application, you might hide this or only show it in development mode.

{csrfToken && (

ย ย <div className="csrf-info">

ย ย ย ย ๐Ÿ”’ CSRF Protection Active - Token: {csrfToken.substring(0, 10)}...

ย ย </div>

)}

What These Changes Accomplish

These changes create a robust CSRF protection system where each user session has a unique secret, and all state-changing operations require a valid token generated from that secret. Attackers cannot obtain these tokens from their malicious sites due to the same-origin policy, effectively blocking CSRF attacks while maintaining a seamless experience for legitimate users.

Now, when you test the attack again, it will fail with “Invalid CSRF token” because the attacker’s site cannot access the session-specific token needed to make valid requests.

Why Automated CSRF Testing Matters 

CSRF vulnerabilities are among the most exploited attack vectors in web applications today. Manual testing often misses subtle implementation flaws, and it’s easy to create security gaps while attempting to balance usability with protection.

A common mistake when implementing CSRF protection is creating configurations that appear to work but have critical weaknesses. For example, using predictable token generation, failing to validate tokens properly across all endpoints, or inadvertently exposing token values in logs or URLs. Additionally, research shows that improperly implemented CSRF protection can give developers false confidence while still leaving applications vulnerable.

Security Considerations

Let’s walk through the possibilities of how CSRF protection might be circumvented:

  • A malicious script that loads before CSRF tokens are set might bypass protection
  • Session fixation attacks combined with CSRF can amplify damage
  • Overly permissive CORS policies may weaken CSRF defenses

While manual code reviews and static testing can catch some of these vulnerabilities, automated, dynamic security testing is the true way to identify complex CSRF attack vectors and ensure your defenses work as intended. One of the most trusted security platforms for dynamic application security testing (DAST) is StackHawk. By using StackHawk to test for vulnerabilities in your applications and APIs, you get:

  • Early detection of CSRF vulnerabilities during development
  • Automated testing of complex attack scenarios you might not think to test manually
  • Clear remediation guidance with specific examples of vulnerable endpoints
  • Verification that CSRF protections don’t break legitimate functionality

Testing and Debugging 

Modern browsers and developer tools provide excellent capabilities for debugging CSRF protection:

  • Console: CSRF token validation errors appear in the browser console
  • Network Tab: Shows CSRF headers and tokens in request details
  • Security Tab: Some browsers show CSRF-related security information

By integrating CSRF security testing into your development workflow, you can ensure that your application is protected against request forgery attacks while maintaining a smooth user experience.

Where StackHawk Comes In

StackHawk is a DAST tool that gives your team the ability to actively run security testing as part of their traditional software testing workflows, while giving AppSec teams the peace of mind of controlled and security-tested applications in production. It includes specific CSRF vulnerability scanners to test your implementation, including:

  • Absence of Anti-CSRF Tokens: Testing if forms and state-changing endpoints are missing CSRF token protection entirely
  • Anti-CSRF Tokens Scanner: Checking if your application is vulnerable to cross-site request forgery attacks despite having tokens implemented
  • Unauthorized action execution: Identifying scenarios where attackers could perform actions on behalf of users without their consent
  • Session hijacking vulnerabilities: Testing for CSRF attacks that could compromise user sessions and lead to account takeover

So let’s get you set up!

From the very top of the process, youโ€™re going to want to sign up for an account if you donโ€™t have one already.

Then, from there, click into the Applications screen and click Add Application:

Applications 52 findings dashboard

Next, you will fill out an Application Name, Environment, and URL. Once filled out, click Next:

Since we will be testing a RESTful API, on the next page, we will choose our Application Type as โ€œDynamic Web Application/Single Page Applicationโ€:

Application Type details page

This screen, of course, will be up to you to decide. For our current use case, we are using a single-page application (as seen earlier), but StackHawk provides a wide range of options to ensure any application type is covered.

Once we select our Application Type, we can then choose the API type, which is REST by default (this is at your discretion and depends on your application type).

Finally, weโ€™ll click Create App:

We are then presented with what is likely the most crucial step in the setup: obtaining the YAML file to provide to your application. This will serve as the jumping point for StackHawk to do its thing. Note: After copying or downloading the stackhawk.yml file, please place it in the root directory of your project.

Placing that into our root:

Run HawkScan

Next, we can go ahead with testing our application. In a terminal pointing to the root of our project, we will run HawkScan using the following command:

hawk scan

Explore The Initial Findings

Once the tests are complete, the terminal will contain some information about any vulnerabilities found. Below, we can see that StackHawk has found a few CSRF vulnerabilities within my code that are present on multiple paths.

To explore this further, we will click on the test link at the bottom of the output. This will take us into the StackHawk platform to explore further.

After clicking on the link, we can now view the test results in a nicely formatted display. Next, we will click on the Anti CSRF Tokens Scanner entry.

Within this entry, we can see an Overview and Resources that can help us with fixing this vulnerability, as well as the Request and Response that the API returned on the right side of the screen. Above this, you will also see a Validate button, which will display a cURL command with the exact HTTP request used to expose the vulnerability.

Understanding and Fixing CSRF Security Issues

When StackHawk identifies CSRF vulnerabilities, you’ll see detailed findings that indicate which paths are affected and provide potential remediation techniques (as shown in the image above). Using the methods in this guide or the CSRF remediation advice provided by StackHawk for the vulnerability, you can then make the necessary adjustments to your code and configuration. Once fixed, ensure that you stop your web servers and redeploy the latest code. Next, we can ensure that the fix implemented resolves our CSRF misconfiguration issues.

Confirm the fix!

With the latest code deployed, letโ€™s confirm the fix in StackHawk. To do this, we will click the “Rescan Findings” button in StackHawk.

Then, we will see a modal containing the โ€œhawk rescanโ€ command that includes the correct Scan ID. Youโ€™ll run this command in the same terminal where you ran the initial set of tests.

Rescan findings window

In the output, you will again see any vulnerabilities that were found during the scan. In this case, youโ€™ll see that the CSRF vulnerabilities are no longer showing. Clicking on the link at the bottom of the terminal output, you can confirm that the CSRF vulnerabilities have now been added to the Fixed Findings from Previous Scan, confirming that the vulnerability has been successfully fixed and has passed any vulnerability tests.

With that, weโ€™ve successfully remedied and retested our application to ensure its safety from potential CSRF attacks.

Conclusion

With average data breach costs exceeding $4.4 million and regulatory fines reaching 4% of global revenue, CSRF represents far more than a technical oversight; it’s a strategic business risk that can undermine organizational trust and operational integrity. Conduct due diligence by securing your application with DAST tools like StackHawk. 

Want to ensure your React application is secure from CSRF attacks and other vulnerabilities? Sign up for StackHawk today for a free 14-day trial to automatically test your CSRF configuration and detect potential vulnerabilities.

If you’d like to gain an understanding of CSRF protection in frameworks other than React, check out one of our other guides:

  • Vue CSRF โ€“ CSRF protection in the progressive JavaScript framework
  • Angular CSRF โ€“ CSRF mitigation in the TypeScript web application framework
  • TypeScript CSRF โ€“ CSRF protection in the typed JavaScript superset

Watch a Demo Blog Banner

FEATURED POSTS

Security Testing for the Modern Dev Team

See how StackHawk makes web application and API security part of software delivery.

Watch a Demo

Subscribe to Our Newsletter

Keep up with all of the hottest news from the Hawkโ€™s nest.

"*" indicates required fields

More Hawksome Posts