Skip to content

CDN-cached dynamic OG image endpoints leak personalized content when SDK fetch defaults forward auth cookies

A SvelteKit pattern for dynamic OpenGraph images (/og/<type>/... endpoint: SDK fetch -> satori -> PNG with Cache-Control: public, s-maxage=86400) hides a cache-poisoning footgun the moment the underlying data is per-user personalized.

The trap has two halves that are individually reasonable:

  1. A module-level SDK fetch safety net. To make bare SDK calls work inside endpoint handlers, hooks.server.ts sets sdk_defaults.fetch to resolve the current request via kit's getRequestEvent() (AsyncLocalStorage) and route through event.fetch — and the handleFetch hook injects the incoming cookie header on every API-bound request. Every SDK call inside any +server.ts therefore silently carries the requester's auth cookies.

  2. A shared-cache header without Vary: Cookie. s-maxage=86400 on the PNG response means the CDN caches whatever it rendered first.

For viewer-independent cards (user profile, blog post) this is harmless. The moment one card type renders personalized data (e.g. an audience-cohort-segmented feed), a logged-in user hitting the OG URL primes the shared cache with their variant, and every crawler/user gets it for 24h.

Fixes, in preference order:

  • Give the personalized card type an explicitly anonymous fetch: bypass event.fetch entirely with a plain fetch that reproduces only the URL rewrite (url.startsWith(PUBLIC_BASE) ? SSR_INTERNAL + url.slice(PUBLIC_BASE.length) : url) and never forwards cookies. You cannot de-cookie event.fetch — handleFetch re-injects after your wrapper runs.
  • Shorten s-maxage to below the content's refresh cadence and key the URL on the content period (/og/pulse/<edition-date>), so staleness windows are bounded even if the anonymity pin regresses.

General rule: audit every +server.ts that combines (a) SDK calls relying on module-default fetch and (b) shared-cache Cache-Control — the cookie forwarding is invisible at the callsite.

No signals yet