Cross-Session Session Leakage Through Cached Next.js Responses
How a shared CloudFront cache exposed an HttpOnly session credential embedded in server-rendered Next.js responses.
Summary
During security testing of Vendor A, a large e-commerce platform, I identified a cross-session credential disclosure caused by the interaction between Next.js server-side rendering and a shared CloudFront cache.
The application serialized the current sid session cookie into the Next.js response body inside a requestCookies object.
At the same time, CloudFront cached the generated HTML even though the origin explicitly returned:
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
This created a situation where one visitor’s session identifier could become embedded inside a shared cached response and later be delivered to a completely different, cookie-free client.
The disclosed sid was not merely an internal identifier. It functioned as a bearer session credential.
Using only two anonymous sessions under my control, I demonstrated that a session ID obtained from the cached response could be supplied to Vendor A’s checkout-state API to access the original session’s cart and checkout state.
No unrelated customer session was accessed during testing.
Vulnerability Overview
The vulnerable flow can be summarized as:
Client A
│
│ request with SID_A
▼
Vendor A / Next.js
│
│ server-side rendering
│ serializes SID_A into requestCookies
▼
CloudFront
│
│ stores personalized HTML
▼
Client B
│
│ requests identical cache key
│ without cookies
▼
CloudFront cache HIT
│
├── Set-Cookie: SID_B
│
└── Cached HTML still contains SID_A
│
▼
SID_A disclosed to Client B
Two security failures had to occur together:
- A sensitive session credential was copied into client-visible server-rendered state.
- The resulting personalized response entered a shared CDN cache.
Either behavior alone would already be undesirable. Together, they resulted in cross-session credential leakage.
Discovery
While inspecting the HTML returned by Vendor A’s Next.js application, I noticed that request cookies were serialized into an inline application payload.
A simplified representation looked like this:
{
"requestCookies": {
"...": "...",
"sid": "SID_A"
}
}
The actual sid cookie was marked HttpOnly.
Normally, HttpOnly prevents JavaScript executing in the browser from accessing a cookie using APIs such as:
document.cookie
However, that protection becomes irrelevant if the server itself copies the credential into HTML or JavaScript delivered to the browser.
Conceptually:
Cookie storage:
sid = SID_A
HttpOnly = true
↓
Server-side rendering reads sid
↓
Next.js response body contains SID_A
↓
Browser receives the supposedly protected value
as ordinary application data
At this point, the issue represented a local session credential disclosure.
The next question was more important:
Could this response ever be delivered to another user?
Testing the CDN Cache
I created two isolated anonymous sessions under my control.
The first controlled browser had the session:
SID_A
I then selected a unique URL containing a random query parameter so that the initial request would create a fresh cache entry.
The first request was made using SID_A.
The resulting response contained:
X-Cache: Miss from cloudfront
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
and the HTML body contained:
SID_A
This behavior was already unexpected.
A personalized response containing a session credential and explicitly marked private and no-store should not normally become a reusable shared cache object.
The Second Client
I then requested the exact same URL from a second client without sending any cookies.
The second response returned:
X-Cache: Hit from cloudfront
Vendor A also assigned the new visitor a fresh session:
Set-Cookie: sid=SID_B
where:
SID_B != SID_A
However, the HTML response body still contained:
SID_A
The resulting response effectively contained two different identities:
HTTP response headers:
Set-Cookie: SID_B
Cached Next.js response body:
requestCookies.sid = SID_A
The second visitor therefore received a newly generated session in the HTTP headers while simultaneously receiving the previous visitor’s session credential inside the cached page body.
Why This Was More Than a Cookie Leak
The important next step was determining whether sid represented harmless tracking state or an actual authorization credential.
To test this safely, I used only sessions that I controlled.
In Client A, I placed a harmless item into an anonymous shopping cart.
The exact product is intentionally omitted from this write-up.
The controlled state was therefore:
Client A
sid:
SID_A
cart:
1 controlled test item
I then primed a fresh cache entry using SID_A.
A second cookie-free client requested the same cache key.
The result again showed:
X-Cache: Hit from cloudfront
while the HTML still contained:
SID_A
I then supplied the disclosed SID_A to Vendor A’s checkout-state API.
The server returned:
200 OK
and the JSON response contained the cart state belonging to Client A, including the controlled item and quantity.
This confirmed that the leaked value was an actual bearer credential for session state.
Possession of the value was sufficient to access state associated with the original session.
Proof-of-Concept Model
The original reproduction used unique cache-buster URLs to ensure the test started with a cache miss.
A simplified version is shown below.
Client A — prime the cache
URL="https://vendor-a.example/?cache-test=<unique-value>"
curl -sk \
-D prime.headers \
-o prime.html \
-b "sid=$SID_A" \
"$URL"
Expected vulnerable result:
X-Cache: Miss from cloudfront
prime.html:
SID_A present
Client B — request the same cache key
curl -sk \
-D second.headers \
-o second.html \
"$URL"
Observed result:
X-Cache: Hit from cloudfront
Set-Cookie:
SID_B
second.html:
SID_A
with:
SID_A != SID_B
The second client had therefore obtained the first client’s session identifier from a shared CDN response.
Controlled Impact Validation
The disclosed identifier was then used with the relevant checkout-state endpoint.
For anonymization, the exact Vendor A endpoint has been omitted.
Conceptually:
curl -sk \
-b "sid=$SID_A" \
"https://vendor-a.example/<checkout-state-api>"
The response returned:
HTTP/2 200 OK
Content-Type: application/json
and contained Client A’s controlled cart state.
The complete demonstrated chain was therefore:
Client A creates session
│
▼
SID_A stored in HttpOnly cookie
│
▼
Next.js SSR serializes SID_A
│
▼
CloudFront caches personalized response
│
▼
Client B receives cached response
│
▼
SID_A disclosed to Client B
│
▼
Client B supplies SID_A to backend
│
▼
Vendor A accepts SID_A
│
▼
Client A's checkout state returned
HttpOnly Was Effectively Bypassed
One notable aspect of the issue was that the session cookie itself was correctly marked HttpOnly.
HttpOnly protects against direct JavaScript access to cookies:
JavaScript
│
├── document.cookie
│
└── cannot read HttpOnly sid
But that protection assumes the application does not expose the same value through another channel.
Vendor A’s server-side rendering effectively did:
HttpOnly sid
│
▼
Server-side code
│
▼
requestCookies
│
▼
Next.js serialized state
│
▼
HTML response
From the browser’s perspective, the session value was no longer protected cookie state. It had become ordinary page content.
The CloudFront Behavior
The most interesting technical aspect was arguably the behavior of the CDN.
The origin response explicitly contained:
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Yet subsequent requests returned:
X-Cache: Hit from cloudfront
The cached body remained associated with the previous session.
At the same time, the Set-Cookie header presented to the second client contained a completely different session.
This resulted in:
CloudFront response
│
┌────────┴────────┐
│ │
▼ ▼
Response header Cached body
│ │
SID_B SID_A
This indicated that the session header and cached response body were being handled differently at the edge.
The cookie assigned to the current visitor could change while the personalized session information serialized inside the cached HTML remained stale.
Generic Two-Client Reproduction
A separate generic test showed the same behavior without relying on cart state.
The first request produced:
Request 1
X-Cache:
Miss from cloudfront
Set-Cookie:
SID_A
HTML:
SID_A
The second cookie-free request to the same URL produced:
Request 2
X-Cache:
Hit from cloudfront
Set-Cookie:
SID_B
HTML:
SID_A
This provided a straightforward way to distinguish the new visitor’s session from the stale value embedded in the cached response.
Long Session Lifetime
The affected sid was issued with a configured lifetime of approximately:
34560000 seconds
or roughly:
400 days
This matters because the security impact of a leaked bearer credential depends partly on how long the credential remains usable.
A long-lived session potentially creates a much larger exposure window unless it is rotated, explicitly invalidated, replaced during authentication, or otherwise expired server-side.
Potential Authenticated Impact
My proof-of-concept intentionally remained anonymous.
I did not create or access an unrelated customer account.
However, the checkout-state object supported fields related to areas such as:
customer state
address information
payment state
order state
CSRF-related data
identity-related tokens
Those fields were not populated in the anonymous session used for testing.
Therefore, I did not claim that authenticated customer information was accessible through my proof-of-concept.
The demonstrated impact was:
Cross-session disclosure and reuse of an anonymous session credential resulting in unauthorized access to another controlled session’s cart and checkout state.
Potential authenticated impact would require separate validation.
Regional Deployments
The vulnerability was reproduced on a second country-specific deployment belonging to Vendor A.
The same pattern occurred:
Request 1
Cache:
MISS
Session:
SID_A
Body:
SID_A
Request 2
Cache:
HIT
New session:
SID_B
Body:
SID_A
This confirmed that the issue was not limited to a single hostname.
A small comparison test against another regional Vendor A deployment resulted in:
Miss → Miss
and therefore did not reproduce the cross-session leak during that particular test.
No conclusion was drawn about that deployment based on such a limited comparison.
Root Cause
The vulnerability existed because two separate controls failed together.
1. Sensitive Request Cookies Were Serialized
The server-side rendering layer exposed raw session information through its client-visible Next.js state.
The browser did not require the raw sid value in order to render the page.
Sensitive session or authentication credentials should never be serialized into:
HTML
Next.js hydration state
React Server Component data
generic request objects
client-visible application state
unless absolutely necessary.
2. Personalized Responses Entered a Shared Cache
CloudFront stored and reused a response containing session-specific data.
The response was still cached despite explicit origin directives such as:
private
no-store
The shared cache therefore crossed an important trust boundary:
Per-user state
↓
Shared response object
Once that happened, the next user requesting the same cache key could receive data belonging to the previous session.
Security Boundary
The vulnerable architecture effectively looked like:
┌───────────────────┐
│ Client A │
└─────────┬─────────┘
│
SID_A
│
▼
┌───────────────────┐
│ Next.js │
│ SSR │
└─────────┬─────────┘
│
serialized SID_A
│
▼
╔════════════════════════╗
║ SHARED CACHE ║
║ CloudFront ║
╚════════════╤═══════════╝
│
cached response
│
▼
┌───────────────────┐
│ Client B │
└───────────────────┘
The key security mistake was allowing:
session-specific state
to cross into:
shared cache state
Remediation
Several defensive changes were recommended.
1. Never Serialize Session Credentials
Raw session identifiers, authentication cookies, or entire requestCookies objects should not be placed into client-visible page state.
Instead of:
{
"requestCookies": {
"sid": "LONG_SECRET_SESSION_VALUE"
}
}
the UI should receive only explicit information it actually needs, for example:
{
"hasSession": true
}
2. Bypass Shared Caching for Personalized Responses
Any HTML or RSC response that:
- reads a session cookie,
- creates a session,
- sets a session cookie,
- contains personalized state,
- or depends on authenticated context
should not enter the shared public cache.
3. Honor private and no-store
CDN configuration should ensure that responses containing:
Cache-Control: private
or:
Cache-Control: no-store
cannot become reusable shared cache objects.
4. Avoid Varying the Public Cache on the Full Session Cookie
A tempting mitigation would be:
Cache key =
URL + complete session cookie
That would prevent cross-session responses, but it would also cause personalized objects containing sensitive information to remain stored at the CDN edge.
A safer rule is:
Personalized responses should not enter the shared cache in the first place.
5. Rotate Potentially Exposed Session IDs
Because session credentials may already have entered shared cache objects, affected session identifiers should be rotated or invalidated after remediation.
This is especially relevant given the long lifetime of the affected cookie.
6. Add a Two-Client Regression Test
This vulnerability can be tested with a simple automated security regression:
Client A
│
├── establishes SID_A
└── requests URL
Client B
│
├── has no cookies
└── requests same URL
Assertions:
Client B body must not contain SID_A
Client B must not be able to use
anything from Client A's response
to access Client A's state
This directly verifies the trust boundary that failed.
Responsible Testing
Testing was intentionally kept low-volume and controlled.
I used:
- two anonymous sessions under my control,
- one harmless cart item,
- unique cache-buster URLs,
- manual requests,
- no purchase,
- no completed checkout,
- no account creation,
- no unrelated customer data.
During investigation, I also observed a shared cache response containing a session identifier that was not associated with either of my controlled sessions.
I did not attempt to use that value.
The controlled two-session proof-of-concept was already sufficient to demonstrate the security issue.
Takeaway
The part I found most interesting about this vulnerability was that no single component needed to be catastrophically broken.
The application used ordinary components of a modern web stack:
HttpOnly cookies
CloudFront
Next.js
server-side rendering
cache controls
session APIs
The vulnerability emerged from the way they interacted.
An HttpOnly cookie is useful only while the credential remains confined to cookie storage.
A CDN is safe for public content only while personalized responses remain outside the shared cache.
A server-rendering framework can safely read request cookies only if sensitive values are not blindly serialized back to the client.
The important question therefore was not simply:
Is this cookie protected?
or:
Is this endpoint authenticated?
or even:
Does the response say
no-store?
The more useful question was:
Can state belonging to one security context cross into a response that another security context can receive?
In this case, the answer was yes.
Once a bearer session credential crossed that boundary, a normal shared cache transformed a local serialization mistake into a cross-user session disclosure vulnerability.