DEV Community

Shivam
Shivam

Posted on

CSRF Protection That Actually Works in a Next.js 16 + React 19 App

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 SameSite cookies
  • 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
  • Lax still 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:

  1. Generate a CSRF token server-side
  2. Store it in a cookie
  3. Require every POST, PUT, PATCH, and DELETE request to include the same token in a custom header
  4. 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
Enter fullscreen mode Exit fullscreen mode

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)