Your Next.js Page Might Be Caching One User's Data and Serving It to Everyone Else
This is the kind of bug that goes from "works perfectly in every test" to "actively leaking one customer's data to another customer" without a single code change in between, purely because of how caching and personalizat
This is the kind of bug that goes from "works perfectly in every test" to "actively leaking one customer's data to another customer" without a single code change in between, purely because of how caching and personalization interact in a way that isn't obvious from reading the component code.
The Setup
// app/dashboard/page.tsx
export default async function DashboardPage() {
const session = await getServerSession();
const user = await User.findById(session.userId);
return (
<div>
<h1>Welcome back, {user.name}</h1>
<p>Account balance: {user.balance}</p>
</div>
);
}
This looks obviously personalized, it reads the session, looks up the specific user, renders their specific data. There's nothing here that looks like it needs special caching considerations. That assumption is exactly where this goes wrong.
Why Next.js Can Get This Wrong
Next.js's App Router tries to statically render as much as possible by default, for good performance reasons. A page gets treated as static (cacheable, shared across all visitors) unless something in it signals that it needs to be dynamic (rendered fresh per-request). Specific APIs, reading cookies(), headers(), or searchParams directly, are what tell Next.js a page actually depends on request-specific data and needs dynamic rendering.
The problem is that getServerSession() in the example above may, depending on exactly how it's implemented and which auth library version you're on, read the session cookie in a way that doesn't always propagate that dynamic signal up to the page itself, especially if the cookie read happens inside a library function several layers removed from the page component, rather than a direct cookies() call visible in the page's own code. When that signal doesn't propagate correctly, Next.js can determine the page is eligible for static optimization, render it once during the build or on first request, and then serve that exact same rendered HTML, including the first user's name and balance, to every subsequent visitor who hits that route, until the cache naturally expires or gets invalidated.
Why This Is Specifically Dangerous, Not Just Buggy
A caching bug that shows stale content is annoying. A caching bug that shows a different user's personal data is a real data exposure incident, and the specific danger here is that it doesn't look like a caching bug from the outside, it looks like the application is broken for that specific user, showing someone else's name and balance, which can easily get reported and triaged as "a display bug" rather than correctly identified as a serious caching and session isolation issue.
Why This Survives Normal Testing
Testing a dashboard page almost always means logging in as a specific test user and confirming their own correct data renders. That test passes regardless of whether the response got cached and shared or genuinely rendered fresh for that specific request, because from a single test account's perspective, the result looks identical either way. The bug only becomes visible with two separate user sessions hitting the same route in a way that reveals the cached response is actually shared, which isn't a scenario most manual or automated testing naturally covers.
The Fix: Force Dynamic Rendering Explicitly for Anything Personalized
Don't rely on an auth helper's internal implementation to correctly signal dynamic rendering. State it explicitly for any route serving per-user data:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const session = await getServerSession();
const user = await User.findById(session.userId);
return (
<div>
<h1>Welcome back, {user.name}</h1>
<p>Account balance: {user.balance}</p>
</div>
);
}
The export const dynamic = 'force-dynamic' line removes any ambiguity about whether the page qualifies for static optimization. It costs the performance benefit of static rendering for that specific route, which is the correct tradeoff for anything genuinely personalized, that cost is real but small compared to the alternative.
An equally valid alternative is calling cookies() or headers() directly inside the page itself, rather than only inside a wrapped auth helper function, since a direct call from the page component is what reliably signals dynamic rendering without depending on how a library internally implements its own session check.
import { cookies } from 'next/headers';
export default async function DashboardPage() {
cookies(); // explicit, direct call signals dynamic rendering regardless of what getServerSession does internally
const session = await getServerSession();
// ...
}
The Rule
Any page serving per-user or per-request data needs to explicitly and verifiably opt out of static rendering, not just assume an auth library's internals do that correctly on its behalf. This matters more, not less, as auth libraries and caching layers evolve between versions, since a signal that correctly propagated in one version can silently stop doing so after an update you didn't closely audit.
I treat every authenticated dashboard route as explicitly force-dynamic by default in the Next.js dashboard templates I build, specifically because the cost of guessing wrong here is a real user data leak, not just a stale page.
Get the templates: https://pixelanas.gumroad.com
Go check your own authenticated pages for an explicit dynamic export or a direct cookies()/headers() call, rather than relying on an auth helper to signal it correctly on your behalf. If you're not sure, this is worth verifying with two separate logged-in sessions today, not assuming. Drop what you find in the comments.
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.