Any value a frontend build reads from an environment variable can end up as plain text in the JavaScript every visitor downloads, and a published source map can hand over the original source, comments included. Modern frameworks make this easy to do by accident: a variable with the right prefix is copied into the bundle at build time, and from then on it is public. This guide explains how build-time inlining and source maps expose keys, how to tell a publishable key from a real secret, how to find and rotate what has leaked, and how to move secret calls behind a server. It is for frontend and platform engineers, application security teams and anyone triaging an "API key exposed in JavaScript" finding. For how environment variables and .env files behave on servers, see the Environment Variables and .env Files guide; this page covers only the browser side.
Key takeaways
- Prefixed means public. Vite exposes
VITE_variables, Next.js inlinesNEXT_PUBLIC_variables and Create React App embedsREACT_APP_variables into the bundle. Each framework's documentation says not to put secrets in them. - Build-time values are frozen and copied everywhere. An inlined key sits in every cached copy, CDN edge and archived snapshot of the bundle, so deleting it from the code does not withdraw it. Only revocation does.
- Source maps can publish your source. A full map with
sourcesContentcarries original files, comments and dead code. Keep production maps off the public web server or upload them privately to error tooling. - "Public" keys still need restrictions. Publishable keys (Stripe
pk_, Firebase, Maps browser keys) are designed for the browser, but only when restricted by API, referrer and quota. Enabling a new API on the same project can turn them into real credentials. - Anything that needs a secret runs on a server. Use a backend, serverless function or backend for frontend that holds the key and exposes only the narrow operation the browser needs.
How secrets get into the bundle
Bundlers replace references to environment variables with literal values when the build runs, so the bundle contains the value itself, not a lookup. Next.js describes this as replacing all references with a hard-coded value that is "inlined into any JavaScript sent to the browser", and notes the value is frozen at build time. Vite says its variables are statically replaced at build time. The prefix is a safety gate: variables without it stay out of client code, but the gate only works if nobody puts a secret behind the prefix.
| Framework or tool | What reaches the browser | Mechanism | What the documentation says |
|---|---|---|---|
| Create React App | Variables prefixed REACT_APP_ (plus NODE_ENV) | Embedded during the build into static files | "Do not store any secrets (such as private API keys) in your React app" |
| Next.js | Variables prefixed NEXT_PUBLIC_; others stay on the server | Inlined at next build; dynamic lookups such as process.env[name] are not inlined | Server code can read unprefixed variables at runtime during dynamic rendering |
| Vite | Variables prefixed VITE_, or the custom envPrefix | Statically replaced on import.meta.env at build | "VITE_* variables should not contain sensitive information such as API keys"; use a backend or serverless function instead |
webpack DefinePlugin and similar | Whatever the config maps in | Text replacement at compile time | No prefix gate: a config that maps all of process.env exposes everything |
| Hard-coded config files | Any constant imported by client code | Bundled like any other module | Treated as source code, so CWE-798 and CWE-540 apply |
Three less obvious routes cause many findings: a shared config module imported by both server and client code, which pulls the server values into the client graph; a CI pipeline that injects every secret into the build environment and a prefix rename that later exposes one; and server-side rendering that serialises configuration into the HTML page for hydration.
Source maps: what they reveal and how to ship them safely
A source map links minified code back to the original files. If it includes sourcesContent, it contains the original source text, including comments, unused branches and any constants that minification renamed but did not remove. CWE-540, Inclusion of Sensitive Information in Source Code, and its child CWE-615, sensitive information in comments, describe the result. The map also reveals internal API paths, feature flags and file structure useful for reconnaissance.
| Setting | Effect | Safe production use |
|---|---|---|
Vite build.sourcemap: false (default) | No map generated | Safe; no mapped stack traces |
Vite true / webpack source-map | Separate .map file plus a reference comment in the bundle | Only if the server blocks .map requests; webpack warns to disallow access for normal users |
Vite 'inline' | Map appended to the bundle as a data URI | Not for production: every visitor downloads it |
Vite 'hidden' / webpack hidden-source-map | Map generated, no reference comment | For error tooling; webpack says not to deploy the file to the web server |
webpack nosources-source-map | Map without sourcesContent | Can be deployed; still exposes file names and structure |
Next.js productionBrowserSourceMaps: true | Maps written next to the JavaScript and served automatically | Off by default; enable only with server-side blocking or private upload |
The usual pattern is to generate hidden maps, upload them to the error-monitoring service during the build, then delete them from the output folder before deployment. Sentry's Vite plugin documents a filesToDeleteAfterUpload option for this and warns that generated maps may otherwise leak source. Add a web tier rule that returns 404 for *.map as a second layer; OWASP ASVS 5.0 V13.4.7 asks that the web tier serve only specific file extensions to prevent source leakage.
Publishable key or real secret?
Not every key found in JavaScript is an incident. Some are designed for the browser, but each depends on restrictions to stay safe. Products are listed alphabetically; the order is not a ranking.
| Credential | Browser safe? | Condition or action |
|---|---|---|
| Analytics or tag IDs | Yes | Identifiers, not credentials; watch for abuse through quota and allowed domains |
| Firebase API key | Yes, if restricted to Firebase APIs | Protection comes from Security Rules and App Check, not secrecy; see the Firebase Security Rules Guide |
| Gemini Developer API and other AI provider keys | No | Firebase docs say never to allowlist the Gemini Developer API on a publicly accessible key; call from a server |
| Google Maps JavaScript API key | Yes, with restrictions | Apply HTTP referrer and API restrictions, one key per app; Google says web service keys should not be publicly exposed |
| OAuth client secret, webhook signing secret, database or cloud keys | No | A browser app is a public client; use PKCE or a backend for frontend |
Stripe publishable key pk_live_ | Yes | Stripe states it can be included in front-end code; it cannot create charges or read account data |
Stripe secret sk_ or restricted rk_ key | No | Server only, with access policies; rotate if exposed |
The Google API keys exposure shows why the "safe to expose" label is conditional: researchers found 2,863 live keys with Gemini access in November 2025 web archive data, because enabling the Gemini API on a project extended keys that had been embedded in websites as public identifiers. Treat enabling a new API as a change to every key in that project.
Finding exposed keys
- Scan the build output, not just the repository: run a secret scanner over
dist/,build/or.next/static/in CI after the build and fail the pipeline on a verified hit. TruffleHog'sfilesystemmode scans directories and verifies findings against the provider by default. Repository scanning misses values injected from CI variables. - Scan what is live: crawl production and staging, download every script and any reachable
.mapfile, and scan them. Include old bundle hashes still cached on the CDN. - Check the prefix list: list every
VITE_,NEXT_PUBLIC_andREACT_APP_variable across projects and CI settings, and confirm each one is meant to be public. A name containing SECRET, PRIVATE, PASSWORD or TOKEN behind a public prefix is a finding by itself. - Check maps: request
<bundle>.js.mapfor each script and look forsourceMappingURLcomments in production bundles. - Use the provider's view: key usage logs, referrer reports and unexpected billing often reveal a leaked browser key before a scanner does.
Attackers use the same approach: searching victim-owned websites and open sources (ATT&CK T1594 and T1593) and harvesting credentials in files (T1552.001). For triage of scanner output, see Developer Secret Scanning Triage.
Rotating a key exposed in a bundle
A redeploy without the key does not end exposure. Old bundles stay in browser caches, CDNs, web archives and scanners' collections, and OWASP's Secrets Management Cheat Sheet notes that restarting an application does not revoke stolen credentials.
- Classify: confirm whether the value is a publishable key or a secret, and what it can reach today, including any APIs enabled since it was created.
- Revoke and replace: OWASP's order is revoke, rotate, then remove from the exposed system. Where a provider offers an overlap, use it briefly: Stripe keeps the old key valid for up to 7 days after rotation unless you choose to expire it now.
- Move the call: if the key was a secret, ship the server-side replacement before or with the rotation, otherwise the next build reintroduces it.
- Purge: invalidate CDN caches for old bundles and maps and remove them from the origin.
- Review usage: check the provider's logs for calls from unexpected origins or volumes during the exposure window, and check for data accessed or charges incurred.
- Restrict the replacement: for publishable keys, apply API, referrer and quota restrictions before release.
Keeping keys server side
- Thin proxy or serverless function: the browser calls your endpoint, which authenticates the user, enforces rate limits and calls the provider with a key held in a secrets manager. Vite's documentation points to a backend or serverless and edge functions for this.
- Backend for frontend: for OAuth, keep tokens and client credentials on a server component and give the browser only a session cookie. See the OAuth for Browser-Based Applications and the BFF Pattern Guide.
- Short-lived, scoped tokens: where the browser must talk to a provider directly (uploads, real-time services), have the server mint a token scoped to one user and one action that expires in minutes.
- Runtime configuration for non-secrets: Next.js notes that build-time public values are frozen and suggests your own API for runtime values. Serving non-secret config from an endpoint also lets one image move between environments.
- Restricted publishable keys: where a browser key is intended, use one key per application with API and referrer restrictions, as Google's Maps guidance recommends, and keep AI or admin APIs in a separate project.
Where identity fits
Every key in a bundle is a non-human identity with an owner, scope and lifetime, even when it is labelled public. Record it in the key inventory with its owning team, the APIs it can reach and its restrictions, and review it when the project's enabled APIs change. Keys that must exist in the browser should be scoped as identifiers; anything that authorises an action belongs to a server-side workload identity. See the API Key Management Guide.
How to implement: a phased approach
- Discover (week 1): scan live sites, CDN contents and build outputs; list every public-prefixed variable and every reachable source map.
- Contain (week 1 to 2): revoke and replace confirmed secrets, block
*.mapat the web tier and purge caches. - Fix the build (weeks 2 to 4): set production maps to hidden or off, upload privately and delete after upload; stop injecting all CI secrets into frontend build jobs, giving them only an allowlist of public variables.
- Move calls server side (weeks 2 to 8): replace direct secret calls with proxies or a BFF, ship behind a feature flag, and keep the old path available for rollback until error rates are stable, with the old key already revoked.
- Prevent (ongoing): add a CI gate that scans build output and fails on verified secrets or on
.mapfiles in the deploy artefact; add a lint rule rejecting public-prefixed names that contain SECRET, PRIVATE or PASSWORD.
Decision guidance
- Restrict or move? If the provider documents the key as browser safe and offers referrer and API restrictions, restrict it. If the key can read data, spend money without a cap or perform admin actions, move it behind a server.
- Hidden maps or no maps? Teams with error monitoring should use hidden maps uploaded privately. Without monitoring, turn maps off in production.
- Small teams: a managed serverless function in front of each third-party API is usually the fastest fix and needs no new infrastructure.
- Large estates: standardise on a shared API gateway or BFF, central key inventory and a single CI scan step for all frontend builds, so each team does not solve it separately.
Detection and measurement
Log and alert on provider key usage from unexpected referrers, countries or volumes; on requests for .map files at the web tier; and on scanner hits in build output. Track:
- Verified secrets in shipped bundles: target zero, measured by the post-build scan and the live-site crawl.
- Publicly reachable source maps: count of production
.mapURLs returning 200. - Browser keys with restrictions: percentage of publishable keys with both API and application restrictions.
- Time to revoke: hours from detection to revocation for keys found in bundles.
- Frontend pipelines with build-output scanning: percentage of frontend repositories with the CI gate enabled.
Common failure patterns
- Prefix used to "fix" a missing value: a developer renames
API_SECRETtoNEXT_PUBLIC_API_SECRETbecause the client could not read it, and the secret ships. - Removing the key from code and calling it fixed: the value stays valid and stays in cached and archived bundles until it is revoked.
- Maps generated for error tooling and deployed anyway: the upload step works, but the delete step or the server block was never added.
- Unrestricted "public" keys: browser keys left unrestricted gain new powers when another API is enabled on the project, as the Google API keys exposure showed.
- Scanning only Git: values injected from CI variables never appear in the repository, only in the build output.
- Server values serialised into HTML: server-side rendering passes a whole config object to the client for hydration, secrets included.
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| OWASP ASVS 5.0 | V13.3.1 | Secrets must not be included in application source code or build artifacts |
| OWASP ASVS 5.0 | V13.4.2, V13.4.7 | Debug features off in production; web tier serves only specific file extensions |
| OWASP ASVS 5.0 | V14.2.1 | No API keys or tokens in URLs or query strings |
| NIST SP 800-53 Rev. 5 | IA-5(7), SA-11 | No embedded unencrypted static authenticators; developer testing |
| NIST CSF 2.0 | PR.PS-06, DE.CM-09 | Secure development practices; monitoring of software and runtime environments |
| MITRE CWE | CWE-798, CWE-540, CWE-615 | Hard-coded credentials; sensitive information in source code and comments |
| MITRE ATT&CK | T1552.001, T1594, T1593 | Credentials in files; search of victim-owned websites and open sources |
Practitioner checklist
- List every public-prefixed environment variable and confirm each is meant to be public.
- Give frontend build jobs only an allowlist of public variables, never the full secret set.
- Scan build output in CI and fail on verified secrets.
- Set production source maps to hidden or off, upload privately and delete them before deploy.
- Block
*.maprequests at the web tier and purge old maps from the CDN. - Apply API, referrer and quota restrictions to every publishable key, one key per application.
- Keep AI provider, admin and payment secret keys in a secrets manager behind a server-side call.
- Revoke, not just remove, any secret found in a bundle, then review provider logs for the exposure window.
- Re-check browser keys whenever a new API is enabled on their project.
Standards and references
- OWASP: Secrets Management Cheat Sheet
- MITRE: CWE-540 Inclusion of Sensitive Information in Source Code
- Vite: Env Variables and Modes
- Next.js: How to use environment variables
- webpack: Devtool configuration
- Sentry: Uploading source maps with Vite
- Firebase: Learn about using and managing API keys
- Stripe: API keys
Related NHI Mgmt Group resources: Environment Variables and .env Files · Client-Side Security Guide · Git Secret Scanning Guide · Generic API Key Detection Guide