Client Exposure
Whatever a bundler ships to the browser is public. isitsecure reads the site's own JavaScript, filtered of vendor bundles, and looks for three kinds of leftovers: a Supabase service_role key, which bypasses Row-Level Security; URLs that point at localhost or staging; and process.env names the build never replaced. It sends no requests of its own.
Three checks over one string
The input is a single string: the concatenated content of the site's first-party scripts. Vendor and framework bundles were removed by a shared filter before the scanner ever sees them, so a minified library cannot trigger a finding. Three independent checks run over that string.
1The scanner receives the first-party JavaScript of the page as one string. If it is empty there is nothing to do. Only first-party code is read: HTML, vendor bundles and third-party scripts are out of scope.
2Check one finds anything shaped like a JWT, decodes its payload, and keeps it only when the role claim is service_role. That is the one CRITICAL finding. The next scene walks through it.
3Check two matches URLs whose host is localhost, a loopback or private address, a .local, .internal or .test name, or a host that starts with staging, dev, qa and similar. A production bundle should not know those exist, so each is a LOW.
4Check three finds process.env.NAME and import.meta.env.NAME left in the shipped code. A bundler should have replaced them. The leftover leaks the variable's name, LOW. A name with a public prefix such as NEXT_PUBLIC_ is meant to ship and is skipped.
5Findings carry the page URL as their endpoint. Each check keeps at most 10 and reports a given URL, token or variable name once.
This scanner has no page under docs/scanners/. Everything here is taken from isitsecure/engine/scanners/client_exposure_scanner.py and the README scanner-table row ("Secrets in client JS: Supabase service_role keys, internal URLs, unreplaced env placeholders"). The regexes, the public-prefix list, severities and confidences are read directly from that file.
Why one JWT is Critical and another is ignored
A Supabase project ships two keys. The anon key is designed to sit in the browser, because Row-Level Security limits what it can do. The service_role key skips RLS entirely. The two look identical until the payload is decoded, so the scanner decodes it.
1The pattern looks for three base64url parts joined by dots, where the first two start with eyJ (the encoding of {"). Each distinct token is processed once.
2The middle part is base64-decoded into JSON. The signature is not verified: the scanner only needs to read the claim, not to trust the token.
3The role claim is lowercased and compared. anon is the key every Supabase front end ships, so it is ignored. So is any token that fails to decode or has another role.
4A role of service_role is CRITICAL. The finding keeps a redacted form of the token, never the full secret, and tells the owner to rotate the key and use it server-side only.
The finding text is blunt: the service_role key "bypasses Row-Level Security and grants full read/write access to the entire database. Anyone viewing the page can extract it." Rotation is the only fix once it has shipped, because the old key is already in every cached copy of the bundle.
What stays quiet
Two of the checks run on names and URLs, which are easy to over-match. These rows show what the code lets through and what it reports.
1Five strings the scanner might meet in a bundle. Two sets of rules decide them: a list of env-var prefixes that are allowed to ship, and a pattern for hosts that should not appear in production.
2A bare process.env.STRIPE_SECRET means the bundler left a server-side name in the output. The name must be upper case, at least three characters, and have no public prefix. LOW, because it leaks a name, not a value.
3NEXT_PUBLIC_ variables are copied into the bundle on purpose. Eight prefixes are allowed: NEXT_PUBLIC_, VITE_, REACT_APP_, PUBLIC_, VUE_APP_, GATSBY_, EXPO_PUBLIC_, NUXT_PUBLIC_.
4The import.meta.env form is handled by the same rule, so a VITE_ name is quiet too.
5A URL whose host is localhost, 127.0.0.1, 0.0.0.0, a 10.x, 192.168.x or 172.16-31.x address, or a .local, .internal or .test name is reported at LOW, with confidence 0.75.
6An ordinary production URL matches none of those patterns and is not reported. The scanner does not judge whether an API host is correct, only whether it looks like an internal one.
Vulnerable vs fixed client code
The fix is to keep the privileged key on the server and let only intentionally public values reach the bundle.
// shipped to every visitor in app.js
const admin = createClient(URL, "eyJhbGciOi…") // payload: {"role":"service_role"} CRITICAL
const api = "http://localhost:4000/api" // internal URL LOW
const db = process.env.DB_URL // un-replaced LOW// browser: only the anon key, protected by Row-Level Security
const supabase = createClient(URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
const api = "https://api.example.com/v1"
// server only (API route, edge function, backend): the service_role key
// lives in a server environment variable and never reaches client code.
// If a service_role key has already shipped, rotate it.
const admin = createClient(URL, process.env.SUPABASE_SERVICE_ROLE_KEY)Reference: what the code does
| Item | Value | Where it comes from |
|---|---|---|
| Input | first-party JavaScript only, vendor and framework bundles filtered out (Next.js /_next/static/chunks/app|pages/ page chunks, webpack app bundles and sibling-brand CDN domains count as first-party); empty input returns nothing | snapshot.first_party_js_content |
| service_role JWT | token matches eyJ….eyJ….sig; payload role lowercased equals service_role → CRITICAL, confidence 0.98; anon ignored | _JWT_RE, _check_service_role_jwt |
| Internal URL | http(s):// then localhost, 127.0.0.1, 0.0.0.0, 10.x, 192.168.x, 172.16-31.x, *.local / *.internal / *.test, or a host beginning with staging, stg, dev, qa, internal, preprod or uat → LOW, confidence 0.75 | _INTERNAL_URL_RE |
| Un-replaced env | process.env.NAME or import.meta.env.NAME, NAME upper case, 3+ characters, no public prefix → LOW, confidence 0.7 | _PROCESS_ENV_RE, _IMPORT_META_ENV_RE, _PUBLIC_ENV_PREFIXES |
| Caps | at most 10 findings per check; one finding per distinct token, URL or variable name | _MAX_FINDINGS_PER_KIND |
| Evidence | token shown as first 16 characters, ..., last 6 | redacted in _check_service_role_jwt |
| Finding | category CLIENT_EXPOSURE, source DAST_URL, endpoint = page URL | the three DeepFinding builders |
| Network | none | module docstring |
| Scan depth | runs at both quick and deep | factory.py DAST list |
It decodes only JWTs, so a service_role key that has been split, encoded or loaded at runtime is not found, and other secrets (cloud keys, private API tokens) are left to the secret scanners. It does not read inline scripts in the HTML or third-party bundles. The host-prefix rule for staging names matches any host that merely starts with dev, qa and the like, for example developers.example.com, so a LOW here deserves a glance at the URL.