The secrets hiding in your JavaScript

Your API response is carrying password hashes, tokens, and connection strings to the browser

A leaked secret usually conjures a key hardcoded in a JavaScript file. There is a quieter way the same thing happens, and it leaves no trace in your source: the secret rides out in an API response. The page loads, calls your own endpoint, and the JSON that comes back carries fields the browser was never meant to see, a password hash next to the username, a refresh token next to the email, sometimes a database connection string in an error.

Nothing in your bundle looks wrong, because nothing in your bundle is wrong. The secret is not in the code you shipped. It is in the data your code requested, served fresh on every page load to anyone watching the network tab.

The fat serializer, and what it spills

The usual cause is a serializer that returns the whole database row. An endpoint fetches a user to render a name and an avatar, and instead of selecting those two fields it serializes everything the record has. The browser renders the two fields it needs. The response carries all of them.

request
GET /api/me HTTP/1.1 Host: app.yoursite.com Cookie: session=...
response
HTTP/1.1 200 OK
Content-Type: application/json
{"id":42,"name":"Dana","email":"[email protected]",
"password_hash":"$2b$12$Q8s...53chars...","role":"admin",
"stripe_secret":"sk_live_51Hx...","refresh_token":"rt_9f2a..."}
The page shows the name. The response carries the hash, an admin role, a live Stripe key, and a refresh token.

What lands in the browser depends only on what the row holds. A password_hash is the worst of the routine cases: hashes are meant to never leave the server, because once an attacker has them they can crack offline, at their own pace, with no rate limit and no lockout to stop them. A refresh_token or access_token in the body is a session an attacker can steal through any XSS or any logged response, and it may belong to a different user entirely. And the same fat-serializer mistake that exposes a hash will, on the wrong record, expose a stored api_key, a client_secret, or a signing_key, a server-only credential handed to every client that calls the endpoint.

The error path leaks differently

Secrets also escape through failure. An endpoint hits a database problem, the error handler is the verbose one meant for development, and the response echoes the raw error, which sometimes carries the connection string that produced it, host, user, and password in one line. That is not a key to an API; it is a direct login to the database.

The most dangerous version is rarer and worse: an API response that echoes cloud instance-metadata credentials, the AccessKeyId and SecretAccessKey pair from 169.254.169.254. That almost always means a server-side request forgery is reaching the metadata endpoint and returning what it finds, and those credentials are frequently a path to the whole cloud account.

0:00Attacker watches your page's API calls
0:15Spots password_hash in the /api/me response
0:30Same fat serializer leaks a refresh_token
2:00Offline hash cracking + a stolen session
The secret was never in your code. It was in the data your code returned.

Trim the serializer, then rotate what leaked

The fix is the rule that governs every server secret, applied one layer in: the fields that belong to the server must never reach the browser, and an API response is the browser.

// serializes the entire user record
const user = await db.user.findUnique({ where: { id } });
return Response.json(user);
// ships password_hash, refresh_token, stripe_secret
Select the fields the client renders. Never let the serializer reach the secret-bearing columns.

Select the fields the client actually renders instead of returning the row, so the serializer can never reach a password_hash, a token, or a stored key. Switch the framework to production error handling so a database failure returns a generic message, not the connection string behind it. Then treat anything that already leaked as compromised and rotate it, because a secret served in a response has been readable by every visitor since the day the field slipped in, and you have no log of who copied it. For an exposed IMDS credential, rotate the role, block the app's path to 169.254.169.254, and enforce IMDSv2.

Whether your responses carry server-only fields is checkable from outside without touching your backend, because your own page already requests them. SurfaceCheckr watches the API responses your page fires during a real-browser render and flags the ones that carry a password-hash shape, a credential-bearing field like refresh_token or api_key, a real provider secret (the same engine that finds a Stripe key in your bundle, pointed at the response body), a database connection string, or an IMDS credential echo. It reads what your page already received and stops there; it never crafts or replays a request, and the evidence it keeps is redacted to a snippet that proves the exposure without storing the secret. The leak isn't in your code. It's in the response, and it is two minutes to confirm it isn't there.

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.