Understanding CSRF: Protecting Against Digital Identity Theft

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();
}