The secrets hiding in your JavaScript

Is there a secret in your NEXT_DATA? (Your SSR props ship to the browser)

Server-side rendering has a step most people never look at. The server builds the page, fills in the data, sends the HTML, and then, so the JavaScript can take over without re-fetching everything, it dumps the state it used into the page as a big JSON blob. In a Next.js app that's the <script id="__NEXT_DATA__"> block. In a Redux app it's window.__PRELOADED_STATE__. Apollo writes window.__APOLLO_STATE__, Nuxt writes window.__NUXT__. The browser reads the blob and hydrates from it. So does anyone who opens view source.

Here's the part that bites: that blob contains whatever your server put into the props or the store. If a page passed a server-only value through to a component, even a value the component never displays, it's sitting in the serialised state in clear text. The data fetch ran on the server, where using a secret is fine. The result of that fetch got serialised into the page, where nothing is private.

request
GET / HTTP/1.1 Host: app.yoursite.com
response
HTTP/1.1 200 OK
Content-Type: text/html
<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{"user":{"id":42},
"stripeKey":"sk_live_51HxQp…Yz"}},"page":"/"}
</script>
The server passed a secret through pageProps. It never rendered on screen, and it's right there in the hydration JSON.

How a server secret gets into client state

The classic version is a getServerSideProps (or getStaticProps, or a loader) that does real work with a secret and then returns more than it meant to. You fetch a user with an admin API key, and instead of returning just the fields the page needs, you spread the whole response object into props. The key was part of the object you were handed, or part of the config you passed around, and now it's in pageProps, serialised into __NEXT_DATA__, served to everyone.

Redux and Apollo have their own route to the same place. The server hydrates a store, and the store is supposed to mirror the client's view of the world, but somebody stashed a config slice or an auth context in it that holds a token. When the server serialises the store for the client to pick up, the token goes with it. The component that needs the token reads it from the store and works perfectly; the fact that the token is also visible to anyone reading the page source is invisible during development, because you're looking at the rendered app, not the bytes.

0:00Scanner fetches the SSR homepage
0:02Parses __NEXT_DATA__ as JSON, walks every value
0:03Finds sk_live_… nested in pageProps
0:25Live key, used against your account
The blob is valid JSON by design, so it parses cleanly and every nested value is trivially readable. No deobfuscation needed.

What makes this surface worse than a key buried in a minified bundle is that it's structured. Minified JavaScript is at least noisy; a hydration blob is clean JSON, indexed by key name like apiKey, token, secret, password. A scraper doesn't grep through code, it parses the object and reads the values. The naming you used to organise your state is the same naming that tells a stranger exactly which field to grab.

Whitelist what the client actually needs

The rule is the one that governs every server secret, applied to the serialisation boundary: decide explicitly what the browser is allowed to receive, and send only that. Don't return whole objects from a server data fetch and hope the secret isn't in them. Pick the fields.

export async function getServerSideProps() {
const data = await fetchUser(process.env.ADMIN_KEY);
// spreads the whole response, including the key it was built with
return { props: { ...data } };
}
Return only the fields the page renders. The secret never enters the serialised props.

For a store-based setup, sanitise the state before you serialise it: strip the slices that hold tokens or config out of the object you hand to the client, and rebuild them client-side from values the browser is allowed to have. And once a secret has been in a served hydration blob, rotate it. It's been sitting in plain JSON in your page source for the whole life of the deploy, and a JSON blob is the easiest thing in the world for a bot to read, so assume it was.

A hydration blob is, by design, the server telling the browser everything it knows about the page, which means it's also telling everyone else. SurfaceCheckr reads it the way a scraper would: it pulls the served HTML, finds the __NEXT_DATA__, __PRELOADED_STATE__, __APOLLO_STATE__, __NUXT__, and generic application/json state scripts, and runs its secret-pattern engine over the parsed contents, flagging only a real key rather than a public identifier. It names the surface the leak came from, so you know whether to look in your props or your store. It reads the state and stops; it doesn't act on what's in it. Two minutes and you know whether your server is serialising a secret into every page it renders.

Find it before someone else does.

Paste your domain. The grade and issue count are free, and you'll see in a couple of minutes exactly what's reachable from outside.