When we were building Fledgr, CSRF protection was one of those areas where most advice felt either outdated or unnecessarily complicated.
A lot of apps either:
- Rely entirely on
SameSitecookies - Add a heavy CSRF library without understanding what it’s doing
- Or skip protection altogether because “modern browsers already handle it”
None of those approaches felt great to us.
So we ended up implementing a lightweight CSRF layer that’s now used across every state-mutating route in production.
Why SameSite Alone Isn’t Enough
SameSite=Strict definitely helps.
It prevents cookies from being attached to most cross-site requests, which blocks a large class of CSRF attacks automatically.
But relying on it alone has a few problems:
- Browser behavior can vary
- Redirect edge cases still exist
- Many frameworks default to
SameSite=Lax -
Laxstill allows cookies on top-level navigations
So while SameSite is an important security layer, we never treated it as the entire solution.
The Pattern We Use: Double-Submit Cookies
The approach we settled on is the classic double-submit cookie pattern.
The flow is simple:
- Generate a CSRF token server-side
- Store it in a cookie
- Require every
POST,PUT,PATCH, andDELETErequest to include the same token in a custom header - Verify both values match on the server
```ts id="vkwx9v"
const headerToken = request.headers.get("x-csrf-token");
const cookieToken = cookies().get("csrf_token")?.value;
if (!headerToken || headerToken !== cookieToken) {
return Response.json(
{ error: "Invalid request" },
{ status: 403 }
);
}
The important detail here is the security boundary.
A malicious site can sometimes trigger a request that includes cookies automatically.
What it *can’t* do is read the CSRF cookie and inject its value into a custom header.
That separation is what makes the pattern effective.
---
## Centralising Protection with `withApi()`
One thing we wanted to avoid was developers forgetting to add CSRF checks on new routes.
So instead of manually verifying tokens everywhere, we moved the logic into a shared wrapper we call `withApi()`.
Every state-mutating route passes through it automatically.
The wrapper handles:
* CSRF verification
* Authentication resolution
* Rate limiting
* Error formatting
* Request validation
That means new endpoints are protected by default rather than depending on memory or code review.
---
## Never Use Raw `fetch()` on the Client
Another lesson we learned pretty quickly:
If developers can call raw `fetch()` directly, CSRF headers eventually become inconsistent.
Someone forgets the header.
A helper gets bypassed.
A new feature ships without protection.
So we wrapped `fetch()` inside a single client utility that automatically:
* Reads the CSRF token from the cookie
* Injects the `x-csrf-token` header
* Applies shared defaults
That utility is now the only allowed way to make authenticated API requests inside the app.
One function.
Used everywhere.
No exceptions.
---
## Why the CSRF Cookie Isn’t `HttpOnly`
This usually surprises people.
The CSRF cookie needs to be readable by JavaScript so the client utility can inject it into request headers.
That means the cookie cannot be `HttpOnly`.
This is intentional.
The security model here isn’t about hiding the token from JavaScript — it’s about preventing *cross-origin attackers* from reading it.
We also set the CSRF cookie to:
```txt id="u7n3xy"
SameSite=Strict
Secure
That gives us another protection layer on top of header validation.
What This Catches in Practice
The interesting part is that this setup has already caught several mistakes during development.
Missing headers.
Incorrect client integrations.
Routes accidentally bypassing wrappers.
Without CSRF validation, some of those mistakes could have become production vulnerabilities.
Instead, requests fail immediately and visibly during development.
Final Thoughts
CSRF protection doesn’t need to be complicated, but it does need to be deliberate.
For us, the winning combination ended up being:
- Double-submit cookies
- Strict cookie settings
- Centralised route wrappers
- A single API client utility
- Zero direct
fetch()usage for authenticated requests
It’s lightweight, easy to reason about, and fits naturally into a modern Next.js 16 + React 19 stack.
We’ve been running this approach across Fledgr in production, and so far it’s proven both reliable and hard to misuse — which is exactly what good security infrastructure should be.
Top comments (0)