← home

hydration - why the page ships more than it shows

· 18 min read
rick at a laptop whose screen shows an almost empty profile page, while the same laptop floods the garage with printer paper reading password_hash, is_admin and internal_notes, morty waist-deep in it

open any app with a javascript front end that renders on the server, hit a page behind login, and read the html document itself, not the xhr calls that follow it. somewhere near the bottom there’s a script tag with a few kilobytes of json in it, and that json is the state the server rendered from.

the page above it showed you a name and an avatar. the json under it holds the row.

no auth bypass, no payload craft, no race. ctrl+u. it’s the only severe bug class you can find on a phone, on a train, with the browser you already have, and it is sitting in production right now at companies that have paid for three pentests and a soc 2.

table of contents

open table of contents

why the payload exists

server rendering produces html, and html is dead. the buttons don’t work, the modal doesn’t open, the list doesn’t sort. to wake it up the client has to build the same component tree the server just built, same state and same props, and attach handlers to the dom that’s already there. that’s hydration.

so the client needs the state, and it has two ways to get it: fetch it again, which throws away the round trip ssr just saved, or read it out of the document the server already sent. every framework picked the second one.

the request what comes back
─────────── ───────────────
GET /account <html>
│ <div>hi, dana</div> ◄── what you see
│ ...
├─ server runs the loader <script id="__NEXT_DATA__">
├─ loader queries the db {"props":{"pageProps":{
├─ loader returns an object "user":{
├─ framework serializes it "id":41,
└─ framework embeds it "name":"dana",
"email":"...", ◄── what you get
"phone":"...",
"password_hash":"...",
"is_admin":false,
"internal_risk_score":72
}}}}
</script>
</html>

notice which step made the decision. the serializer didn’t pick those fields. it serialized what the loader returned, because that’s its entire job and it has no way to know which of those keys the jsx above it actually touched. the loader returned the row because the developer needed name off it and the orm handed back the whole thing.

that’s the shape of the bug class:

the ui renders the fields it uses. the payload carries the fields you fetched. nobody wrote code to make those two sets the same, and no error is raised when they differ.

“but isn’t that just the api?”

this is the first thing anyone says, and it deserves a real answer rather than a paragraph later on. the objection is: that data came from an api, i intercept the api, so i would have seen it. three quarters of the time there is no api call to intercept.

getServerSideProps, a server component, a remix loader, useAsyncData, angular’s ssr pass: all of them fetch inside the server process. the call goes to the database driver, or to a service on the internal network. what your proxy sees is one line:

GET /projects/x → 200 text/html

one request, one response, the records already inside it. there is no /api/projects/x row in the history to click on, because nothing crossed the network you’re watching. “i intercept the api” is true and beside the point: no api call was made for you to intercept.

when there is an api, it’s a second code path rather than the same one. the public endpoint has the serializer and the authorization check somebody wrote and somebody reviewed, and a test asserting password_hash never leaves. the loader beside it often goes straight to the database, or calls the same service with an internal identity, and ships whatever came back on the assumption that react won’t draw it. two roads to the same data, one of them audited.

sveltekit makes the whole argument visible in a single feature. a fetch inside load is a genuine http call, the server makes it, and then it keeps the answer:

During server-side rendering, the response will be captured and inlined into the rendered HTML by hooking into the text, json and arrayBuffer methods of the Response object.

the request never appears on the wire in front of you. its response is sitting in the page source. that’s the gap between “i’m watching the api” and “i’m seeing the data”, in one documented sentence.

so what makes it a vulnerability is not that data is in the page. it’s that the fields are in the page and the decision not to show them is made in the browser.

it also explains why the class survives a pentest that found everything else. manual testing lives in the proxy’s xhr tab, and scanners rank text/html low. even when somebody looks, the values don’t announce themselves: "is_admin": false fires no regex, and neither does "cost_price": 41.2. secret scanners find keys and tokens, and the fields that matter here are ordinary business columns.

and it is not always a vulnerability. the same payload also carries everything the page legitimately draws, because that is what the payload is for, and a post that skips this teaches you to report every logged-in page you open. two things separate a finding from the product working:

  • a record an anonymous visitor gets too is the application publishing, not a leak. check it signed out before you check anything else
  • “sent but never rendered” is a trace, not proof. it tells you where the authorization decision is being made. it does not tell you the viewer was unauthorized to see the record. turning it into proof needs something independent that ties the record to somebody else, which is what the three-identity comparison further down is for

there is a second reason a field can be present and unrendered, and it isn’t a permission check at all: the component was never rendered on the server. a panel below a suspense boundary, a deferred section, a tab whose content streams in later. the data is in the payload, the markup isn’t in the document, and nothing was hidden from anybody. read the markup as well as the payload before you decide which one you’re looking at.

which apps have it

“server-rendered” is the wrong test, and it decides whether there is anything to look for at all.

php, django templates, rails erb, laravel blade, asp.net core mvc and razor pages all render html on the server and send html. the template interpolated four values into a string and the browser got the string, and there is no second copy of the data anywhere in the response. put htmx or turbo on top and that stays true.

the payload appears when the client has to resume what the server started: the same component code runs in both places, and to re-run it the client needs the state the server had. that’s the test. not “did the server render this”, but “is the client about to render it again”.

which makes the list longer than the javascript frameworks. laravel with inertia ships the whole page object, props included, in a <script type="application/json">. livewire puts every public property in a wire:snapshot attribute on the component’s root element. blazor webassembly serializes PersistentComponentState into the prerendered html. and webforms got here two decades before any of them: __VIEWSTATE is a serialized control tree in a hidden input, carrying whatever the developer hung off a control’s properties, including controls that never rendered visibly. it’s mac-signed by default, which stops you editing it, and signing is not encryption: unless ViewStateEncryptionMode is on, you base64-decode it and read it. if you have ever run a viewstate decoder against an .aspx page, you have already done the thing this post is about.

two stacks opted out, which is the proof that the rest could. phoenix liveview keeps the state in the server process and says so in one sentence: “the server data is never shared with the client beyond what your template renders”. that’s the whole fix, expressed as an architecture instead of a warning. blazor splits it by render mode and writes the consequence out plainly rather than leaving it to be inferred:

During client-side rendering (CSR, InteractiveWebAssembly), the data is exposed to the browser and must not contain sensitive, private information. During interactive server-side rendering (interactive SSR, InteractiveServer), ASP.NET Core Data Protection ensures that the data is transferred securely.

the feature that ships it

there is no bug here to point at, which is why nobody points at it. every framework has one named feature whose documented job is to carry the server’s data to the client so the client doesn’t fetch it a second time. that feature is what puts the row in the document, and it is doing exactly what it says it does.

frameworkthe featurewhat its docs promise
next.js (pages)the props object returned from getServerSideProps“it should be a serializable object so that any props passed could be serialized with JSON.stringify
next.js (app)any prop crossing a "use client" boundary, carried in the rsc flight stream“prop values passed from a Server Component to Client Component must be serializable”
nuxtuseAsyncData / useFetch, writing into useNuxtApp().payload“the data is forwarded to the client in the payload”
sveltekitthe object returned from load in +page.server.js“a server load function must return data that can be serialized with devalue”
remix / react routerthe object returned from loader, landing in __remixContext.state.loaderDatasame shape, same contract
angularthe http transfer cache installed by provideClientHydration()ssr-time GET responses are cached so the browser doesn’t repeat them

read the right-hand column again. every one of them states a requirement about serializability. not one of them states a consequence about visibility. a developer reads “must be JSON-serializable” as a type constraint, the same category as “must be a valid date”. it isn’t a type constraint. it’s a disclosure notice written in the grammar of a type constraint.

the names help it along. getServerSideProps, +page.server.js, “server component”: the word server in all three says where the code runs, not where the data stays. next’s own reference page spells out the first half and never mentions the second.

Imports used will not be bundled for the client-side. This means you can write server-side code directly in getServerSideProps, including fetching data from your database.

both sentences are true. the sentence you needed is the one about the return value, and it isn’t on the page.

so which component is at fault? none of them alone. it takes two defaults meeting at a boundary that has no owner:

the data layer the transfer layer
────────────── ──────────────────
prisma.user.findUnique(...) return { props: { user } }
`select` is opt-in serializes what it was handed
default: every column default: every field
│ │
└──────────► the row ───────────────┘
into the document

neither default is wrong by itself. an orm that returns the whole row is doing the obvious thing, and it’s the thing you wanted the other ninety times you called it. a serializer that ships what it was handed has no other option, because it cannot see which of those keys the jsx above it will use. the bug is the join, and the join is a return statement. there is no line in the diff for a reviewer to stop on.

angular alone treats the channel as public by default: its transfer cache refuses a request carrying Authorization or Cookie headers unless you set includeRequestsWithAuthHeaders: true. the rest hand you a serializer and trust that you knew what you gave it.

where it lands

the table above is what you grep for in a repo. this one is what you grep for in a response, because a payload you don’t recognise reads as minified junk and gets skipped.

frameworkmarkerthe payload’s own endpoint
next.js (pages)<script id="__NEXT_DATA__" type="application/json">/_next/data/<buildId>/<route>.json
next.js (app / rsc)self.__next_f.push([1,"..."]), many chunkssame route with ?_rsc=<hash>
nuxt__NUXT_DATA__ (devalue) on 3, window.__NUXT__ as an iife on 2payload extraction routes
sveltekit__sveltekit_<hash> = { data: [...] }, plus data-sveltekit-fetched blocks holding whole inlined http responses/<route>/__data.json
remix / react routerwindow.__remixContext, or window.__reactRouterContext on rr7same route with ?_data=<routeId>
angular, gatsby, qwik, astro<script id="ng-state">, page-data.json, <script type="qwik/json">, <astro-island props="...">/page-data/<route>/page-data.json

a scanner that only understands __NEXT_DATA__ will call four out of five apps clean, and it will call every app router app clean as well: there is no __NEXT_DATA__ in one. the flight stream is not a props object either. it’s a serialized react element tree, ["$", "$L27", key, props], and the data travels at the point where it is handed to a client component. a server component renders on the server and only its output makes the trip.

that right-hand column is a second door. the framework needs the same state for client-side navigations, so it exposes a route that returns the payload alone. it is a different route from the page, which means the checks wrapped around the page (middleware, an auth layout, a redirect) do not automatically run on it. sometimes they do. a data route that answers when the page redirects is a much shorter write-up.

what actually leaks

“the payload has more fields than the screen” is true everywhere and interesting almost nowhere. these are the shapes that turn it into a finding:

the row. the loader did select * or handed back an orm entity, so you get password_hash, mfa_secret, reset_token, soft-delete columns, internal timestamps. the common one, and the boring one, right up until the column is a credential.

someone else’s row. a list page renders each item’s title, and the loader fetched each item with its author joined in full. the screen shows what you’re allowed to see; the payload holds what the query returned, and the query was written to be convenient rather than scoped.

the branch you don’t get. {user.is_admin && <AdminPanel data={adminData} />} renders nothing for you. but if adminData was fetched above the conditional, which is how loaders get written, it is in the payload. the ui hid it. the transport didn’t. same for a config module that got spread into props, or an error object serialized with its stack.

your row, in someone else’s cache. a personalized document that a cdn was allowed to cache is served to the next visitor with the previous visitor’s state still inside it. this is the highest-impact version of the whole class and the one that’s invisible from a single request. check Age, x-vercel-cache, cf-cache-status on a document that carries user state. s-maxage on a page whose payload contains a user id is the finding, whether or not you catch it crossing.

what it costs

the reflex, once a field turns up in a payload, is to compare it to the api: same field, same user, so same severity, and it goes in the report as a low. that comparison is wrong, and it’s the argument you’ll have with the developer.

an api response is a private conversation. one client asked, one server answered, and when the tab closes it’s gone. nothing indexes it.

an html document is a public artifact by construction. it’s what crawlers fetch, what the wayback machine keeps, what a cdn writes to disk in twenty pops, what a corporate proxy logs in full, what slack fetches to build a preview card when somebody pastes the link.

so the same column leaks differently depending on which door it left through. through the api it reaches whoever was attacking you. through the document it reaches every system that has ever been pointed at that url, most of which you don’t own and none of which you can phone.

and it outlives the fix. you can ship the patch this afternoon. you cannot un-crawl.

morty pressing a big red patched button while a wall of servers behind him labelled wayback, cdn cache, proxy log and link preview still shows the old page

the patch ships this afternoon. the copies don't take patches.

that’s the severity argument. the human one is what makes the fix land:

  • a support console renders a ticket subject and the requester initials, and carries the full customer record for every ticket in the queue, forty at a time
  • a pricing page renders the sale price and carries cost and margin
  • a patient portal hides the diagnosis behind a “show” toggle, and the toggle is a css class

that last one is the shape to remember. hiding something in the ui is a rendering decision, and the payload is built before rendering happens. hidden, display: none, a false in a conditional, a role check in jsx: every one of them runs on data that has already been serialized and sent.

none of this is carelessness. the developer wrote const user = await db.user.findUnique({ where: { id } }) and passed the result to a component. that is the first example in the framework’s own tutorial, and in the orm’s. so this isn’t a finding about somebody who should have known better. it’s a finding about a default, which is why the recommendation has to be a mechanism instead of a reminder.

finding it

three steps, about twenty minutes per app once you’ve done it twice.

1. pull the payload out of the document

get the document, not the traffic after it. for the plain-json frameworks, sed the script tag out and hand it to jq. rsc flight needs reassembling first: the payload arrives as a series of push calls holding json string fragments, and concatenating them in order gives you the stream.

# flight.py - stdin is the html document, stdout is the reassembled flight stream
import json, re, sys
doc = sys.stdin.read()
chunks = re.findall(r'self\.__next_f\.push\(\[1,(".*?")\]\)', doc, re.S)
sys.stdout.write("".join(json.loads(c) for c in chunks))

it is not pretty and it does not need to be. you’re about to grep it, not render it. nuxt 3 and sveltekit hand you devalue instead, an array whose objects refer to each other by index: devalue.parse in node turns it back into an object graph.

2. ask which values never reached the screen

this is the step that separates a real finding from “the payload is large”. take every scalar leaf in the payload and check whether it appears anywhere in the rendered text of the page. the ones that don’t are what the server sent you but the ui never intended to show.

# unseen.py <payload.json> <rendered.txt>
import json, sys
def leaves(node, path="$"):
if isinstance(node, dict):
for k, v in node.items():
yield from leaves(v, f"{path}.{k}")
elif isinstance(node, list):
for i, v in enumerate(node):
yield from leaves(v, f"{path}[{i}]")
else:
yield path, node
payload = json.load(open(sys.argv[1]))
visible = open(sys.argv[2], encoding="utf-8").read()
for path, value in leaves(payload):
if value is None or isinstance(value, bool):
continue
text = str(value)
if len(text) < 4:
continue # too short to match meaningfully
if text not in visible:
print(f"{path}\t{text[:80]}")

get the rendered text from a headless browser dump or w3m -dump, anything that gives you what a human would read. then sweep the field names of whatever comes back: password, hash, token, email, ssn, iban, is_admin, role, internal, deleted_at, cost, margin, apiKey. hits are leads, not findings. an email key on your own account page is the product working.

watch the filter itself, though, because it quietly becomes your definition of a record. that length floor drops short numbers, so a chart series of {"month": "2026-08", "revenue": 812} contributes one string and nothing else, and a page whose payload is mostly numeric reads as carrying no data at all. i have read a page as clean on exactly that basis and been wrong by twenty-eight records. when a page comes back empty, suspect the filter before you believe the page.

3. run the route as three identities, not one

one identity tells you what the payload contains. it cannot tell you whether that’s a leak, because your own data being sent to you is the feature.

so take the same route as anonymous, as user a, and as user b, and compare all three. this is the only comparison that separates the outcomes:

what the payloads showwhat you have
user a’s document holds fields belonging to user ba leak. write it up with both ids and the path to the field
user a’s document holds a’s own hidden fields, b’s holds b’sdisclosure of internal shape. real, lower, and it’s the map for the next bug
anonymous gets a payload an authenticated user getsdepends entirely on the field
a and b get byte-identical documents with a user id insidestop and go read the cache headers

a two-way diff can’t do this. anonymous versus user a tells you the page personalizes, which you already knew. it’s the third point that tells you whether the personalization is scoped to the person.

the fix, and the three things that aren’t it

recommendationverdict
“delete the field in the component”wrong. the component isn’t the serializer. the payload is built from the loader’s return value and the component never touches it
“move the fetch to the client side”not a fix, a relocation. the data still leaves the server, through the api instead, where at least you already have a serializer and an authz check
“select only the columns you render, in the query”this is the fix. it’s also the one that survives the next refactor, because there’s nothing to forget to update
“put a dto between the loader and the framework - the same one the api uses”this is the fix, one release later, and the one that scales past a single route
“mark server-only values so they can’t be serialized”the structural version. next has server-only and the taint apis; the general form is a wrapper that throws when it hits the serializer
“don’t let a shared cache store a personalized document”a different bug, and the one that turns your own data into everyone’s. mandatory once a user id appears in the payload

closing thoughts

one habit covers most of this: after you log in, read the document before you read the traffic.

everything in it arrived because a server chose to send it, and on an ssr app that choice was made by someone writing a database query with a screen in mind. so check the two against each other, per route, as three identities: what the query returned, against what the page rendered.

and when you write it up, lead with the door rather than the field. a report that says password_hash is in the html gets a patch on one route. a report that says every loader in the app is a serializer nobody wrote gets a dto, and that closes the other forty routes you never had time to open.