Skip to content
Moteeb Asadimoteeb.asad@gmail.com

Blog

My Next.js middleware trusted a cookie anyone could write

To make page loads faster, my middleware read the logged-in user from a 60-second cookie instead of calling Supabase. The cookie was plain text, so anyone could write their own with any role. How I replaced it with getClaims(), and the trade-offs.

2 min read#nextjs#supabase#security#middleware

I found a security problem in my own Next.js middleware. I wrote it to make the app faster.

The plan

My Supabase server is in Singapore. The middleware checked auth with that server on every request, so login, logout and every page change waited for a round trip.

So I saved the logged-in user in a cookie for 60 seconds. The middleware read the cookie instead of calling Supabase each time.

It worked. Pages got faster.

middleware.ts (before, simplified)
const cached = request.cookies.get("auth_cache")?.value;
 
if (cached) {
  // Trusted as-is: whatever the cookie says, the middleware believes
  const user = JSON.parse(cached);
  return checkAccess(user.role, request);
}
 
const { data } = await supabase.auth.getUser(); // round trip to Supabase
response.cookies.set("auth_cache", JSON.stringify({ id: data.user?.id, role }), {
  maxAge: 60,
});

The problem

The cookie was plain text.

Nothing signed it, so the server had no way to tell a cookie it wrote from one someone else wrote. Anyone could create their own with any role, and the middleware accepted it.

What I changed

  • Removed the cookie.
  • The middleware now uses Supabase's getClaims(). It checks the token on my server, without calling Supabase every time.
  • getCurrentUser() reads the same checked token, so page loads don't make an extra auth call.
middleware.ts (after, simplified)
const { data } = await supabase.auth.getClaims();
const claims = data?.claims;
 
if (!claims) {
  return NextResponse.redirect(new URL("/login", request.url));
}

getClaims() verifies the token's signature with Supabase's public keys. It downloads those keys once and reuses them, so most requests never leave my server. A token someone edits by hand fails the check.

The trade-offs

The first request on a new Vercel instance is slower. That instance has to download Supabase's public keys before it can check anything. After that, it reuses them.

After logout, the old token works until it expires. The token is checked locally, so the middleware doesn't know it was signed out until the token runs out. That's why important actions still check the database, which always has the current state.

Next

Roles were still stored in user metadata, which users can edit themselves. I wrote up that fix in Don't store roles in Supabase user_metadata.

The lesson

Don't trust data in a cookie your server didn't sign.

And check access again in server actions, not just in middleware. The middleware decides which pages open. It isn't the thing that keeps your data safe.

All posts