What is Cross-Site Request Forgery?
Imagine you receive a letter that appears to be from your bank, complete with your account details, asking you to sign a document. The signature looks legitimate because it's your real account information, but the letter is actually from an impostor trying to use your identity to authorize transactions. This is essentially how Cross-Site Request Forgery (CSRF) works in web applications - it's an attack that tricks your browser into making requests to a website using your legitimate logged-in session, but the requests are actually initiated by a malicious actor.
Understanding CSRF Through Real-World Analogies
The Coffee Shop Loyalty Card Analogy
Think of CSRF like this: You have a loyalty card for your favorite coffee shop. One day, someone creates a fake coffee shop that looks identical to your regular one. When you try to use your loyalty card there, the fake shop can actually charge purchases to your real loyalty card account because they're using your legitimate credentials. The fake shop (malicious website) is using your real authentication (loyalty card) to perform actions on your behalf at the real coffee shop (legitimate website).
The Bank Check Analogy
Another way to understand CSRF is through the concept of pre-signed checks. Imagine you sign a blank check, and someone else fills in the amount and recipient. With CSRF, attackers create "pre-signed" requests using your authenticated session, essentially filling in that blank check with malicious actions.
The Mechanics of CSRF Attacks
The Technical Flow
Let's break down how a CSRF attack typically works:
Step 1: The Setup
You log into your bank's website and get an authenticated session cookie. Your browser stores this cookie and sends it with every request to the bank's website.
Step 2: The Trap
While still logged into your bank, you visit what seems to be an innocent website. This site contains hidden malicious code:
<!-- Malicious site's hidden form -->
<form id="hidden-form" action="https://your-bank.com/transfer" method="POST">
<input type="hidden" name="recipient" value="attacker-account"/>
<input type="hidden" name="amount" value="1000"/>
</form>
<script>
// Automatically submit the form when the page loads
document.getElementById('hidden-form').submit();
</script>
Step 3: The Attack
The malicious site automatically submits a form to your bank's website. Because you're logged in, your browser includes your authenticated session cookie with this request. The bank's server sees a legitimate request with valid authentication, and processes the transfer.
CSRF Attacks in the Wild
The Gmail CSRF Incident
In 2007, Gmail faced a CSRF vulnerability that could allow attackers to modify victims' email settings, potentially forwarding copies of all emails to the attacker's address. This attack worked because the settings-change request didn't require any special confirmation beyond the user's existing authentication.
The Twitter CSRF Vulnerability
Twitter once had a CSRF vulnerability that allowed attackers to force authenticated users to follow accounts or post tweets without their knowledge. This demonstrated how CSRF can be used for social engineering and spreading malicious content.
Building a CSRF Defense System
The Double Submit Cookie Pattern
Think of this like a two-part ticket system at an event. You get two matching ticket stubs - one you keep (the cookie) and one you present at each checkpoint (the token in the request). Both parts must match for the request to be valid.
Backend Implementation
// Generate CSRF tokens on the server
const crypto = require('crypto');
function generateCSRFTokens() {
// Generate a random token
const rawToken = crypto.randomBytes(32).toString('hex');
// Create encrypted version
const encryptedToken = crypto.createHmac('sha256', SECRET_KEY)
.update(rawToken)
.digest('hex');
return {
decryptedToken: rawToken,
encryptedToken: encryptedToken
};
}
// Set up the cookies
app.use((req, res, next) => {
const { decryptedToken, encryptedToken } = generateCSRFTokens();
// Set the decrypted token as a regular cookie
res.cookie('csrf_token', decryptedToken);
// Set the encrypted token as a secure, HTTP-only cookie
res.cookie('csrf_token_secure', encryptedToken, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
next();
});
Frontend Implementation
// Add CSRF token to all requests
fetch('/api/data', {
method: 'POST',
headers: {
'CSRF-Token': getCookie('csrf_token') // Get decrypted token from cookie
},
body: JSON.stringify(data)
});
Request Validation
// Middleware to validate CSRF tokens
function validateCSRF(req, res, next) {
const decryptedToken = req.headers['csrf-token'];
const encryptedToken = req.cookies.csrf_token_secure;
// Verify tokens match after encryption
const calculatedToken = crypto.createHmac('sha256', SECRET_KEY)
.update(decryptedToken)
.digest('hex');
if (calculatedToken !== encryptedToken) {
return res.status(403).json({ error: 'Invalid CSRF token' });
}
next();
}
User-Visible Security Measures
Visual Security Patterns
Beyond technical measures, applications can implement visual security patterns to help users identify legitimate pages:
Security Images
Many banks show users a pre-selected personal image on login and transaction pages. This helps users verify they're on the legitimate site:
<div class="security-verification">
<img src="/user-security-image" alt="Personal Security Image">
<p>Verify this is your security image before proceeding</p>
</div>
Transaction Confirmation Steps
Implementing additional confirmation steps for sensitive actions:
// Two-step transaction confirmation
app.post('/api/transfer', validateCSRF, async (req, res) => {
// Generate unique confirmation code
const confirmCode = generateConfirmationCode();
// Send to user's verified email/phone
await sendConfirmation(user.email, confirmCode);
// Store pending transaction
await storePendingTransaction(req.body, confirmCode);
res.json({ status: 'awaiting_confirmation' });
});
CSRF Prevention Best Practices
Essential Security Measures
Use SameSite Cookies
Configure cookies with appropriate SameSite attributes to prevent cross-site usage:
Set-Cookie: sessionId=abc123; SameSite=Strict; Secure; HttpOnly
Implement Proper Token Validation
Always validate CSRF tokens using secure comparison methods:
// Use timing-safe comparison
const crypto = require('crypto');
function safeCompare(a, b) {
return crypto.timingSafeEqual(
Buffer.from(a, 'hex'),
Buffer.from(b, 'hex')
);
}
Handle Token Rotation
Regularly rotate CSRF tokens to minimize the impact of token exposure:
// Implement token rotation
function rotateCSRFToken(req, res) {
const { decryptedToken, encryptedToken } = generateCSRFTokens();
// Set new tokens
res.cookie('csrf_token', decryptedToken, { maxAge: 3600000 }); // 1 hour
res.cookie('csrf_token_secure', encryptedToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000
});
}
Testing Your CSRF Protection
Verification Methods
Implement comprehensive tests to verify your CSRF protection:
Basic Token Validation Test
describe('CSRF Protection', () => {
it('should reject requests without CSRF token', async () => {
const response = await request(app)
.post('/api/sensitive-action')
.send({ data: 'test' });
expect(response.status).toBe(403);
});
it('should accept requests with valid CSRF token', async () => {
const token = await getValidCSRFToken();
const response = await request(app)
.post('/api/sensitive-action')
.set('CSRF-Token', token)
.send({ data: 'test' });
expect(response.status).toBe(200);
});
});
Token Rotation Test
it('should rotate CSRF tokens after use', async () => {
const token = await getValidCSRFToken();
// First request should succeed
await request(app)
.post('/api/sensitive-action')
.set('CSRF-Token', token)
.expect(200);
// Same token should not work again
await request(app)
.post('/api/sensitive-action')
.set('CSRF-Token', token)
.expect(403);
});
Going Beyond Basic CSRF Protection
Additional Security Layers
Multi-Factor Authentication
Implement MFA for sensitive actions to provide an additional layer of security:
async function requireMFA(req, res, next) {
if (isSensitiveAction(req.path)) {
const mfaToken = req.headers['mfa-token'];
if (!await validateMFAToken(req.user, mfaToken)) {
return res.status(401).json({
error: 'MFA required for this action'
});
}
}
next();
}
Request Origin Validation
Add additional checks for request origins:
function validateOrigin(req, res, next) {
const origin = req.get('Origin') || req.get('Referer');
if (!allowedOrigins.includes(origin)) {
return res.status(403).json({
error: 'Invalid request origin'
});
}
next();
}