Tutorial

How to Implement CSRF Double Submit Cookies

Implement CSRF protection with double-submit cookies so a cross-site request lacks the token, and the server rejects it.

On this page

  1. Introduction
  2. Generate a token
  3. Set in cookie and header
  4. Compare on POST
  5. Rotate per session
  6. SameSite too
  7. Test CSRF
  8. Common pitfalls
  9. Conclusion

Introduction

The hardest part of block cross-site requests is not writing the code — it is deciding what the code is allowed to reject. A CSRF defense setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in CSRF defense, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.

CSRF defense earns its place when block cross-site requests is the bottleneck the user feels. Before that, it is overhead that slows the common case to protect a rare one. Measure the rejection rate and the tail latency, then decide whether the guard is worth its cost. A guard that never fires is a guard you can remove; a guard that fires constantly is a signal that the producer contract is wrong, not that the guard is too strict.

The sections below walk through the concrete decisions: where to put the csrf defense check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.

Generate a token

Generate a token is the first decision because it determines what the rest of the CSRF defense pipeline can assume. A wrong choice here turns into block cross-site requests under load, and the invariant you thought CSRF defense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Generate a token is the one most teams skip: drive csrf defense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

const token = crypto.randomUUID();
res.cookie('csrf', token, { sameSite: 'strict' });
if (req.cookies.csrf !== req.headers['x-csrf']) throw 403;

The javascript above is the shape to ship for block cross-site requests. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.

The mechanism behind Set in cookie and header is straightforward in CSRF defense, but the consequences ripple. A wrong choice here turns into block cross-site requests under load, and the invariant you thought CSRF defense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Set in cookie and header is the one most teams skip: drive csrf defense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Compare on POST

Compare on POST is where most CSRF defense implementations leak performance. A wrong choice here turns into block cross-site requests under load, and the invariant you thought CSRF defense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Compare on POST is the one most teams skip: drive csrf defense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

test('rejects missing token', () => {
  expect(() => check({ cookies: {}, headers: {} })).toThrow();
});

Pin the behavior with this test. A CSRF defense setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.

Rotate per session

The hard part of Rotate per session is not the happy path — it is what happens when csrf defense fails halfway. A wrong choice here turns into block cross-site requests under load, and the invariant you thought CSRF defense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Rotate per session is the one most teams skip: drive csrf defense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

SameSite too

SameSite too should be observable without a debugger. A wrong choice here turns into block cross-site requests under load, and the invariant you thought CSRF defense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for SameSite too is the one most teams skip: drive csrf defense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Test CSRF

Test CSRF is a trade, not a winner. A wrong choice here turns into block cross-site requests under load, and the invariant you thought CSRF defense guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Test CSRF is the one most teams skip: drive csrf defense to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Common pitfalls

The common pitfalls in block cross-site requests all reduce to one mistake: treating CSRF defense as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.

  • Skipping the csrf defense boundary so block cross-site requests enters unvalidated.
  • Assuming CSRF defense is fast without measuring the tail latency under load.
  • Letting block cross-site requests fail silently instead of failing loudly with a metric.
  • Testing only the happy path so the csrf defense failure surfaces in production.
  • Sharing mutable state across requests so one bad input poisons the next.

Conclusion

Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: CSRF defense fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.

Self-check: write a test that drives csrf defense to the failing condition and assert the system degrades gracefully. Then block cross-site requests is a property you maintain, not a feature you hope for.

Sources: OWASP CSRF, SameSite

FAQ

Why double submit?
The cookie is auto-sent, the header is not. A cross-site form cannot set the header.
SameSite enough?
In modern browsers yes. Add the token for legacy and defense in depth.

Related Examples