squirrelscan
GitHub

Leaked API key in client-side code: how to find one

An API key shipped in HTML or a bundle can be read by anyone who views source. squirrel scans every page and script against more than 90 key patterns.

What a leaked secret is

A leaked secret is a credential that reaches the browser: an API key in a bundle, a database connection string in server-rendered HTML, a token in an inline script. Anything the browser can load, anyone can read, and automated scrapers do read it. A leaked key is used within hours of being published.

Not every key in client-side code is a leak. Stripe publishable keys, Firebase and Google browser keys, OAuth client IDs, Supabase anon keys and Sentry DSNs are designed to ship publicly and are safe as long as their server-side restrictions are configured. The dangerous ones are secret keys, service-role keys, personal access tokens and anything with a database password in it.

What squirrel checks

The rule scans the HTML of every crawled page, every inline <script>, and the content of external JavaScript files the crawl fetched. It matches against more than 90 patterns covering AI providers, cloud platforms, payment processors, databases, source hosts, messaging services and generic assignments such as api_key = "...". Most carry a literal marker of their own, a prefix such as sk-, ghp_ or AKIA, a URL host or a PEM header, and run on all content. The rest are bare runs of hex or alphanumeric characters that only count next to the provider’s name, such as datadog or cohere, and only when assigned to a key that is not a digest or hash. Findings are deduplicated by value across the whole site, and each reported value is masked. It emits these checks:

  • leaked-secrets-critical fails when a server-only secret sits in a browser-served script. Message: {count} critical server-only secret(s) shipped to every visitor in browser-served scripts (rotate now). Items have the same shape as the high check. See Server-only secrets in a script are critical.
  • leaked-secrets-high fails when a high-confidence pattern matched. Message: {count} high-confidence leaked secret(s) detected. Each item reads as the pattern name plus the masked value, labelled Found in {location} ({url}). Severity error.
  • leaked-secrets-medium warns on medium-confidence matches, which are mostly the generic assignment patterns. Message: {count} potential secret(s) detected (verify manually).
  • leaked-secrets-public reports info for keys that are public by design. Message: {count} public client-side key(s) found (public by design — verify usage restrictions are configured). These never count as leaks.
  • leaked-secrets-info reports info for tokens whose own structure says they are not a credential worth stealing: a JWT whose exp claim is in the past, or a first-party session token that expires within 30 days (the JWT row in Token structure raises or lowers confidence has the exact conditions). Message: {count} expired, session-scoped or signature-bound token(s) found (informational, not counted as a leak). These never count as leaks either.
  • leaked-secrets-unscanned reports info when the scan of one or more pages ran past the rule’s time budget. Message: {count} page(s) were not scanned for secrets because the scan exceeded its time budget, so no finding on them is not a clean result. It lists up to 20 of those pages.
  • leaked-secrets passes when no critical, high- or medium-confidence match remains and every page was scanned. Message: No leaked API keys or secrets detected.

The location on each item is html, inline-script or external-script, with the suffix (base64) when the value was only found inside a base64 blob that the page decodes at runtime. Masking keeps the first six and last four characters when the value is longer than twelve, and the first four otherwise, so the report identifies the key without republishing it. Weight is 10, the highest of any rule in the Security category.

Server-only secrets in a script are critical

Some secrets only ever belong on a server. When one of them is read from a browser-served script, an external JavaScript file or an inline <script> (including a base64 blob decoded from one), every visitor already has it. squirrel reports that hit under leaked-secrets-critical instead of the high or medium check it would otherwise land in. It is the same finding with a higher severity, never a second one, and it reads “shipped to every visitor” in the check message.

The server-only classes are database connection strings (PostgreSQL, MySQL, MongoDB, Redis), Supabase service-role and secret keys, Stripe sk_live_ keys, Clerk secret keys, AWS secret access keys, Azure storage keys and DigitalOcean tokens. The same class found only in page HTML keeps its current severity, and publishable keys (Stripe pk_live_, the Supabase anon and publishable keys) are never escalated. Neither is a DigitalOcean Spaces access key id, which is the public half of the key pair, nor a value a tutorial prints: a value containing EXAMPLE in capitals, which is how AWS’s documented example credentials end, an Azure connection string whose AccountKey is not a real 88-character key (such as <account-key>), a connection string that names a placeholder host or database, and a database URL whose every host is localhost, a 127.x.x.x address or [::1]. Those still report under their own check. This matters for a docs site whose framework inlines the page content into <script> payloads, as Next.js does, where every code sample on the page is also in a script. A value inlined into a bundle under a public-variable prefix such as NEXT_PUBLIC_ is reported by the pattern that matches its value, not by its name, so it escalates only when its value is one of the classes above.

Encoded content is decoded before scanning

A key does not have to be spelled out in the bytes the crawler receives. Before the patterns run, the rule decodes four encodings a page uses that a browser undoes anyway:

  • HTML character references in the serialized page, so a JSON object stored in an attribute as data-state="{&quot;apiKey&quot;:&quot;...&quot;}" is read as the JSON it is. Such a finding reports at location html.
  • \uXXXX, \u{X} and \xXX escapes in script text, so a string literal a minifier or an obfuscation pass wrote as escapes is read as the characters the engine sees. A \\u0041 (an escaped backslash followed by u) is left alone.
  • Percent-encoding, so a settings object embedded as data-settings="%7B%22key%22%3A%22pk_live_...%22%7D" or passed to decodeURIComponent() in a script is read as the JSON it is. A run has to carry at least three %XX escapes and decode to text; a % not followed by two hex digits is never decoded. Decoded in place, so the finding keeps the plain location.
  • Standalone base64 blobs of 64 or more characters that decode to text, such as a config object assigned to a global or stored in a data attribute. The decoded text is scanned as content of its own, two levels deep, and anything found reports with the (base64) location suffix. The payload of a data: URI and the value of an integrity hash are never decoded, and a blob that decodes to binary is skipped.

Token structure raises or lowers confidence

Several formats carry enough structure to confirm or downgrade a match without asking anyone. The rule never contacts a provider to verify a credential it found on someone else’s site. What it decodes rides along on the item as meta fields in the JSON, LLM and XML report outputs and in the MCP get_report result:

FormatWhat is decodedEffect on the finding
AWS access key idThe account id encoded in the id (accountId, prefix)An id whose suffix does not decode to a 12-digit account id is medium, not high
GitHub classic tokens (ghp_, gho_, ghu_, ghr_)The CRC32 checksum in the last six characters (checksum)A wrong checksum drops the finding; a right one stays high
JWTs, any algorithm (JSON Web Token pattern)Header and payload claims (issuer, role, projectRef, expiresAt, expired)Supabase service_role is high as Supabase Service Role JWT; Supabase role: anon is public as Supabase Anon Key; an expired exp moves the finding to leaked-secrets-info; a token issued by a *.myshopify.com domain is public as Shopify Storefront JWT; a session token that stands alone (an exp within 30 days of iat or of now, no admin scope, no issuer naming Auth0, Clerk, Firebase, Cognito, Okta or Supabase, and not inside an Authorization: Bearer literal or under a key such as apiKey or secret) is reported as Session Token (JWT) in leaked-secrets-info with kind: session, whether or not it names a subject; any other token from another issuer is medium at most, reported as JSON Web Token, and never labelled Supabase; an admin scope is high. Which service issued the token comes from the decoded iss, never from the header
Algolia secured API keysThe restriction parameters in the key (keyType, restrictIndices, validUntil, filters, …)A key with restrictions is public; one with none is medium; a base64 string near the word algolia that is not a secured key is dropped. A 32-hex key near the keyword stays medium with keyType: admin-or-search-shaped
Stripe keyskind (secret or publishable) and mode (live or test)Informational
Slack tokenskind and the numeric workspace id segmentsA token without the numeric ids is medium
Sentry DSNsorg and projectId from the ingest hostInformational

Vendor identifiers in script URLs are not reported

Some tag vendors put a public project identifier in the query string of the script their install snippet loads. It ships to every visitor by design, so a value in exactly that parameter is not reported at any level, and does not appear under leaked-secrets-public either. The match has to be the whole parameter value, on the exact host and path below:

VendorHostPathParameter
Midacdn.mida.so, cdn-eu.mida.so/js/optimize.jskey
Klaviyostatic.klaviyo.com/onsite/js/klaviyo.jscompany_id
Google reCAPTCHAwww.google.com, www.recaptcha.net/recaptcha/api.js, /recaptcha/enterprise.jsrender

This applies to the URL of a src or href in a page or script, and only to that value. The same parameter on any other host, a lookalike host (cdn.mida.so.example.net, a host with a port or userinfo), another parameter on the vendor’s URL, and the same value anywhere else on the page are all still scanned as usual.

To extend the list, add a row to PUBLIC_URL_IDENTIFIERS in packages/rules/src/security/leaked-secrets.ts with the exact hosts, paths and parameters, and add the vendor to the table-driven tests in packages/rules/tests/leaked-secrets.test.ts. Add a vendor only when its own documentation says the identifier is public and shows it in a URL.

Labels, templates and documentation placeholders are not values

A generic assignment such as password: "..." names a credential, but the value under it is not always one. These values are not reported:

  • A label in a compiled JSX string that ends in the credential word, such as children:"Password:". The quote that closes the label is not read as the start of a value, so the match no longer spans into the code that follows. A real assignment after the label is still read.
  • A translated password label, such as Passwort, Contraseña or Mot-de-passe. A single word with an accented letter and no digits or symbols counts as a label.
  • A template or JSX expression in place of the value, such as {form.password}, {{ user.password }} or <%= config.secret %>.

A connection string whose password is a documentation placeholder is not reported. Placeholders include password, <password>, ${DB_PASSWORD}, your_password and a mask of *, • or . characters:

postgresql://username:password@host:5432/dbname # pragma: allowlist secret

A real-looking password in a connection string whose host or database is itself a placeholder (@host:5432/dbname) is kept for review at medium. Its meta carries placeholder: host or placeholder: database.

A provider name needs the whole token

A provider name is given only when the pattern matches the whole token, delimited on both sides. A shape that runs on past its token is not that provider’s token. Examples are AKIA plus 16 characters followed by a word or by digits, and AKIA..._PROD.

Such a token may still be a credential. A fixed-length format can grow, a key can carry a suffix, and text with its markup stripped can run a token into the next word. So the whole token is reported at medium as Secret Key (provider unknown). Its meta carries provider: unknown and the pattern it resembles. A token is dropped only when the provider’s own structure rules out the matched part, such as a GitHub token whose checksum fails.

sk_live_ and sk_test_ are Stripe’s prefixes, and other vendors copy them for their own keys. A key such as sk_live_..._acmewidget cannot be a Stripe key. It is still reported, at medium, as Secret Key (sk_live_ prefix, provider unknown). Its meta carries prefix, provider: unknown and resembles: Stripe Live Key, so the attribution says what is and is not known.

Key prefixes that say server or client

Some providers write the kind of key into its prefix. Statsig documents server secret keys (secret-) as confidential and client SDK keys (client-) as meant for public applications. A value under a generic key such as serverSideApiKey or clientSideApiKey is classified by its prefix when statsig appears in the key or up to 120 characters before it:

ValueProvider namedReported asCheck
secret- plus 32 to 64 letters and digitsYesStatsig Server Secret Keyleaked-secrets-high
client- plus 32 to 64 letters and digitsYesStatsig Client SDK Keyleaked-secrets-public
secret- plus 32 to 64 letters and digitsNoSecret Key (secret- prefix, provider unknown)leaked-secrets-medium
client- plus 32 to 64 letters and digitsNoThe generic assignment, as beforeleaked-secrets-medium

A finding says what the page shows, not what the key can do. Its meta carries exposure: present in content served to the browser, validity: not tested and scope: not tested. The rule never calls the provider to check a key, so a revoked or narrowly scoped key reads the same as a live one. A key passed straight to a call, such as Statsig.initialize("secret-..."), is not classified, because a call argument is not a value position.

To add a provider, add a row to PROVIDER_KEY_PREFIXES in packages/rules/src/security/leaked-secrets.ts. Add one only when the provider’s documentation says which prefix is confidential and which is public.

How to fix it

// server route, not the client bundle
const res = await fetch("https://api.example.com/v1/data", {
  headers: { Authorization: `Bearer ${process.env.EXAMPLE_SECRET_KEY}` },
});

Move the call behind an endpoint you control and read the key from an environment variable that the bundler never inlines. In a framework with a public-variable prefix, check that the leaked name does not carry it. Rotate the exposed credential before anything else: it has been public for as long as the page has.

A medium-confidence hit on a variable named apiKey holding a placeholder is a false positive. Confirm the value before rotating.

Rule IDsecurity/leaked-secrets
CategorySecurity
ScopeSite-wide
Severityerror
Weight10/10

Enable / disable

Disable this rule

[rules]
disable = ["security/leaked-secrets"]

Disable all Security rules

[rules]
disable = ["security/*"]

Enable only this rule

[rules]
enable = ["security/leaked-secrets"]

enable replaces the default ["*"], so this is the only rule selected. Leave disable out: a rule that matches any disable pattern is skipped even when enable names it. Only an enabled setting under a rule’s own [rule_options] table overrides both lists.

Security findings ship in every audit next to the SEO, performance and agent experience rules. See Website security scan with AI for how an agent works through a report.

Glossary: Leaked secrets.

References

Check your site

Run squirrel audit https://example.com and open the Security section of the report. Every match is listed with its pattern name, masked value and the page or script it came from. Signed-out local audits need no account and cost nothing. Signed-in audits spend credits.

Type to search…

↑↓ navigate↵ openesc close