CSRF Protection in Web Applications: Tokens, SameSite Cookies, and Double Submit

Cross-Site Request Forgery (CSRF) attacks exploit the trust a web application has in an authenticated user's browser. When a user visits a malicious site while logged into your application, that malicious site can forge requests that appear legitimate because the browser automatically includes cookies.
Synchronizer Token Pattern
The synchronizer token pattern remains the most widely deployed CSRF defense. The server generates a unique, unpredictable token for each session and requires it on every state-changing request. The token is embedded in forms or sent as a request header, and the server validates it against the session-stored value.
<!-- Server-rendered form with CSRF token -->
<form method="POST" action="/api/transfer">
<input type="hidden" name="csrf_token" value="a1b2c3d4e5f6...">
<input type="number" name="amount">
<input type="text" name="recipient">
<button type="submit">Send</button>
</form>
// Express.js CSRF token generation with csurf (adapted)
import { doubleCsrf } from 'csrf-csrf';
const { generateToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET!,
cookieName: 'csrf-token',
cookieOptions: {
httpOnly: true,
sameSite: 'lax',
secure: true,
},
size: 64,
});
app.get('/api/csrf-token', (req, res) => {
res.json({ csrfToken: generateToken(req, res) });
});
app.post('/api/transfer', doubleCsrfProtection, (req, res) => {
// Process the transfer
});
The token must be cryptographically random, regenerated on login, and have a limited lifetime. Never accept the token in query strings or URL parameters where it could leak via referrer headers.
SameSite Cookie Attribute
The SameSite attribute on cookies provides browser-enforced CSRF protection without server-side token validation. It tells the browser when to include cookies with cross-origin requests.
Set-Cookie: sessionId=abc123; SameSite=Lax; Secure; HttpOnly
SameSite=Strict: Cookies are never sent on cross-site requests. Strongest protection but breaks legitimate use cases like navigating from an email link to an authenticated page.SameSite=Lax: Cookies are sent on top-level navigations (GET requests) but not on subresource requests or POST forms from other origins. This is the recommended default for most applications.SameSite=None: Cookies are sent on all cross-origin requests. Required for third-party integrations but must be combined with Secure attribute and explicit token-based CSRF protection.
SameSite is not a complete replacement for CSRF tokens. It is not supported in all legacy browsers, and subdomains can set cookies for the parent domain. Use SameSite as a defense layer in addition to token validation rather than instead of it.
Double Submit Cookie Pattern
The double submit cookie pattern eliminates server-side token storage by sending the CSRF token in both a cookie and a request header. The server validates that the two values match.
// Double submit cookie implementation
function generateCsrfCookie(): string {
const token = crypto.randomBytes(32).toString('base64url');
return token;
}
function doubleSubmitMiddleware(req: Request, res: Response, next: NextFunction) {
const cookieToken = req.cookies['csrf-double'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || !headerToken) {
return res.status(403).json({ error: 'Missing CSRF token' });
}
if (!crypto.timingSafeEqual(
Buffer.from(cookieToken),
Buffer.from(headerToken)
)) {
return res.status(403).json({ error: 'CSRF token mismatch' });
}
next();
}
// Set cookie on page load
app.get('/app/*', (req, res) => {
res.cookie('csrf-double', generateCsrfCookie(), {
httpOnly: true,
secure: true,
sameSite: 'lax',
});
// Render application...
});
Timing-safe comparison is critical — using === for token comparison introduces a timing side channel that attackers can exploit to brute-force tokens character by character.
Framework-Specific Implementations
Modern frameworks include CSRF protection built in, but the default configurations vary.
Rails: protect_from_forgery with: :exception is enabled by default. Rails uses per-form tokens stored in the session.
# Rails CSRF protection
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
end
Django: CSRF middleware is included in MIDDLEWARE by default. Templates use the {% csrf_token %} template tag.
# Django: CSRF in a form view
from django.shortcuts import render
def transfer_view(request):
if request.method == 'POST':
form = TransferForm(request.POST)
if form.is_valid():
# Process transfer — CSRF is validated by middleware
pass
return render(request, 'transfer.html', {'form': form})
Next.js (App Router): Server Actions include built-in CSRF protection through a combination of SameSite cookies and cryptographic tokens embedded in the action URL.
// Next.js Server Action — CSRF protected by default
'use server';
export async function transferFunds(formData: FormData) {
const amount = formData.get('amount');
// CSRF validation is handled by the framework
}
APIs Without Cookie Authentication
Applications using token-based authentication (Authorization header with Bearer tokens) are inherently immune to CSRF because cookies are not used for authentication. The Authorization header is not automatically attached by the browser on cross-origin requests. However, if the application also accepts cookie-based authentication for browser clients, CSRF protection is still required.
Multi-Step Form Security
For multi-step forms like checkout wizards, generate a new CSRF token for each step or validate the original token across all steps. Token reuse across steps is acceptable as long as the token is scoped to the user's session and invalidated on completion.
// Invalidate CSRF token after successful form submission
app.post('/api/checkout/confirm', csrfProtection, async (req, res) => {
await processOrder(req.body);
// Regenerate CSRF token to prevent replay
res.clearCookie('csrf-token');
const newToken = generateToken(req, res);
res.json({ success: true, csrfToken: newToken });
});
Testing CSRF Protections
Validate your CSRF implementation with automated tests that simulate cross-origin requests and verify they are rejected:
import request from 'supertest';
it('rejects POST without CSRF token', async () => {
await request(app)
.post('/api/transfer')
.set('Origin', 'https://malicious-site.com')
.send({ amount: 1000, recipient: 'attacker' })
.expect(403);
});
it('accepts POST with valid CSRF token', async () => {
const tokenRes = await request(app).get('/api/csrf-token');
const csrfToken = tokenRes.body.csrfToken;
await request(app)
.post('/api/transfer')
.set('X-CSRF-Token', csrfToken)
.send({ amount: 100, recipient: 'user2' })
.expect(200);
});
A layered CSRF defense combining SameSite cookies, synchronizer tokens, and proper CORS configuration provides robust protection against cross-origin request forgery across all clients and browser versions. Our <a href="/services/web-development">web development team</a> implements CSRF protection as a standard component of every project. Contact SoniNow to review your security controls.
Related Insights

API Rate Limiting Strategies: Token Bucket, Leaky Bucket, and Sliding Window
A guide to implementing API rate limiting including token bucket, leaky bucket, sliding window, and distributed rate limiting with Redis for production APIs.

Authentication Patterns in Modern Web Apps: JWT, OAuth, and Session Management
A guide to authentication patterns for web applications including JWT implementation, OAuth 2.0 flows, refresh tokens, session management, and secure storage.

Authentication Patterns in Modern Web Apps: JWT, Sessions, and Passkeys
A guide to modern authentication patterns comparing JWT, session-based auth, and passkeys including implementation strategies, security considerations, and user experience.