SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Apr 4, 2026·Security·SecureStartKit Team·Updated May 9, 2026

Secure 'use cache' in Next.js 16: No User Data Leaks

Next.js 16's 'use cache' is easy to misuse. Cache the wrong thing and User A sees User B's data. The three directives explained safely.

Summarize with AI

On this page

  • Table of contents
  • Why caching is a security primitive
  • The three cache directives and what each one trusts
  • "use cache" (default, shared)
  • "use cache: remote" (shared, durable)
  • "use cache: private" (per-user, ephemeral)
  • The cookies and headers rule that prevents most leaks
  • cacheLife: how long secrets stay in your cache
  • Tag invalidation: cacheTag, revalidateTag, updateTag
  • revalidateTag (now requires a profile argument)
  • updateTag (Server Actions only, immediate)
  • Real SaaS caching patterns that don't leak
  • Marketing pages: cache aggressively, no auth involved
  • Blog posts: static with tag invalidation
  • Dashboard: dynamic shell, cached subtrees only when safe
  • Server Actions: invalidate tags, redirect afterward
  • Five mistakes that leak user data
  • Next.js cache CVEs you should know about
  • CVE-2024-46982: pages router cache poisoning (CVSS 7.5) [7]
  • CVE-2025-49826: ISR/CDN 204 cache poisoning
  • CVE-2025-57752: image optimization cache deception
  • What this means for your Next.js 16 app

On this page

  • Table of contents
  • Why caching is a security primitive
  • The three cache directives and what each one trusts
  • "use cache" (default, shared)
  • "use cache: remote" (shared, durable)
  • "use cache: private" (per-user, ephemeral)
  • The cookies and headers rule that prevents most leaks
  • cacheLife: how long secrets stay in your cache
  • Tag invalidation: cacheTag, revalidateTag, updateTag
  • revalidateTag (now requires a profile argument)
  • updateTag (Server Actions only, immediate)
  • Real SaaS caching patterns that don't leak
  • Marketing pages: cache aggressively, no auth involved
  • Blog posts: static with tag invalidation
  • Dashboard: dynamic shell, cached subtrees only when safe
  • Server Actions: invalidate tags, redirect afterward
  • Five mistakes that leak user data
  • Next.js cache CVEs you should know about
  • CVE-2024-46982: pages router cache poisoning (CVSS 7.5) [7]
  • CVE-2025-49826: ISR/CDN 204 cache poisoning
  • CVE-2025-57752: image optimization cache deception
  • What this means for your Next.js 16 app

Secure caching in Next.js 16 means picking the right directive for the data you're storing. Pick the wrong one and a server-rendered page caches one user's session data and serves it to the next visitor. Three directives ship in the framework, three different trust models, and only one of them is safe for per-user content. The bug rarely shows up in development. It shows up in production, intermittently, weeks after deploy.

This guide covers the three "use cache" directives, the rules that keep them from leaking user data, and the patterns that hold up under load in a real SaaS [1].

TL;DR:

  • Three directives, three trust models: "use cache" (shared, in-memory), "use cache: remote" (shared, durable), "use cache: private" (per-user, browser-only and still experimental as of 16.2) [1][2].
  • Per-user data ONLY in private: Only "use cache: private" is safe for personalized content. Putting authenticated data in a shared cache is how cross-user leaks happen.
  • No cookies, headers, or searchParams in shared caches: Calling cookies() or headers() inside "use cache" or "use cache: remote" throws a build error by design [2].
  • Tag invalidation, not path: cacheTag plus updateTag (Server Actions only) gives scoped invalidation. revalidateTag now requires a cacheLife profile as a second argument [6].
  • Cache poisoning is a real CVE class: CVE-2024-46982 (CVSS 7.5) hit the pages router in 2024, and a related ISR/CDN bug landed as CVE-2025-49826 in 2025 [7]. Stay patched.

Table of contents

  • Why caching is a security primitive
  • The three cache directives and what each one trusts
  • The cookies and headers rule that prevents most leaks
  • cacheLife: how long secrets stay in your cache
  • Tag invalidation: cacheTag, revalidateTag, updateTag
  • Real SaaS caching patterns that don't leak
  • Five mistakes that leak user data
  • Next.js cache CVEs you should know about
  • What this means for your Next.js 16 app

Why caching is a security primitive

Caching changes who sees what. That's the whole feature. A cache hit takes the same response that was computed for one request and reuses it for another. When the response contains shared data (a marketing page, a public blog post, a product catalog), reuse is fine. When the response contains user-scoped data (a dashboard, a notifications feed, an order list), reuse is a breach.

Most caching tutorials cover this implicitly. They show you how to cache, not what's safe to cache. In Next.js 14 the framework cached aggressively by default and developers spent debugging time figuring out how to opt out. In Next.js 16 the model flipped: nothing is cached unless you explicitly opt in [3]. That's good for security because the unsafe default went away. The remaining footgun is choosing the wrong directive for the data you're caching.

The three directives draw three boundaries: shared and in-memory, shared and durable, or per-user and ephemeral. The boundary determines what data is safe to put inside.

The three cache directives and what each one trusts

Next.js 16 ships three cache directives [1][2]. Each draws a different boundary around what it stores and who it serves the result to.

DirectiveStorageShared between users?cookies/headers/searchParams allowed?Production-ready
"use cache"In-memory LRU on the serverYesNoYes
"use cache: remote"Configurable cache handler (Redis, KV, etc.)YesNoYes
"use cache: private"Browser memory onlyNo (per-user)YesExperimental [2]

"use cache" (default, shared)

The standard directive. Cached results live in an in-memory LRU on the server. In serverless environments this cache is lost on cold starts, so entries may be recomputed more often than you expect.

Use it for content that's the same for every visitor: marketing pages, blog posts, pricing tables, public docs.

async function getBlogPost(slug: string) {
  'use cache'
  return getPostBySlug(slug)
}

The crucial property: every visitor sees the same cached output. If your function returns user-specific data and you cache it here, that data goes to other users.

"use cache: remote" (shared, durable)

Same trust model as "use cache", but the storage is a remote cache handler (Redis, Vercel KV, custom) so entries survive cold starts and serverless instances [1]. All users still share the same entries.

async function getProductPrice(id: string, currency: string) {
  'use cache: remote'
  cacheLife({ expire: 300 })
  return db.products.getPrice(id, currency)
}

Pick remote over default when you need cache hits across function instances. The tradeoff is small extra latency per lookup.

"use cache: private" (per-user, ephemeral)

The only directive that can read cookies(), headers(), and searchParams inside a cached scope [2]. Results never touch the server cache. They live in the browser's memory only and don't survive a page reload.

import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'

async function getRecommendations(productId: string) {
  'use cache: private'
  cacheTag(`recommendations-${productId}`)
  cacheLife({ stale: 60 })

  const sessionId = (await cookies()).get('session-id')?.value
  return db.recommendations.findMany({ where: { productId, sessionId } })
}

One important caveat from the docs: as of Next.js 16.2.6, "use cache: private" is still marked experimental and not recommended for production [2]. It depends on a runtime prefetching feature that isn't yet stable. Plan for that. If you need per-user caching today and can't ship experimental flags, the safer move is to keep per-user data uncached and use a <Suspense> boundary to stream it.

The cookies and headers rule that prevents most leaks

The single most important rule about Next.js 16 caching: cookies(), headers(), and searchParams cannot be called inside "use cache" or "use cache: remote" [2]. The framework throws a build error if you try.

This isn't a stylistic choice. Those values change per request. If a cached function reads them, the cache key would have to include the entire cookie or header, which defeats the point. So the framework refuses.

// Throws a build error
async function getData() {
  'use cache'
  const token = (await cookies()).get('token')
  return db.query({ where: { token } })
}

// Works: pass the value as an argument
async function getData(token: string) {
  'use cache'
  return db.query({ where: { token } })
}

The fix is to read the runtime value outside the cached scope and pass it in as an argument. The cache key becomes the argument value, which is deterministic and safe.

There's a second-order security implication. If your cached function takes a userId as an argument and you accidentally pass the same userId for two different sessions (say, the function gets called from a server component that hasn't yet authenticated the request), the cache will return one user's data for another user's session. Always read the auth token first, validate it server-side, then pass the validated user ID into cached helpers.

cacheLife: how long secrets stay in your cache

By default, cached entries don't expire on a clock. The cacheLife function sets a lifetime [4].

import { cacheLife } from 'next/cache'

async function getStaticContent() {
  'use cache'
  cacheLife('max')      // Cache indefinitely until manual invalidation
  return fetchContent()
}

async function getBlogPosts() {
  'use cache'
  cacheLife('days')     // Revalidate after 1 day
  return getAllPosts()
}

async function getPricing() {
  'use cache'
  cacheLife('hours')    // Revalidate after 1 hour
  return fetchPricing()
}

Define custom profiles in next.config.ts with three timing properties:

  • stale: how long the client can use a cached value before checking back
  • revalidate: how often the server refreshes in the background
  • expire: maximum age before the entry is dropped
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    blog: { stale: 3600, revalidate: 900, expire: 86400 },
    pricing: { stale: 60, revalidate: 30, expire: 3600 },
  },
}

export default nextConfig

The security angle on cacheLife: the 'max' profile keeps an entry around indefinitely. If you ever cache something derived from authenticated data using a shared directive (which you shouldn't, but mistakes happen), 'max' means that data lives forever in your cache. Using a short expire value is a defense in depth: even if the directive choice is wrong, the bad entry rotates out before too many users see it. Short profiles aren't a substitute for picking the right directive, but they shrink the blast radius when something slips.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

Tag invalidation: cacheTag, revalidateTag, updateTag

Tag-based invalidation is the precise tool for invalidating specific cache entries when data changes [1].

import { cacheTag } from 'next/cache'

async function getPost(slug: string) {
  'use cache'
  cacheTag(`post-${slug}`)
  return getPostBySlug(slug)
}

Two functions invalidate tags in Next.js 16, with different semantics.

revalidateTag (now requires a profile argument)

revalidateTag invalidates a tag with stale-while-revalidate semantics. The single-argument form is now deprecated. As of Next.js 16, you must pass a cacheLife profile as the second argument [6].

'use server'

import { revalidateTag } from 'next/cache'

export async function updatePost(slug: string, data: PostInput) {
  await db.posts.update({ where: { slug }, data })
  revalidateTag(`post-${slug}`, 'max')
}

The recommended profile is 'max': it marks the entry as stale and serves the stale value while fresh data fetches in the background. Use revalidateTag in Server Actions, Route Handlers, and webhooks.

updateTag (Server Actions only, immediate)

New in Next.js 16. updateTag immediately expires a tag. The next request waits for fresh data instead of seeing stale content [5]. Use it when the user expects to see their own change reflected straight away (read-your-own-writes).

'use server'

import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const post = await db.post.create({ data: { title } })

  updateTag('posts')
  updateTag(`post-${post.id}`)

  redirect(`/posts/${post.id}`)
}

updateTag only works in Server Actions. Calling it from a Route Handler throws an error. For Route Handlers and webhooks, stick with revalidateTag(tag, 'max') [5].

The security angle: scoped tags beat blunt invalidation. Calling revalidatePath('/blog') flushes every cached entry under /blog. If one of those entries was wrongly serving cached user data, the flush hides the bug instead of surfacing it. Tag-based invalidation only touches the entries you've explicitly tagged, which makes scope obvious and review easier.

Real SaaS caching patterns that don't leak

Here's how the three directives map to typical pages in a SaaS application. These patterns come from building SecureStartKit, where marketing pages serve thousands of visitors while the dashboard handles authenticated, per-user data.

Marketing pages: cache aggressively, no auth involved

Your landing page, pricing page, and blog don't change per user. Cache the entire page with a long lifetime:

// app/(marketing)/page.tsx
import { cacheLife } from 'next/cache'

export default async function HomePage() {
  'use cache'
  cacheLife('days')

  return (
    <main>
      <Hero />
      <Features />
      <Pricing />
      <Testimonials />
    </main>
  )
}

There's no authenticated data anywhere in this tree, so a shared cache is correct. Even a security headers misconfiguration on this page wouldn't expose user data: there's no user data to expose.

Blog posts: static with tag invalidation

Blog posts fit "use cache" combined with generateStaticParams. Pre-render at build time, invalidate individual posts when content changes:

// app/(marketing)/blog/[slug]/page.tsx
import { cacheLife, cacheTag } from 'next/cache'

export async function generateStaticParams() {
  const posts = getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  'use cache'
  const { slug } = await params
  cacheTag(`blog-${slug}`)
  cacheLife('days')

  const post = getPostBySlug(slug)
  if (!post) notFound()

  return <Article post={post} />
}

When a blog post updates, the Server Action calls updateTag(\blog-$`)` and the next visitor sees fresh content.

Dashboard: dynamic shell, cached subtrees only when safe

The dashboard layout (sidebar, navigation, branding) is the same for every authenticated user. The content inside is per-user. Don't cache the page; use Suspense to separate the static shell from dynamic data:

// app/(dashboard)/page.tsx
import { Suspense } from 'react'

export default function DashboardPage() {
  return (
    <div>
      <DashboardHeader />
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  )
}

async function UserStats() {
  const user = await getAuthenticatedUser()
  if (!user) return null

  const stats = await getUserStats(user.id)
  return <StatsView stats={stats} />
}

UserStats and RecentActivity read user-specific data and don't get a "use cache" directive at all. The framework renders them on every request, which is fine. That's exactly what <Suspense> is for.

If you want to cache the inner data for performance, the only safe option is "use cache: private" (still experimental) keyed on the validated user ID. Don't try to cache it with a shared directive even if you "scope by userId argument": one bad call site eventually leaks a stale entry to another user.

Server Actions: invalidate tags, redirect afterward

When a user updates their profile, completes a purchase, or posts a comment, invalidate the tags that depend on the changed data. Use updateTag for Server Actions [5]:

'use server'

import { updateTag, revalidatePath } from 'next/cache'
import { profileUpdateSchema } from '@/lib/schemas/user'

export async function updateProfile(formData: FormData) {
  const parsed = profileUpdateSchema.safeParse(Object.fromEntries(formData))
  if (!parsed.success) return { error: parsed.error.flatten() }

  await db.user.update({ where: { id: parsed.data.id }, data: parsed.data })

  updateTag(`user-${parsed.data.id}`)
  revalidatePath('/settings')
  return { success: true }
}

Two things to notice: the input is validated with Zod before the database write, and the tag is keyed by the validated user ID, not by anything from the raw request. That ordering is what keeps the cache key untainted.

Five mistakes that leak user data

These are the patterns that bite people most often when adopting Cache Components.

1. Caching authenticated pages with "use cache"

If a page calls getAuthenticatedUser() (which reads cookies) and then caches its return value with the default directive, the build fails. If the auth check happens elsewhere and you only cache the data fetch, make sure the cache key includes the validated user ID. A shared cache scoped by user ID is fragile: anyone who can pass a different user ID into the function reads someone else's data.

The safer pattern: don't cache authenticated reads. Let <Suspense> handle the loading state. Cache the marketing-side and let the dashboard render dynamically.

2. Forgetting to enable cacheComponents

The "use cache" directive does nothing without cacheComponents: true in next.config.ts. If your pages still render dynamically after adding the directive, check the config first.

3. Skipping signature verification on cache invalidation webhooks

If you expose a /api/revalidate endpoint that calls revalidateTag based on URL params, anyone who finds the endpoint can flush any tag they want. Cache invalidation isn't usually a data-leak vector, but it is an availability vector and a good way to grind your origin to dust. Treat the webhook like a Stripe webhook: require a signed token, verify on every call, rate limit it.

4. Long cacheLife('max') on data that should expire

'max' keeps an entry around until you explicitly invalidate it. Combined with a tag invalidation bug elsewhere in the system, a wrong cached value can sit there for weeks before someone notices. Use bounded profiles ('hours', 'days') for anything with a real freshness requirement. Reserve 'max' for content you're certain is invariant.

5. Mixing "use cache: private" with non-validated cookies

Reading a cookie inside "use cache: private" is allowed, but the value is still attacker-controlled. If you key a database query off a session cookie without validating it server-side first, you're effectively taking input from the browser and using it as a privileged identifier. Always validate the session against your auth provider before using its claims as a cache key. The Supabase auth flow shows the right ordering.

Next.js cache CVEs you should know about

Cache poisoning has hit the framework directly in the past 18 months. Three CVEs are worth knowing if you operate Next.js in production.

CVE-2024-46982: pages router cache poisoning (CVSS 7.5) [7]

Affected versions 13.5.1 through 14.2.9. By sending a crafted HTTP request, an attacker could coerce Next.js into caching a route that was meant to remain dynamic, and inject Cache-Control headers that some upstream CDNs would also cache. Fixed in 13.5.7 and 14.2.10. App Router is unaffected, but if you run a hybrid project with any pages router routes, stay on patched versions.

CVE-2025-49826: ISR/CDN 204 cache poisoning

Affected Next.js 15.1.0 through 15.1.7 in next start or standalone mode, when ISR or SSR routes sit behind a CDN that caches HTTP 204 responses. The bug allowed denial-of-service through cache poisoning. Fixed in 15.1.8.

CVE-2025-57752: image optimization cache deception

A cache deception flaw in the Image Optimization endpoint that could lead to authentication bypass and data leakage through a mismatch between how cached images are stored and how they're fetched.

The pattern across all three: cache layers are rich attack surface. Audit your CDN configuration, keep Next.js patched, and treat any custom cache handler as a potential security boundary. The secure SaaS launch checklist covers the framework-level checks alongside CSP and headers, with the interactive tool running the same audit pass.

What this means for your Next.js 16 app

The directive you pick is a security decision. "use cache" and "use cache: remote" are shared by every visitor, so they're for content that doesn't depend on who's looking. "use cache: private" is the only directive that can read cookies and serve different results per user, but it's still experimental as of 16.2 and lives in browser memory only.

Tag-based invalidation with cacheTag plus updateTag (Server Actions) or revalidateTag(tag, 'max') (Route Handlers and webhooks) gives precise control without flushing more than you mean to. The single-argument revalidateTag(tag) form is deprecated. Update your call sites.

Two adjacent reads pair well with this one. The Next.js security hardening checklist covers the surrounding hardening surface (CSP, headers, RLS, validation), and why security-first matters for SaaS explains the architectural philosophy behind the backend-only data access pattern that prevents most caching mistakes from being catastrophic in the first place. If your cached function never touches authenticated data, no directive choice can leak it.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Directives: use cache— nextjs.org
  2. Directives: use cache: private— nextjs.org
  3. Next.js 16— nextjs.org
  4. Functions: cacheLife— nextjs.org
  5. Functions: updateTag— nextjs.org
  6. Functions: revalidateTag— nextjs.org
  7. Next.js Cache Poisoning (CVE-2024-46982)— github.com/vercel/next.js

Related Posts

Mar 16, 2026·Security

Next.js Security Checklist: 12 Steps [2026]

A production security checklist for Next.js apps. Covers HTTP headers, CSP, environment variables, Server Actions, RLS, webhook verification, and more.

May 21, 2026·Security

Supabase OAuth, Magic Links, MFA in Next.js [2026]

Secure OAuth, magic links, and MFA in Supabase + Next.js. PKCE flow, redirect URL allowlists, AAL2 step-up, and 5 implementation failure modes.

May 18, 2026·Security

Pre-Launch Security Audit: 12 Checks That Matter Most [2026]

Pre-launch security audit for Next.js + Supabase: 12 highest-impact checks of 30, in audit order, with triage rules. Run weeks before launch.