Building Secure Forms: CSRF Protection in React
Cross-Site Request Forgery (CSRF) is a subtle but devastating attack: an attacker tricks a logged-in user into making an unwanted request on your site (e.g., transferring money, deleting account, changing email). The attack works because the user's authentication cookie is automatically included in the request, making it appear legitimate. React developers must implement CSRF tokens in forms, use SameSite cookie flags, and validate the Origin header. This guide covers CSRF mechanics, how to implement tokens in React, and how to configure your backend to prevent forged requests.
How CSRF Works
Imagine you are logged into your bank's website. While still logged in, you click a link in an email from a phishing site. That site contains hidden HTML:
<!-- Attacker's website (attacker.com) -->
<img src="https://bank.example.com/transfer?toAccount=attacker&amount=1000" />
Your browser automatically includes your authentication cookie (because you are still logged into bank.example.com) and makes the request. The bank's server sees a valid cookie and processes the transfer. You never intended to make this request—the attacker did.
The problem is that the bank relied solely on the cookie to verify the request. The attacker tricked your browser into making a request on your behalf.
CSRF Token Approach
The solution is a CSRF token: a unique, unpredictable value that the server generates and verifies. The token must be included in every state-changing request (POST, PUT, DELETE, PATCH). Because the attacker's website cannot read the token (due to same-origin policy), it cannot forge the request.
Backend: Generate and Validate Tokens
// Backend (Express)
import crypto from "crypto";
import cookieParser from "cookie-parser";
import session from "express-session";
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: true
}));
// Middleware to generate CSRF token on GET requests
app.use((req, res, next) => {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString("hex");
}
next();
});
// Endpoint to retrieve the CSRF token (called by React on mount)
app.get("/api/csrf-token", (req, res) => {
res.json({ csrfToken: req.session.csrfToken });
});
// Middleware to validate CSRF token on state-changing requests
function validateCsrfToken(req, res, next) {
const tokenFromBody = req.body._csrf || req.headers["x-csrf-token"];
const tokenFromSession = req.session.csrfToken;
if (!tokenFromBody || tokenFromBody !== tokenFromSession) {
return res.status(403).json({ error: "CSRF token invalid or missing" });
}
next();
}
// Protected routes
app.post("/api/transfer", validateCsrfToken, async (req, res) => {
const { toAccount, amount } = req.body;
// Process transfer only if CSRF token was valid
await processTransfer(req.user.id, toAccount, amount);
res.json({ success: true });
});
app.delete("/api/account", validateCsrfToken, async (req, res) => {
// Delete account only if CSRF token was valid
await deleteUser(req.user.id);
res.json({ success: true });
});
React: Fetch and Include Token
import React, { useEffect, useState } from "react";
function TransferForm() {
const [csrfToken, setCsrfToken] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Fetch CSRF token on component mount
useEffect(() => {
async function fetchToken() {
const response = await fetch("https://api.example.com/api/csrf-token", {
credentials: "include" // Include session cookie
});
const data = await response.json();
setCsrfToken(data.csrfToken);
}
fetchToken();
}, []);
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
setError("");
const formData = new FormData(e.target);
const payload = {
toAccount: formData.get("toAccount"),
amount: formData.get("amount"),
_csrf: csrfToken // Include CSRF token in request body
};
try {
const response = await fetch("https://api.example.com/api/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload)
});
if (response.ok) {
alert("Transfer successful");
e.target.reset();
} else {
const errorData = await response.json();
setError(errorData.error || "Transfer failed");
}
} catch (err) {
setError("Network error: " + err.message);
} finally {
setLoading(false);
}
}
return (
form onSubmit={handleSubmit}>
<h2>Transfer Funds</h2>
{error && <p className="error">{error}</p>}
<input type="text" name="toAccount" placeholder="Recipient Account" required />
<input type="number" name="amount" placeholder="Amount" required />
<button type="submit" disabled={loading || !csrfToken}>
{loading ? "Processing..." : "Transfer"}
</button>
/form>
);
}
export default TransferForm;
Alternatively, pass the token in a custom header:
async function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const response = await fetch("https://api.example.com/api/transfer", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken // Custom header
},
credentials: "include",
body: JSON.stringify({
toAccount: formData.get("toAccount"),
amount: formData.get("amount")
})
});
}
SameSite Cookie Flag
The SameSite flag on cookies provides automatic CSRF protection without requiring tokens. Setting sameSite=strict prevents cookies from being sent in cross-origin requests:
// Backend
res.cookie("sessionToken", token, {
httpOnly: true,
secure: true,
sameSite: "strict", // Prevents CSRF: cookie not sent from attacker.com
maxAge: 15 * 60 * 1000
});
With sameSite=strict, the hidden image request on attacker.com does NOT include the session cookie, so the bank's server cannot authenticate the request. Problem solved.
However, sameSite=strict can break legitimate cross-origin requests (e.g., embedded forms). Use sameSite=lax for a middle ground: cookies are sent for top-level navigation but not for hidden cross-origin requests.
| SameSite Value | Cookies in Cross-Origin Requests? | Protection |
|---|---|---|
| strict | No (not even navigation) | Maximum protection |
| lax | Top-level navigation only | Balanced |
| none | Yes (requires Secure flag) | No CSRF protection |
Double-Submit Cookie Pattern
If you cannot use server-side sessions, use the double-submit cookie pattern: store the CSRF token in both a cookie (set by server) and a request header/body (sent by React). The server verifies they match:
// Backend: Set token in cookie
app.get("/", (req, res) => {
const token = crypto.randomBytes(32).toString("hex");
res.cookie("csrfToken", token, { sameSite: "lax", secure: true });
res.send("<html>...</html>");
});
// Backend: Validate that header token matches cookie token
function validateCsrfToken(req, res, next) {
const tokenFromHeader = req.headers["x-csrf-token"];
const tokenFromCookie = req.cookies.csrfToken;
if (tokenFromHeader !== tokenFromCookie) {
return res.status(403).json({ error: "CSRF token mismatch" });
}
next();
}
// React: Read token from cookie and include in header
function getCSRFTokenFromCookie() {
const cookies = document.cookie.split("; ");
for (let cookie of cookies) {
const [name, value] = cookie.split("=");
if (name === "csrfToken") return decodeURIComponent(value);
}
return null;
}
async function handleSubmit(e) {
e.preventDefault();
const csrfToken = getCSRFTokenFromCookie();
const response = await fetch("https://api.example.com/api/transfer", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken // Send as header
},
credentials: "include",
body: JSON.stringify(formData)
});
}
The attacker cannot read the cookie due to same-origin policy, so they cannot forge the header. Protection achieved.
Origin and Referer Header Validation
Additional layer: validate the Origin header. The Origin header indicates the domain making the request and cannot be spoofed in cross-origin requests:
// Backend middleware
function validateOrigin(req, res, next) {
const origin = req.headers.origin;
const allowedOrigins = ["https://myapp.com", "https://www.myapp.com"];
if (!allowedOrigins.includes(origin)) {
return res.status(403).json({ error: "CSRF: Invalid origin" });
}
next();
}
app.post("/api/transfer", validateOrigin, validateCsrfToken, async (req, res) => {
// Process transfer
});
Note: Origin is sent for POST requests from the browser, but developers can configure it in some scenarios. CSRF tokens are more reliable.
Form Best Practices
// Safe form component
function PaymentForm() {
const [csrfToken, setCsrfToken] = useState("");
const [formData, setFormData] = useState({ amount: "", currency: "usd" });
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
// Fetch CSRF token
fetch("/api/csrf-token", { credentials: "include" })
.then(r => r.json())
.then(d => setCsrfToken(d.csrfToken));
}, []);
function handleChange(e) {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
}
async function handleSubmit(e) {
e.preventDefault();
setSubmitted(true);
// Validate form client-side
if (!formData.amount || parseFloat(formData.amount) <= 0) {
alert("Invalid amount");
return;
}
try {
const response = await fetch("/api/payment", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
credentials: "include",
body: JSON.stringify(formData)
});
if (!response.ok) throw new Error("Payment failed");
alert("Payment successful");
setFormData({ amount: "", currency: "usd" });
} catch (error) {
alert("Error: " + error.message);
} finally {
setSubmitted(false);
}
}
return (
form onSubmit={handleSubmit}>
<input
type="number"
name="amount"
value={formData.amount}
onChange={handleChange}
placeholder="Amount"
required
/>
<select name="currency" value={formData.currency} onChange={handleChange}>
<option value="usd">USD</option>
<option value="eur">EUR</option>
</select>
<button type="submit" disabled={submitted || !csrfToken}>
{submitted ? "Processing..." : "Pay"}
</button>
/form>
);
}
Key Takeaways
- CSRF attacks forge requests using the user's session cookie.
- Use CSRF tokens (unique per session, verified server-side) to prevent forged requests.
- Set SameSite cookies (strict or lax) as an additional layer of protection.
- Validate the Origin header for POST requests.
- Always validate forms server-side, never trust client-side validation alone.
Frequently Asked Questions
Do I need CSRF tokens if I use SameSite=strict cookies?
SameSite=strict provides strong protection, but tokens are a defense-in-depth layer. Use both if possible. SameSite has broader browser support now, but tokens are not harmful and work everywhere.
How often should I rotate CSRF tokens?
Rotate on every page load or state-change to maximize security. Rotating per-session is acceptable for most applications. Rotating per-request can cause issues with browser back-buttons and cached pages.
Does CSRF affect API tokens (like Bearer tokens in the Authorization header)?
No. Bearer tokens in the Authorization header are not included in cross-origin requests (same-origin policy). However, if you accidentally accept Bearer tokens in cookies, CSRF becomes possible. Keep tokens in headers or httpOnly cookies, not in regular cookies.
What if my frontend and backend are on different domains?
CSRF tokens still work with CORS. Ensure your backend sets CORS headers and validates CSRF tokens. The token must be fetched from your backend (same-origin), then included in cross-origin requests.
Can I use a package like express-csurf?
Yes. express-csurf automates token generation and validation:
import csrf from "csurf";
app.use(csrf());
app.get("/", (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
app.post("/api/transfer", (req, res, next) => {
// csrf() middleware validates automatically
// Proceed if token is valid
next();
});