How to Cache HTML for Logged-In Users Safely
This guide covers the highest-stakes pattern in Edge Compute & Dynamic Caching, within Advanced Caching Strategies & CDN Architecture. Logged-in users are often a site's most valuable visitors, and they usually get its slowest pages: authenticated HTML bypasses the CDN, so every request pays the full origin render. Caching that HTML promises the biggest TTFB improvement available — and carries the worst possible failure mode. If a personalised page is cached and served to someone else, you have leaked one user's data to another.
Safe caching for logged-in users is therefore an architecture, not a header change. The cached artefact must be provably free of per-user data; per-user data must arrive through a separate, uncached channel; and automated tests must check isolation continuously. With those in place, logged-in pages can be served at cache speed while personal data stays private.
Rapid Diagnosis
- Inventory personal data in templates. Names, emails, avatars, addresses, order data, saved items, CSRF tokens and feature flags per user — anything that differs between users.
- Check how the CDN treats authenticated requests. Many configurations bypass the cache when a session cookie is present; some cache them accidentally if headers allow.
- Check
Set-Cookieon HTML. Session refreshes on page responses both prevent caching and, if cached, would hand one user's cookie to others. - Check role-based content. Admin links or plan-specific features in the shell must be part of the key or moved to fragments.
Root Cause Analysis
1. Templates with implicit access to the user. Frameworks make the current user available everywhere, so personal data seeps into shared markup unnoticed.
2. Over-broad cache keys or none at all. Keying on full cookies fragments the cache; ignoring cookies entirely without removing personal data from the shell leaks data.
3. Tokens in markup. Per-session CSRF tokens embedded in forms make every page personal.
4. Header leakage. Set-Cookie, per-user ETags or debug headers containing user identifiers can be cached along with the body.
Step-by-Step Resolution
1. Make shell rendering structurally user-free
Render cacheable templates through a code path that does not receive the user at all — for example, a separate render function or a framework option that strips cookies from the request before rendering. Structural separation is far more reliable than discipline.
// Express example: the cacheable route never sees cookies.
app.get('/products/:id', stripAuth, async (req, res) => {
const html = await renderProductShell(req.params.id); // no req.user, no cookies
res.set('Cache-Control', 'public, s-maxage=600, stale-while-revalidate=86400');
res.set('Cache-Tag', `product-${req.params.id}`);
res.send(html);
});
function stripAuth(req, _res, next) { delete req.headers.cookie; req.user = undefined; next(); }
// trade-off: anything that previously relied on the user in this template must
// move to a fragment; some features become a little more complex to build.
Expected outcome: it is impossible for the shell to include user data, even by accident.
2. Key only on safe, low-cardinality attributes
If content differs by role or plan, derive a key from a verified claim (a signed token's role field) at the edge — never from client-modifiable values unless the content is non-sensitive.
3. Serve personal data privately
app.get('/api/me', requireAuth, (req, res) => {
res.set('Cache-Control', 'private, no-store');
res.json({ name: req.user.firstName, cartCount: req.user.cart.length });
});
// trade-off: one extra request per page view. Fetch it in parallel with the
// shell (at the edge or on the client) so it never sits on the LCP path.
4. Remove Set-Cookie from cacheable responses
Refresh session cookies on the /api/me call or another private endpoint, and configure the CDN to strip or refuse to cache responses that still carry Set-Cookie.
5. Test isolation continuously
// Run in CI and as a production synthetic check every few minutes.
const [a, b] = await Promise.all(['userA', 'userB'].map((u) =>
fetch(URL, { headers: { cookie: `session=${tokens[u]}` } }).then((r) => r.text())));
for (const marker of ['userA@example.test', 'User A']) {
if (b.includes(marker)) throw new Error(`Leak: ${marker} appeared in userB's response`);
}
// trade-off: marker checks catch known fields only; give test accounts unique
// values in every personal field so any leak is detectable.
Verification
Before enabling caching, run isolation tests against staging with caching on. After enabling it for a small share of traffic, monitor the production synthetic check and error logs, and compare TTFB p75 for authenticated traffic with and without caching. Confirm CDN logs show hits for authenticated requests on cacheable templates and never for private endpoints.
Worked Example: A Subscription News Site
A subscription publisher's subscribers (logged in) bypassed the cache entirely, with article TTFB p75 of 640ms versus 70ms for anonymous readers. Articles were the same for all subscribers except a "Hi, Alex" header, a saved-articles count and a CSRF token in the comment form. The team rendered articles without the user, moved the header details to an /api/me fragment, fetched the CSRF token when the comment box was focused, and keyed the cache on a signed "subscriber" claim so paywalled content was never served to anonymous visitors. Subscriber TTFB p75 fell to 85ms. A continuous isolation check with two synthetic accounts ran every five minutes and never fired.
Common Mistakes
- Relying on
Vary: Cookie. It fragments the cache and still leaks if any proxy ignores it. - Keying on unverified client values for access control. A paywall keyed on a client-editable cookie can be bypassed; use signed claims.
- Caching error pages with personal content. Error templates often include the user menu; ensure they are user-free too or not cached.
- No kill switch. You need a way to force bypass instantly per template.
Edge Cases
Server-side A/B assignments per user. If assignment is per user, put the arm in the key (low cardinality), not the user.
Locale from user settings. If language comes from a profile setting, derive it at the edge from a non-sensitive cookie and key on it.
Preview and draft modes. Editors viewing drafts must bypass the cache entirely; detect preview mode at the edge before any cache lookup.
Embedded user-generated content. Comments and reviews are shared content and safe to cache, but moderation removals must purge quickly.
FAQ
Is it ever acceptable to cache per-user HTML at the edge?
Only with the user identifier in the cache key and strict private semantics at every layer — at which point you gain little, because each entry is used by one person. The safer and faster approach is a shared shell plus private fragments.
Does this work with server components or streaming SSR?
Yes. Render and cache the shell (or the static segments) without the user, and stream or fetch personal segments separately. Frameworks with partial prerendering support this split directly.
How do I handle CSRF tokens?
Use the double-submit cookie pattern, SameSite cookies, or fetch a token from a private endpoint when the user starts interacting with a form. None require embedding a per-user token in cached HTML.
What should the isolation test check?
That a response fetched as user B never contains markers unique to user A, for every cacheable template, including error pages. Run it after deploys and on a schedule, because cache contents change over time.
Can the browser cache these pages too?
The shared shell can be cached by browsers with revalidation (max-age=0 with validators, or short max-age). Private endpoints should use private, no-store or short private caching as appropriate.
Related
- Personalizing cached pages at the edge — rewriting small personal details safely.
- Cache-Control no-store and bfcache — browser-side header choices for authenticated pages.
- Purging CDN cache by tag on deploy — keeping shared shells fresh.