Skip to main content

CSRF Attacks: How to Protect React Apps

A Cross-Site Request Forgery (CSRF) attack exploits the browser's willingness to send requests to your origin from any other origin. Even though same-origin policy blocks JavaScript from reading responses, it does not block the browser from sending authenticated requests (cookies included) to your API from an attacker's page. CSRF tokens are the standard defense: your server generates a unique token for each user session, the React form includes it, and the server validates it before processing state-changing requests. Tokens are simple, proven, and required for production React apps.

CSRF is rated in the OWASP Top 10 because the attack is trivial to execute and affects nearly every web application (OWASP, 2021). This article teaches you the mechanics and the defense.

How a CSRF Attack Works

Assume you are logged into your bank at https://mybank.com. You open a new tab and visit https://evil.com. Evil.com's HTML contains:

<!-- Attacker's HTML on evil.com -->
<form action="https://mybank.com/transfer" method="POST" style="display:none;">
<input name="to_account" value="[email protected]">
<input name="amount" value="5000">
</form>

<script>
// Automatically submit the form when the page loads
document.querySelector('form').submit();
</script>

Here is what happens:

  1. Your browser loads evil.com and sees the hidden form and script.
  2. The script calls .submit() on the form, sending a POST request to https://mybank.com/transfer.
  3. Because you are still logged into mybank.com in another tab, your browser includes your authentication cookies with the request (the Set-Cookie from mybank.com is still in the jar).
  4. The bank's server receives a POST request with your valid session cookie, so it thinks you initiated the transfer.
  5. The bank deducts 5000 from your account and credits the attacker.

The attack works because:

  • The browser sends cookies automatically (SOP does not prevent this).
  • The form submission bypasses SOP (forms can POST to any origin).
  • The bank does not verify that the request came from its own domain.

This is CSRF: the attacker tricked your browser into making a request on your behalf, using your authentication.

CSRF Tokens: The Defense

A CSRF token is a unique, unguessable string generated by the server and embedded in the form. The server validates the token before processing the request. Here is the flow:

  1. User visits the bank app (https://mybank.com).
  2. Server generates a CSRF token (e.g., abc123def456) and sends it in the HTML form or as a cookie.
  3. React form includes the token in a hidden input field.
  4. User submits the form.
  5. React sends the form data and token to the server.
  6. Server compares the token in the request to the token in the session. If they match, process the request. If not, reject it.

An attacker on evil.com cannot forge the token because:

  • The token is generated on the server and tied to the user's session.
  • Even if evil.com can see the page HTML, the attacker cannot read the token (same-origin policy blocks this if the token is in a cookie or only on the server side).
  • Each request requires a fresh, valid token.

Implementing CSRF Protection in React

Here is a complete example of a React form with CSRF protection:

// Step 1: Fetch the CSRF token from the server
import React, { useEffect, useState } from 'react';

export default function TransferForm() {
const [csrfToken, setCsrfToken] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
// Fetch the CSRF token from your server
fetch('/api/csrf-token', { method: 'GET', credentials: 'include' })
.then(res => res.json())
.then(data => setCsrfToken(data.token))
.catch(err => setError(err.message));
}, []);

const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);

// Step 2: Include the CSRF token in the request
const formData = new FormData(e.target);
formData.append('csrf_token', csrfToken);

try {
const response = await fetch('/api/transfer', {
method: 'POST',
body: formData,
credentials: 'include', // Important: send cookies
});

const result = await response.json();
if (response.ok) {
alert('Transfer successful');
} else {
setError(result.error || 'Transfer failed');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};

if (error) return <div>Error: {error}</div>;
if (!csrfToken) return <div>Loading token...</div>;

return (
<form onSubmit={handleSubmit}>
<input name="to_account" type="email" required />
<input name="amount" type="number" required />
{/* CSRF token is included automatically via FormData */}
<button type="submit" disabled={loading}>
{loading ? 'Processing...' : 'Transfer'}
</button>
</form>
);
}

Server-side validation (example in Node.js with Express):

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

app.use(express.urlencoded({ extended: false }));
app.use(session({
secret: 'your-secret-key',
resave: false,
saveUninitialized: true,
}));

// Step 1: Generate and send CSRF token
app.get('/api/csrf-token', (req, res) => {
// Generate a unique token tied to the session
const token = crypto.randomBytes(32).toString('hex');
req.session.csrfToken = token;
res.json({ token });
});

// Step 2: Validate CSRF token on state-changing requests
app.post('/api/transfer', (req, res) => {
const tokenFromRequest = req.body.csrf_token;
const tokenFromSession = req.session.csrfToken;

// CSRF check: tokens must match
if (!tokenFromRequest || tokenFromRequest !== tokenFromSession) {
return res.status(403).json({ error: 'CSRF token invalid' });
}

// Token is valid, process the transfer
console.log('Processing transfer to', req.body.to_account);
res.json({ success: true });
});

app.listen(3001);

When the form from evil.com tries to submit, it sends no CSRF token (or the wrong one), and the server rejects the request. Only requests from mybank.com's own forms, which include the server-generated token, are accepted.

Token Rotation and Regeneration

For higher security, regenerate the token after each successful request:

// Server: regenerate token after use
app.post('/api/transfer', (req, res) => {
if (req.body.csrf_token !== req.session.csrfToken) {
return res.status(403).json({ error: 'Invalid token' });
}

// Process the request
// ...

// Regenerate token for the next request
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
res.json({ success: true });
});

This limits the window in which a leaked token can be exploited. However, for most applications, a single token per session is sufficient.

Key Takeaways

  • CSRF attacks exploit the browser's automatic cookie inclusion; same-origin policy does not prevent this.
  • CSRF tokens are unique, unguessable strings generated by the server and validated before processing state-changing requests.
  • Always include CSRF tokens in POST, PUT, DELETE, and PATCH requests; GET requests do not modify state.
  • Token regeneration after each use provides additional security if you handle sensitive transactions.
  • Use credentials: 'include' in fetch() to ensure cookies are sent; the server will validate the token.

Frequently Asked Questions

Why not use the Referer header to check if a request came from the same site?

The Referer header can be spoofed or omitted (especially in privacy-respecting browser configurations). It is not a reliable defense. CSRF tokens are cryptographically strong and cannot be guessed. Always prefer tokens.

Do I need CSRF protection for API calls from my React app if I use the Authorization header?

If your API requires an Authorization: Bearer <jwt> header instead of cookies, you have some protection because the browser will not automatically send the token (it is not a cookie). However, CSRF tokens are still recommended for extra defense and for form submissions, which do use cookies.

Can an attacker steal the CSRF token from my React form?

If the token is in the HTML, an attacker on a different origin cannot read it directly (SOP prevents this). However, if your React form has a reflected XSS vulnerability, an attacker could inject JavaScript and steal the token. CSRF tokens defend against CSRF, but you must also defend against XSS (article 6 covers CSP).

How long should a CSRF token last?

Tie the token to the user's session. Regenerate it when the user logs in or out. You can also regenerate after each request for maximum security, but this is usually unnecessary.

Further Reading