Securealy
Check · Security · blocker · passive

No secret keys in client JavaScript

First-party bundles contain no secret-shaped strings: Stripe sk_, Supabase service_role, AWS, OpenAI, Anthropic, Twilio, SendGrid, Resend.

Severity
blocker (one caps the grade at D, two at F)
Tier
Passive: runs on any URL
Category
Security
Fix
Free, in full, below
Id
secrets-in-bundle

What this means

Passing means the JavaScript your site serves contains nothing shaped like a secret key: no Stripe sk_, no Supabase service_role key, and none of the secret key formats of the other providers we scan for. Failing means one of those strings is in a file any visitor can download. Whoever reads it holds the same power over that service as you do, with no sign-in required.

How we test it

We fetch your home page and the first-party JavaScript bundles it loads, then look for credential-shaped strings: Stripe secret keys, Supabase JWTs whose role claim is service_role, and the key formats of the cloud, model, messaging and email providers in the catalogue. Any secret key found is a fail and a launch blocker; there is no warn state, because a secret is either in the bundle or it is not. Publishable keys and the Supabase anon key are public by design and do not fail this check. Evidence in your report is always redacted to a prefix and an ellipsis; we never store or display the full value.

Why it matters

A secret key in the bundle is a full compromise of that service. A Supabase service_role key bypasses row level security on every table; a Stripe secret key can refund, charge and read every customer; an email key can send from your domain. The Fix Kit's own audit found sixteen publicly served script files carrying a live database password, four days after a check that searched for connection-string URLs had reported nothing exposed, because the password sat on its own PGPASSWORD= line. Search for the shape of a secret, not for the secrets you happen to know.

The fix

Rotation comes first, before any code change. A found secret is not a dead secret. Follow the order in the kit's templates/rotate-leaked-credential.md card (Module 3); a human does the dashboard steps.

  1. Identify by value, not by name. Search every repo and env store for the first eight characters: grep -rn "FIRST8CHARS" ~/projects -l. List every place it lives.
  2. Create the new credential at the provider first. Two live keys for ten minutes is fine; zero is an outage. Supabase: Project Settings, API, and prefer the new-format sb_secret_ keys, which revoke individually, because generating a new JWT secret invalidates both keys and signs every user out. Stripe: Developers, API keys, Roll key. Resend: API Keys, create new, delete old.
  3. Set it everywhere from step 1. In Vercel use printf %s 'VALUE' | vercel env add NAME production, never echo, which bakes a trailing newline into the value. Locally, edit .env.local.
  4. Deploy, wait for Ready, verify with one request. Then revoke the old key at the provider.
  5. Prove the old one is dead: one request with the old value, expect 401 or 403.

Then make the next leak impossible:

  1. Move the call that used the key to the server. Secrets are read from process.env inside an API function, never in code that ships to the browser. No STRIPE_* or SUPABASE_SERVICE_KEY variable may carry a NEXT_PUBLIC_ or VITE_ prefix; those prefixes mean "inline this into the bundle".
  2. Scan the build output, not just the source, because a secret inlined at build time is a secret in the browser even after the source file is fixed:
bash MODULE-03-lock-it-down/templates/secret-scan.sh ; echo "exit $?"
grep -rl "SERVICE_KEY\|STRIPE_SECRET" .next/static dist public 2>/dev/null

Expect exit 0 and no file names.

  1. Block the next commit that carries one. Install the kit's pre-commit hook from Module 3 step 10, which runs this on every commit and refuses it on a hit:
gitleaks protect --staged --redact
  1. Re-run our passive scan on your domain after the deploy. If the secret was ever committed, say so in your notes: it remains in git history, and rotation, not a history rewrite, is the real fix.

A deeper runbook for this class is in the Fix Kit, Module 03.

Prompt for your AI

Paste this into the coding agent that built the app.

Search the build output and the source for secret-shaped strings: run `bash MODULE-03-lock-it-down/templates/secret-scan.sh` and `grep -rl "SERVICE_KEY\|STRIPE_SECRET" .next/static dist public 2>/dev/null`, and show me the output with every value redacted to its first four characters.
For each hit, tell me the file, the variable name, and which service the key belongs to.
STOP here. I will rotate each key at the provider dashboard myself; do not touch any provider, and never print a full value.
After I confirm rotation, move every call that used a secret into a server-side function that reads process.env, and remove any NEXT_PUBLIC_ or VITE_ prefix from a secret's variable name.
Install the gitleaks pre-commit hook from Module 3 step 10.
Rebuild, then run the two scan commands again and show me the output; expected: secret-scan.sh prints nothing and exits 0, and the grep prints no file names.

Next

Grade your own URL

Every grade lists this check with its evidence for your site. Two free grades a day.