← back

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.

Vendor
Vendor A
Product
E-commerce Web Platform
Severity
High
Status
Published
Disclosure
Full

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:

  1. A sensitive session credential was copied into client-visible server-rendered state.
  2. 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.


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.

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.