A token a logged-out visitor can read is a token you can't trust

Hit Ctrl+U on your own homepage and read the raw HTML, the version before any JavaScript runs. That's the exact bytes a stranger gets, logged out, with one request and no account. Server-rendered apps pour a lot into that blob: the props your page needs to hydrate, a __NEXT_DATA__ script, an inline config object. Most of it is harmless. Two things in there are not, and both are about authentication.

The first is a JSON Web Token that's broken by construction, the kind an attacker can forge rather than just replay. The second is the signing secret itself, the server-only key that proves a token is real. Either one in the page source means the same thing: the mechanism you use to tell a logged-in user from a stranger is readable by the stranger. Here's how each one gets there and what someone does with it.

The forgeable token

A JWT is three base64 chunks joined by dots: a header, a payload, and a signature. The signature is the whole point. It's what stops someone editing the payload to say "role": "admin" and having your server believe it. So when a token shows up in your HTML, the question is whether its signature actually protects it, and there are a few ways the answer is no.

The loudest is alg=none. The JWT spec allows a header that declares no algorithm, meaning the token is unsigned, and a server that honors it will accept a token with the signature stripped off entirely. Decode the header of a token in your page and if it reads {"alg":"none"}, anyone can hand-write a token with any contents they like and your backend takes it. That's not a leak of one credential, it's a forging press.

request
# the JWT's header segment, base64-decoded
response
{"alg":"none","typ":"JWT"}
# no signature required: edit the payload to
{"sub":"any-user","role":"admin"}
# re-encode, and the server accepts it
alg=none means the token is unsigned. An attacker writes their own payload and the server has nothing to reject it with.

The same critical class covers a header that embeds key material: a jwk, an x5c chain, or a d private-key component sitting in the token header. That's either an invitation for an attacker to inject their own signing key, or a sign that a real signing key has already leaked into the page. Both mean forgeable.

Then there's the subtler one: a token that's signed fine but is an identity credential with no business being in public HTML. If the payload carries a sub, email, user_id, or role claim, it's tied to a person, not an anonymous session. And if that token has no exp (expiry) at all, or an exp more than 30 days out, it's a long-lived credential to whoever copies it out of the page. Anyone can lift it and be that user for as long as it stays valid, which, with no expiry, is forever.

The check decodes every JWT-shaped string in the served body, parses the header and payload as real JSON, and only fires on one of those conditions. A short-lived anonymous token, the kind a public API legitimately ships to the browser with a one-hour expiry and no identity claim, does not trip it. The point isn't "there's a JWT in the page," it's "this specific JWT is forgeable or is a durable identity credential sitting in plain text."

The signing secret itself

The token problem is bad. The next one is worse, because it skips the token and hands over the press directly.

Frameworks that issue sessions sign them with one server-only secret: NextAuth and Auth.js call it AUTH_SECRET or NEXTAUTH_SECRET, others use JWT_SECRET, SESSION_SECRET, or COOKIE_SECRET. With that value, an attacker doesn't need to find a flaw in a token. They mint a valid session for any user they want, signed correctly, indistinguishable from a real login. It is total authentication bypass.

It ends up in the page the same way most frontend secrets do: someone put it where the client could see it. In Next.js the usual cause is prefixing it with NEXT_PUBLIC_, which is an explicit instruction to inline the value into the browser bundle and the hydration state. The secret that was supposed to live only in your server environment gets serialized into the HTML next to the harmless config.

string found in bundleverdict
NEXT_PUBLIC_API_URL: https://api.yoursite.compublic by designsafe
publishableKey: pk_live_51Hx...Stripe publishable key, meant to be publicsafe
AUTH_SECRET: "k3J9x2_realRandom32charSecret_pQ"session signing secret, server-onlyflagged
NEXTAUTH_SECRET: "8fLm_anotherForgeableSession_xZ2"forges a session for any userflagged
Two of these belong in the browser. The other two sign your sessions, and in the hydration blob they sign sessions for an attacker too.

The check looks for those exact key names with a real value attached, 16 or more characters of opaque randomness, and filters out placeholders like YOUR_SECRET_HERE or a value that just echoes the key name, so a config schema with empty slots doesn't fire. It wants the actual secret, not the shape of one.

The fix is the same shape for both

If either of these is in your page, treat the secret as already compromised, because it has been public for as long as the page has, and rotate it now. A signing secret you rotate invalidates every forged session along with the real ones, which is the point.

# .env, read at build time into the client bundle
NEXT_PUBLIC_AUTH_SECRET=k3J9x2_realRandom32charSecret_pQ
The NEXT_PUBLIC_ prefix is the instruction that ships it to the browser. Drop the prefix and the secret stays where it belongs.

Then keep the auth material server-side. A signing secret reads only in server code and never gets prefixed for the client. Identity tokens stay short-lived, carry a real exp, and don't get embedded in HTML that a logged-out visitor can read. If you're already auditing what your server-rendered props leak, this lives in the same blob as a leaked API key in NEXT_DATA; it's worth reading that one too.

This is checkable from the outside because it has to be: the danger is precisely that the value is in the public HTML. SurfaceCheckr reads the served page the same way the browser's view-source does, decodes the JWTs it finds to test whether they're forgeable or long-lived identity tokens, and scans the hydration state for a real signing secret under one of those key names. It never logs in and never tests a token against your server, because it doesn't need to. The exposure is the token being readable at all, and that's a thing you can confirm in seconds, before someone else does.

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.