Beginner 5 min HTTP 403

403 Forbidden Due to CSRF Token Mismatch

Симптомы

- Form submissions return 403 Forbidden with a message like "CSRF verification failed. Request aborted." or "Invalid CSRF token"
- The form page loads and displays correctly, but all POST requests are rejected regardless of the form data
- The error appears after leaving the browser tab open for a long period (session or token expiry) then submitting
- AJAX fetch or XHR POST requests from JavaScript fail with 403 even though GET requests to the same domain work fine
- Clearing cookies and logging in again temporarily resolves the issue until the session or token expires again

Первопричины

  • CSRF token not included in the AJAX request headers — JavaScript fetch calls omit the `X-CSRFToken` or equivalent header
  • Session expired on the server, invalidating the associated CSRF token that was embedded in the HTML form when the page was first rendered
  • Domain mismatch between where the token was issued (e.g., example.com) and where the form submits (e.g., api.example.com) — treated as cross-site
  • SameSite=Strict or SameSite=Lax cookie attribute preventing the CSRF cookie from being sent on cross-origin form submissions or redirects
  • Caching layer (CDN, reverse proxy, or browser) serving a stale page with an already-expired CSRF token embedded in the HTML

Диагностика

**Step 1 — Check the request headers in browser DevTools**

Open Network tab → submit the form → click the failing request → inspect Request Headers:
```
# Good: token is present
X-CSRFToken: abc123...
Cookie: csrftoken=abc123...

# Bad: token missing
# No X-CSRFToken header at all
```

**Step 2 — Verify the token matches between cookie and header/form field**

The value in the `csrftoken` cookie must match the value sent in the request. A mismatch means the session expired or the page is cached:
```javascript
// In browser console
document.cookie.split(';').find(c => c.includes('csrftoken'))
// Compare with the form field value:
document.querySelector('[name=csrfmiddlewaretoken]').value
```

**Step 3 — Test with curl to confirm the server-side check**

```bash
# Fetch the page to get a fresh CSRF token
TOKEN=$(curl -c /tmp/cookies.txt https://example.com/form/ \
| grep -o 'csrfmiddlewaretoken.*value="[^"]*"' \
| grep -o 'value="[^"]*"' | cut -d'"' -f2)

# Submit with matching token and cookie
curl -b /tmp/cookies.txt -X POST https://example.com/submit/ \
-H "X-CSRFToken: $TOKEN" \
-d 'field=value'
```

**Step 4 — Check Django CSRF_TRUSTED_ORIGINS setting (Django 4.0+)**

```python
# settings.py — must include all origins that submit forms
CSRF_TRUSTED_ORIGINS = [
'https://example.com',
'https://www.example.com',
'https://api.example.com', # If submitting cross-subdomain
]
```

**Step 5 — Inspect CSRF cookie attributes**

```bash
curl -I https://example.com/
# Look for Set-Cookie header:
# Set-Cookie: csrftoken=abc; SameSite=Lax; Secure
# SameSite=Strict will block cross-site form submissions
```

Решение

**Fix 1: Include CSRF token in AJAX requests (Django)**

```javascript
// Read the token from the cookie
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}

const csrfToken = getCookie('csrftoken');

// Include in every mutating request
await fetch('/api/submit/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken, // Required by Django
},
body: JSON.stringify(data),
});
```

**Fix 2: Set up CSRF for all axios requests globally**

```javascript
import axios from 'axios';

// Configure once at app startup
axios.defaults.xsrfCookieName = 'csrftoken'; // Django default
axios.defaults.xsrfHeaderName = 'X-CSRFToken';
axios.defaults.withCredentials = true;
```

**Fix 3: For SPAs — use CSRF_COOKIE_SAMESITE = 'Lax' and ensure cookies are sent**

```python
# settings.py
CSRF_COOKIE_SAMESITE = 'Lax' # Default; use 'None' for cross-site (requires HTTPS)
CSRF_COOKIE_SECURE = True # Required when SameSite=None
CSRF_TRUSTED_ORIGINS = ['https://app.example.com']
SESSION_COOKIE_SAMESITE = 'Lax'
```

**Fix 4: Extend session lifetime to reduce token expiry issues**

```python
# settings.py
SESSION_COOKIE_AGE = 86400 * 7 # 7 days (default: 2 weeks)
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
```

**Fix 5: For API-only endpoints that don't need CSRF (use authentication instead)**

```python
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator

@method_decorator(csrf_exempt, name='dispatch')
class MyAPIView(View):
# Only exempt if you're using JWT/API key auth instead
pass
```

Профилактика

- **Use token-based auth (JWT) for pure API endpoints** — CSRF protection is for cookie-based session auth; REST APIs using `Authorization: Bearer` headers are not vulnerable to CSRF attacks
- **Set Cache-Control: no-store on pages with CSRF forms** — this prevents shared caches from serving stale pages with expired tokens to different users
- **Configure CSRF_TRUSTED_ORIGINS on day one** — adding every origin that legitimately submits forms prevents mismatch errors during cross-subdomain deployments
- **Test CSRF flows in staging** by simulating session expiry: load the form, expire the session manually, then submit to confirm the error message is helpful and the token refresh works

Связанные коды состояния

Связанные термины