Context & Why
Picture an engineer logged into an internal AWS console or billing portal. In a second tab, they click an unvetted link from a Slack channel or phishing email. Within 200 milliseconds, their account email changes, an attacker-controlled SSH key gets added, or funds transfer out. The user never saw a confirmation prompt.
Cross-Site Request Forgery (CSRF) exploits ambient authority in web browsers. Browsers automatically attach stored credentials—such as session cookies and basic auth headers—to outgoing HTTP requests. They do this regardless of which site initiates the call.
While Chrome 80 introduced SameSite=Lax by default in February 2020, relying solely on browser defaults leaves dangerous gaps. Top-level GET navigations still transmit Lax cookies. Mobile webviews, older embedded browsers, and Safari’s ITP quirks can also handle these flags inconsistently. Attackers actively exploit these blind spots. For instance, CVE-2020-3580 in Cisco ASA demonstrated how CSRF can trigger remote code execution even inside enterprise perimeters.
Consider an unhardened endpoint that updates an account email address:
POST /api/user/email HTTP/1.1
Host: myapp.internal
Content-Type: application/x-www-form-urlencoded
Cookie: session_id=9f82a1b7c4e5d6...
[email protected]
The attacker never needs to steal or read the session_id cookie. Instead, they force the victim’s browser to execute the request on their behalf. A hostile page on https://evil-site.com triggers this with a 5-line hidden form that auto-submits on page load:
<form id="csrfForm" action="https://myapp.internal/api/user/email" method="POST">
<input type="hidden" name="email" value="[email protected]" />
</form>
<script>
document.getElementById('csrfForm').submit();
</script>
The server receives valid session cookies with the cross-origin request. Without a secondary check, it cannot separate legitimate user actions from forged ones. Stopping this exploit requires two distinct controls: deterministic cookie boundary rules and cryptographic token validation.
Installation
We will build a defense layer using Node.js, Express, and csrf-csrf (the modern HMAC-based successor to the deprecated csurf library), along with cookie-parser.
Create a dedicated workspace and install the required dependencies:
mkdir csrf-defense-lab && cd csrf-defense-lab
npm init -y
npm install express cookie-parser csrf-csrf dotenv
npm install --save-dev nodemon
This stack implements the Double Submit Cookie pattern with encrypted HMAC validation. It verifies request authenticity without forcing you to store state in Redis or a relational database.
Configuration
A solid defense combines two independent layers:
- Strict Cookie Isolation: Restricts when browsers include session cookies during cross-site requests.
- Cryptographic Request Tokens: Requires a unique, unpredictable HMAC token in a custom header. The Same-Origin Policy (SOP) prevents third-party origins from reading or forging this value.
1. Setting Up Cryptographic Secrets
Generate high-entropy 256-bit keys and store them in your local .env file:
PORT=3000
NODE_ENV=development
COOKIE_SECRET=d83f12a98e54c7b20f456123489abced98471234908123490812349081234908
CSRF_SECRET=4f7b9c1d2e3a5b6c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c
2. Implementing Middleware and Endpoint Protection
Write the application entry point in server.js:
require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const { doubleCsrf } = require('csrf-csrf');
const app = express();
const isProduction = process.env.NODE_ENV === 'production';
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
const {
invalidCsrfTokenError,
generateToken,
doubleCsrfProtection,
} = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: isProduction ? '__Host-app.csrf' : 'app.csrf',
cookieOptions: {
httpOnly: true,
sameSite: 'lax', // Use 'strict' for high-risk transactional tools
secure: isProduction, // Required for __Host- prefix in production HTTPS
path: '/',
},
size: 64,
ignoredMethods: ['GET', 'HEAD', 'OPTIONS'],
getTokenFromRequest: (req) => req.headers['x-csrf-token'] || req.body._csrf,
});
// Handshake endpoint: single-page apps call this during bootstrap
app.get('/api/csrf-token', (req, res) => {
const csrfToken = generateToken(req, res);
res.json({ csrfToken });
});
// Simulated login that issues an authenticated session cookie
app.post('/api/login', (req, res) => {
res.cookie('auth_session', 'usr_sess_9a8b7c6d5e4f3a2b1c', {
httpOnly: true,
secure: isProduction,
sameSite: 'lax',
path: '/',
maxAge: 3600000, // 1 hour
});
res.json({ status: 'authenticated', userId: 42 });
});
// State-modifying mutation route protected against CSRF
app.post('/api/user/email', doubleCsrfProtection, (req, res) => {
const { email } = req.body;
console.log(`[AUDIT] Email successfully updated to: ${email}`);
res.json({ status: 'success', email });
});
// Centralized error handler for rejected tokens
app.use((err, req, res, next) => {
if (err === invalidCsrfTokenError) {
console.warn(`[SECURITY ALERT] Rejected forged request on ${req.method} ${req.originalUrl}`);
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid or missing CSRF token.',
});
}
next(err);
});
app.listen(process.env.PORT, () => {
console.log(`Server listening on port ${process.env.PORT}`);
});
3. How the Defense Mechanisms Work Together
- The
__Host-Prefix: Under RFC 6265bis, browsers reject this cookie unless it hasSecure, lacks aDomainattribute, and usesPath=/. Subdomains cannot overwrite or hijack it. - SameSite=Lax: Blocks the cookie on cross-site form submissions and background AJAX requests. Normal top-level link clicks retain cookies, preserving search engine entry UX.
- Custom
x-csrf-tokenHeader: Single Page Apps (React, Vue, Angular) fetch the token on boot and attach it to API calls. Cross-origin attackers cannot inspect this token because the browser’s Same-Origin Policy forbids reading responses across origins.
Verification & Monitoring
Test both valid client flows and malicious forgery attempts using curl.
1. Testing the Legitimate User Workflow
Authenticate, save session cookies, fetch a token, and run an authorized update:
# 1. Log in and save session cookies to a local jar
curl -s -c cookies.txt -X POST http://localhost:3000/api/login
# 2. Fetch the CSRF token while presenting the cookie jar
CSRF_TOKEN=$(curl -s -b cookies.txt -c cookies.txt http://localhost:3000/api/csrf-token | jq -r .csrfToken)
echo "Obtained Token: $CSRF_TOKEN"
# 3. Perform the authorized mutation
curl -s -b cookies.txt \
-H "Content-Type: application/json" \
-H "x-csrf-token: $CSRF_TOKEN" \
-d '{"email":"[email protected]"}' \
http://localhost:3000/api/user/email
The server returns HTTP 200 OK with {"status":"success","email":"[email protected]"}.
2. Simulating a CSRF Attack
Submit a request with the victim’s session cookie, but omit the custom header:
curl -i -b cookies.txt \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}' \
http://localhost:3000/api/user/email
The middleware rejects the request immediately:
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{
"error": "Forbidden",
"message": "Invalid or missing CSRF token."
}
3. Production Monitoring and Alerting
Repeated 403 errors on mutation endpoints point to active brute-forcing or broken client builds. Treat them as security events.
Pipe structured JSON error logs to collectors like Vector, Fluentbit, or Datadog:
if (err === invalidCsrfTokenError) {
const securityLog = {
timestamp: new Date().toISOString(),
event: 'CSRF_VALIDATION_FAILURE',
client_ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
origin: req.headers['origin'] || null,
referer: req.headers['referer'] || null,
path: req.originalUrl,
method: req.method,
user_agent: req.headers['user-agent'],
};
console.error(JSON.stringify(securityLog));
}
Set an alert threshold in your log monitoring tool for spikes exceeding 10 failed events within 60 seconds from a single IP, or any request where the Origin header does not match your allowed domains list. Catching these patterns early keeps user accounts safe before an exploit succeeds.

