PRACTICAL GUIDE / client side secret exposure testing
The secret left source control but still shipped to the browser
Learn to find server-only values in bundles, source maps, storage, URLs, and responses, then build a release gate that avoids false alarms in CI.
In this guide6 sections
- Decide what the browser is allowed to know
- Trace the value through build and runtime paths
- Work through three failures that look different
- Tell a real leak from a false positive
- Separate a current build leak from stale delivery
- Fix the boundary and roll it out safely
- Know when a browser check is the wrong gate
What you will learn
- Decide what the browser is allowed to know
- Trace the value through build and runtime paths
- Work through three failures that look different
- Tell a real leak from a false positive
The repository search is clean, but the production JavaScript still contains yesterday's payment secret. A build-time substitution copied it into a minified chunk, and the CDN is doing exactly what it was configured to do: deliver that chunk to every browser.
Decide what the browser is allowed to know
Anything delivered to a browser must be treated as readable by the person operating it. Minification changes presentation, not access. Obfuscation raises effort but does not create a server boundary. A hidden input, hydration object, script tag, response body, storage entry, and URL are all client-visible channels.
That rule does not mean every key-shaped value is a secret. Payment providers, map services, analytics products, and identity systems often issue public identifiers or publishable keys for browser use. Their safety depends on the provider's design, permitted operations, origin restrictions, quotas, and the server-side credential remaining separate. A scanner cannot infer that contract from a prefix.
Create a small classification register before writing a gate. Each entry needs an owner, purpose, allowed locations, forbidden locations, rotation procedure, and an example pattern that does not contain the live value. Four categories are usually enough:
- Public configuration may appear in a bundle and response, but it still should not disclose private topology or administrative controls.
- Publishable credentials are intended for client use under a provider-defined restriction model. The paired private credential remains server-only.
- User session material may need to reach the user agent, but its storage and transport rules are part of the authentication design.
- Server-only secrets must never be present in browser-readable bytes, regardless of whether the UI renders them.
The test oracle comes from this register. “No strings containing key” is not an oracle. “The synthetic value classified as server-only is absent from every emitted client asset and runtime channel” is precise enough to automate.
Build systems create the first exposure route. Frontend frameworks commonly replace selected environment references while compiling client code. A developer may import a server configuration module into a component, use a public environment prefix on the wrong variable, or leave a secret fallback literal in a branch that minification retains. Searching authored source for the current value can miss all three. Test the same production build that will be deployed.
Source maps deserve explicit handling. MDN describes a source map as a JSON mapping between transformed code and its original form, and notes that maps can include original source content. Browsers discover maps through a SourceMap response header or a sourceMappingURL annotation. If a map is publicly fetchable, assume a user can inspect its content. If it is uploaded only to a restricted error service, the access boundary is different, but the file can still leak through CI artifacts or a misconfigured release.
Runtime creates more routes. Web Storage exposes localStorage and sessionStorage to scripts for the current origin. A query parameter can be copied into history, analytics, a screenshot, or a referrer under some policies. An API can serialize configuration into JSON. An error handler can render process settings only on a failed request. A test needs to observe these channels after the application is running, not only inspect the build directory.
Trace the value through build and runtime paths
Use synthetic markers rather than a real credential. Give the public test setting one marker and the server-only setting another. The public marker proves the build actually consumed the fixture. The server marker supplies the negative oracle. If neither appears, the test may have exercised the wrong build or configuration.
A useful marker is unique, nonfunctional, and recognizable without looking like a real provider credential. Record a safe case ID and a digest. Never put an active token in a bundle to “see whether it leaks.” A production-like build does not require production authority.
The first diagnostic scans emitted assets. This Python program uses only the standard library, walks the selected build directory, and checks raw, Base64, hexadecimal, and URL-encoded forms. It prints a digest prefix and file path, not the canary. Save it as scripts/scan_client_assets.py.
from __future__ import annotations
import argparse
import base64
import hashlib
import os
from pathlib import Path
from urllib.parse import quote_plus
TEXT_EXTENSIONS = {
".css",
".html",
".js",
".json",
".map",
".mjs",
".txt",
}
def marker_forms(marker: str) -> dict[str, bytes]:
raw = marker.encode("utf-8")
return {
"raw": raw,
"base64": base64.b64encode(raw),
"hex": raw.hex().encode("ascii"),
"url-encoded": quote_plus(marker).encode("ascii"),
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("build_dir", type=Path)
args = parser.parse_args()
marker = os.environ.get("SERVER_ONLY_CANARY", "")
if len(marker) < 24:
raise SystemExit("SERVER_ONLY_CANARY must be a nonfunctional test marker")
digest = hashlib.sha256(marker.encode("utf-8")).hexdigest()[:12]
findings: list[tuple[Path, str]] = []
for path in args.build_dir.rglob("*"):
if not path.is_file() or path.suffix.lower() not in TEXT_EXTENSIONS:
continue
data = path.read_bytes()
for representation, needle in marker_forms(marker).items():
if needle in data:
findings.append((path, representation))
for path, representation in findings:
print(f"marker={digest} form={representation} file={path}")
return 1 if findings else 0
if __name__ == "__main__":
raise SystemExit(main())Run the public-marker assertion separately. At least one approved emitted file should contain it. Otherwise a clean server-marker scan may be a false pass caused by the test building a different target, skipping environment substitution, or scanning the wrong directory.
Compressed deployment assets need thought. Build directories may contain both original and compressed copies, or compression may happen at the CDN. Scan uncompressed emitted files first because deterministic string search works there. If only compressed files are retained, decompress supported formats before scanning. Do not assume searching raw gzip bytes will find a plaintext marker.
Browser automation covers channels that a build scan cannot. The following Playwright test watches outgoing request URLs and bodies, waits for the application's bootstrap response, inspects both Web Storage areas in the page context, and checks the final URL. It collects only channel names in failure output.
import { expect, test } from '@playwright/test';
const serverOnlyCanary = process.env.SERVER_ONLY_CANARY;
test('server-only canary never reaches the browser', async ({ page }) => {
if (!serverOnlyCanary) {
throw new Error('SERVER_ONLY_CANARY is required');
}
const requestLeaks: string[] = [];
page.on('request', (request) => {
const searchable = [request.url(), request.postData() ?? ''];
if (searchable.some((value) => value.includes(serverOnlyCanary))) {
const url = new URL(request.url());
requestLeaks.push(request.method() + ' ' + url.origin + url.pathname);
}
});
const bootstrapPromise = page.waitForResponse('**/api/bootstrap');
await page.goto('/account');
const bootstrap = await bootstrapPromise;
const bootstrapBody = await bootstrap.body();
const storage = await page.evaluate(() => ({
local: Object.fromEntries(
Array.from({ length: localStorage.length }, (_, index) => {
const key = localStorage.key(index) ?? '';
return [key, localStorage.getItem(key) ?? ''];
}),
),
session: Object.fromEntries(
Array.from({ length: sessionStorage.length }, (_, index) => {
const key = sessionStorage.key(index) ?? '';
return [key, sessionStorage.getItem(key) ?? ''];
}),
),
}));
expect(requestLeaks, 'canary appeared in an outgoing request').toEqual([]);
expect(bootstrapBody.includes(serverOnlyCanary)).toBe(false);
expect(JSON.stringify(storage)).not.toContain(serverOnlyCanary);
expect(page.url()).not.toContain(serverOnlyCanary);
});This test is intentionally application-specific at one point: /api/bootstrap must be replaced with the response that initializes the tested page. A broad response listener can be useful for exploration, but indiscriminately reading every body adds latency, can consume large downloads, and may make test artifacts sensitive. Start with the responses most likely to carry configuration and expand from evidence.
Do not record the real marker in an assertion message. Most test frameworks print expected and received values when equality assertions fail. Boolean containment checks avoid that. Treat traces, videos, HAR files, and screenshots from the security run as sensitive until the canary scan proves otherwise.
Work through three failures that look different
The first worked failure is a secret in a compiled chunk. The authored module reads SERVER_PAYMENT_SECRET, but a client import pulls that module into the browser graph. The source repository may not contain the value because CI supplied it. The asset scanner reports a raw match in a hashed JavaScript file.
Evidence points to the build when the same marker appears in the emitted directory before deployment. Open the build manifest or bundler analysis to identify the importing entry point. Search for the marker only in the isolated test build, then remove the build after diagnosis. The fix is to move the operation behind a server endpoint and expose only the result the client needs. Renaming the variable is not a fix.
A near-miss looks almost identical: the match is a publishable payment key whose classification allows browser delivery. The correct response is to verify the public key's restrictions and ensure its private pair is absent, not to hide the publishable value with obfuscation. A severity decision made from the word “payment” alone produces noise and teaches engineers to ignore the scanner.
The second failure is original source embedded in a map. The JavaScript chunk is clean because an optimizer removed an unused fallback, but the map's sourcesContent still includes the original module and its literal test marker. The report points to a .map file rather than the executable chunk. Fetch the deployed map URL without an authenticated session to determine whether it is publicly available. Also inspect the SourceMap response header and sourceMappingURL annotation, because a renamed map can still be discoverable.
The fix has two possible shapes. Remove the secret from source history and rotate it, because disabling maps cannot make an exposed credential valid again. Then decide whether maps should be generated, uploaded privately, stripped of sourcesContent, or excluded from the public deployment. Each choice costs something. Removing public maps makes production debugging harder. Private upload adds release credentials and another access policy. Stripping source content may reduce the quality of stack trace reconstruction.
The near-miss here is a source map present in the build artifact but not in the public deployment. That is still a CI artifact handling question, but it is not the same client exposure. Check who can download the artifact and whether a later deployment step accidentally uploads the entire directory. Report the actual audience instead of claiming every generated map was served.
The third failure occurs only at runtime. A login callback receives a one-time authorization code, and frontend telemetry captures the complete current URL before the application removes the query parameter. The production bundle is clean. The Playwright request listener shows the canary in an analytics request path or body, while the final page URL may already be sanitized.
The diagnostic order matters. Capture outgoing requests before navigation. Record the receiving origin and path without retaining the secret. Check whether the browser initiated the request before or after application code changed history. Review the site's Referrer-Policy too, but do not treat that header as the primary fix for placing sensitive data in a URL. A policy can limit cross-origin referrer disclosure; it cannot remove the value from history, screenshots, same-origin logs, or frontend instrumentation.
A short-lived one-time code has a different risk from a long-lived service key. It should still be handled according to the identity provider's flow. The application can exchange the code promptly and replace the visible URL without preserving it in history. This browser-side function demonstrates the sequence; the server endpoint must validate the code and establish the session safely.
export async function finishSignIn(): Promise<void> {
const current = new URL(window.location.href);
const code = current.searchParams.get('code');
if (!code) {
return;
}
const response = await fetch('/api/session/exchange', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code }),
});
if (!response.ok) {
throw new Error('Session exchange failed');
}
current.searchParams.delete('code');
current.searchParams.delete('state');
const cleanUrl = current.pathname + current.search + current.hash;
window.history.replaceState({}, '', cleanUrl);
}This code does not make an arbitrary URL secret safe. It is appropriate only when the protocol specifies a short-lived code and the server validates it. It also creates a trade-off: removing the state value from the address bar can make callback debugging harder, so capture a nonsecret correlation ID on the server instead.
A fourth runtime route is Web Storage. If the application puts a bearer token in localStorage, any script executing in that origin can read it. Moving the value to an HttpOnly cookie prevents frontend JavaScript from reading that cookie through document.cookie, as documented by MDN. It does not eliminate cross-site scripting impact, and it introduces cookie-specific requirements such as Secure, SameSite, Path, lifetime, and request-forgery protection. Choose the session architecture with the security team rather than turning one storage assertion into a universal design rule.
Tell a real leak from a false positive
An exact synthetic marker in browser-readable output is strong evidence because classification and expected absence were set before the run. A generic high-entropy match is only a lead. It may be a content hash, fixture, public identifier, source map mapping, or compressed data. Confirm ownership and capability before escalating it as a credential.
Use provenance to narrow the cause. A match in the production build exists before the browser starts. A match only in the deployed response can come from runtime server configuration, edge injection, or a different deployment artifact. A match only in localStorage was written by page code or an earlier session. A match only in an outgoing request may have come from the current page, a service worker, an extension in a nonisolated browser profile, or a redirect.
Run security checks in a fresh browser context with no extensions and controlled test data. Clear storage by creating a new context rather than deleting selected keys after navigation. Old data can make a repaired build look vulnerable. Conversely, clearing too late can erase the evidence you wanted to observe.
Record the build identifier, deployment identifier, route, user role, browser project, storage keys, request origin and path, response content type, file digest, and classification record. Do not attach raw bodies unless access and redaction have been reviewed. A useful finding says “server-only canary digest 4f3a... appeared in account-bootstrap.js and /api/bootstrap response,” not “possible secret found.”
Error paths deserve a separate case. Request a nonexistent record, submit invalid input, and exercise an unauthorized route. Framework development overlays and verbose exception serializers should not be enabled in production, but configuration mistakes happen. The success response may be clean while a 500 page includes environment data. Make assertions against the deployed production mode, not a local development server.
Cache state can confuse remediation. A CDN or service worker may serve an older chunk after the new release is clean. Capture response URLs and content digests. Check both a fresh context and a repeat visit. If an old asset remains reachable by its hashed URL, rotate the secret even if current HTML no longer references it. The credential's exposure history matters more than the active entry point.
Retries do not erase deterministic findings. A bundle match that disappears on retry usually means the test scanned a different directory, a deployment changed underneath it, or cache state differed. Preserve both build IDs. Runtime channels can vary by route and session, so compare the exact requests observed rather than accepting a green final status.
Separate a current build leak from stale delivery
Two failures can produce almost the same scanner line. In the first, the current compiler substitutes the server-only marker into a client module. In the second, the current build is clean, but the release process copies an older chunk into the upload set, or a previously published object remains reachable. Both reports can name a hashed JavaScript file and the raw representation. Changing an import fixes the first. Changing artifact selection or cache retirement fixes the second.
Use a different synthetic marker for two consecutive diagnostic builds, each in an isolated destination. Retain the emitted-file manifest and a cryptographic digest of each candidate file. Active substitution makes the marker follow the build: the second marker appears in the second output, and the deployed file digest matches it. Stale delivery leaves the earlier marker in deployed bytes or a deployed digest absent from the current manifest. That evidence moves the investigation to release packaging, an edge cache, or a service worker. If the current marker is present in both isolated output and deployed bytes, stale delivery is not an adequate explanation.
Read marker first: its digest must match the synthetic value assigned to that run. form identifies the literal or encoded representation, and file must resolve beneath the new build destination. A healthy result has no server-marker lines while the public-marker assertion succeeds. A broken current build prints the current digest and output path. A previous run's digest is a real exposure but misleading evidence for a source import. A current-looking filename outside the manifest points to packaging, not the current compiler.
Fix the boundary and roll it out safely
Remove server-only values from the client dependency graph. Put privileged operations behind authenticated and authorized server endpoints. Return the narrow data the page needs, not an environment object. Review server rendering and hydration carefully: code can execute on the server yet still serialize its result into HTML sent to the browser.
Make public configuration explicit. Maintain an allowlist of names permitted in client builds and fail when a server-only canary appears. A denylist of words such as secret or token misses renamed values and generates false positives for legitimate public fields. Pair the server-only negative assertion with a public-marker positive assertion so the test proves it scanned the correct build.
Wire the asset gate immediately after the production build and before upload. This shell example assumes the repository already has a production build command and that CLIENT_BUILD_DIR points to its emitted browser assets.
#!/usr/bin/env bash
set -euo pipefail
: "${CLIENT_BUILD_DIR:?set CLIENT_BUILD_DIR to the emitted client directory}"
: "${SERVER_ONLY_CANARY:?set SERVER_ONLY_CANARY to a synthetic marker}"
: "${PUBLIC_BUILD_CANARY:?set PUBLIC_BUILD_CANARY to an approved public marker}"
npm run build
SERVER_ONLY_CANARY="$SERVER_ONLY_CANARY" python3 scripts/scan_client_assets.py "$CLIENT_BUILD_DIR"
if ! rg --fixed-strings --quiet "$PUBLIC_BUILD_CANARY" "$CLIENT_BUILD_DIR"; then
echo "public marker was not found; the expected build may not have been scanned" >&2
exit 1
fiPassing markers on the environment avoids command-line expansion, but the build process can still read them. Use only synthetic values. Disable verbose environment dumps in this job. Retain the scanner's file list and safe digest, not the build containing an intentional leaky fixture.
Add the runtime test after the preview deployment is reachable. Cover one unauthenticated page, one authenticated bootstrap path, one error path, and one callback or redirect flow if the product has it. Keep the set small. Broadly reading every response in the entire end-to-end suite increases runtime and may copy customer-like data into test output.
For an existing suite, start in report-only mode with generic pattern findings and blocking mode for exact server canaries. Triage the existing inventory with owners. Mark approved public identifiers in the classification register rather than scattering ignore comments across generated files. Once the server-only canary cases are stable, make them release blockers.
Land the supporting pieces in dependency order. First add classification, safe digest logging, and artifact retention, so a failure does not publish the marker or vanish with an expired job. Next prove the scanner fails on a known-marker fixture, then connect the production build and public-marker control. Only after those checks identify the intended output should the asset scan block upload. Add preview browser cases afterward, because they depend on a known build and deployment identity.
The first break in a mature suite is often fixture plumbing: validation rejects the nonfunctional marker, the job builds a test target, or cleanup removes output before scanning. Those are separate from a security finding. The rollout works when the fixture fails on demand, the public marker identifies the intended build, unchanged clean input passes repeatedly, and a deployed file digest traces back to the manifest. Do not call intermittent green runs success.
The clean-build comparison has a concrete price. A second isolated production build repeats compiler work and lengthens the failing pipeline, while retaining manifests and file digests consumes artifact storage. Keep the ordinary release gate to one build and trigger the second build for diagnosis, unless the product's cache risk justifies paying that cost on every release.
Ownership should follow the evidence. The application team owns imports, serialization, and browser storage. Release engineering owns the upload set and manifest. The platform or CDN team owns a reachable stale object. Security owns credential classification and the rotate-or-revoke decision. A handoff needs the safe marker digest, build and deployment identifiers, representation, path, local and served file digests, manifest membership, reproduction steps, and rotation status. Without both digests, teams can pass the same ticket back and forth while examining different bytes.
After a confirmed real exposure, the migration includes response work. Rotate the credential. Remove it from current builds. Purge or expire reachable assets where supported. Review public source maps, CDN caches, CI artifacts, browser traces, error reports, and deployment archives. A code fix without rotation leaves anyone who downloaded the old bytes with a usable value.
The costs should be visible. Production builds make the gate slower than a source scan. Exact canaries find known paths but not unknown credentials. Generic scanners need human classification. HttpOnly cookie sessions require server and cross-site request controls. Private source maps add operational dependencies. These are acceptable costs when they are tied to the risk being reduced.
Know when a browser check is the wrong gate
Do not use a browser test as the first defense against build-time injection. A deterministic asset scan is faster, simpler, and tells you the file before deployment. Keep browser coverage for runtime responses, storage, URLs, redirects, and behavior added by the deployed environment.
Do not block a release solely because a pattern resembles an API key. Determine whether the provider documents it as publishable, what operations it permits, and how it is restricted. A public identifier can still be abused through quota or origin misconfiguration, but that is a different finding from a leaked server credential.
Do not disable every source map without discussing incident diagnosis. Public maps containing sensitive source are a problem. Restricted maps used by an error service may be an intentional trade-off. Test content and access separately, then choose generation and publication policies that match the product.
Do not move a token from localStorage to a script-readable cookie and call the issue fixed. JavaScript can still read a non-HttpOnly cookie. Even an HttpOnly cookie does not solve authorization flaws, unsafe scripts, or request-forgery concerns. The session design needs more than a storage-location assertion.
Do not place a real secret in a Playwright trace to validate redaction. A trace is retained evidence and can become a second exposure. Use a powerless marker, restrict artifacts from the security job, and scan them before wider publication.
Most importantly, do not treat disappearance from the latest bundle as incident closure. Browser assets are copied, cached, archived, and sometimes indexed. Once a live credential has crossed the server boundary, rotate it. Testing proves the replacement path is clean; it cannot make the old value private again.
This technique does not catch a backend endpoint that keeps its credential server-side but performs a privileged action for an unauthorized caller. No secret bytes need to reach the browser for that authorization defect to be exploitable. Test endpoint authorization separately.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official developer.mozilla.org reference
developer.mozilla.org
Primary documentation selected and verified for the claims in this guide.
- 02Official developer.mozilla.org reference
developer.mozilla.org
Primary documentation selected and verified for the claims in this guide.
- 03Official developer.mozilla.org reference
developer.mozilla.org
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
How can I check a JavaScript bundle for exposed secrets?
Build the production client with a synthetic server-only canary, then scan every emitted script and source map for the raw marker and the encodings your build can produce. A match is actionable because the test knows the value's classification and expected absence.
Is every API key found in frontend code a security defect?
No. Some identifiers and publishable keys are designed to be sent to browsers, while their paired server credentials are not. Confirm the provider's documented key type, scope, and restrictions before assigning severity.
Does an HttpOnly cookie solve client-side token exposure?
An HttpOnly attribute prevents frontend JavaScript from reading that cookie through document.cookie, which removes one common theft route. It does not fix script injection, cross-site request risks, excessive cookie scope, or a server that returns the same token in JSON.
Should production source maps always be disabled?
Not automatically. Public maps can reveal original source and embedded content, but private maps can be valuable for error diagnosis. Test what each map contains, who can fetch it, and whether your deployment publishes a SourceMap header or mapping annotation.
What should I do after a secret has shipped in a client bundle?
Rotate or revoke it as an incident response step, because old assets may remain in caches and deployment history. Remove the value from the client build path, scan the replacement build, and review accessible maps, logs, and artifacts for the same credential.
RELATED GUIDES
Continue the learning route
GUIDE 01
Configure Client Certificates in Playwright Test Projects
Master Playwright client certificates with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 02
Debug Playwright Client Certificate Authentication Failures
Master debug Playwright client certificates with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 03
Agent Side-Effect Containment Interview Scenarios for Senior AI Testers
Practice 19 senior AI QA scenarios on tool authorization, approvals, idempotency, dry runs, retries, compensation, isolation, and kill switches.
GUIDE 04
Redact Secrets from Playwright HAR and Trace Evidence
Learn Playwright HAR trace secret redaction with working code, failure cases, debugging steps, and CI evidence for reliable QA automation in practice.