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 Vector | Description | Common Targets |
Malicious link | Hidden form submissions via GET requests | Profile updates, account settings |
Embedded form | Auto-submitting forms on malicious sites | Money transfers, password changes |
XHR request | JavaScript-based cross-origin requests | API endpoints, AJAX calls |
Image tags | Using img src to trigger GET requests | Logout 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 Vector | CSRF Vulnerable | CSRF 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
SameSite Cookie Evolution
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.
SameSite Cookie Attribute
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:
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โ:
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.
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