Environment variables in browser extensions
Browser extensions ship code that runs on your users’ machines. That makes the line between configuration and secrets sharper than in a typical web app. Anyone who installs your extension can read anything you bundle into JavaScript, HTML, or the compiledmanifest.json.
Extension.js handles this with two environment loading paths. One serves compiled extension bundles (browser and mode aware). The other runs while loading extension.config.* in Node.
Both paths matter depending on where you read your variables.
Template examples
new-env

EXTENSION_PUBLIC_* values.
content-env

How it works
Extension bundles (compile time)
When building your extension, the compiler chooses one env file from your extension package folder by the first match in this order:.env.[browser].[mode](for example,.env.chrome.development).env.[browser]- Engine-family variants of the two above (see the note below)
.env.[mode].env.local.env
chromium, chrome, edge, chromium-based, the Chromium forks, and Safari builds) also try .env.chromium, .env.chrome, .env.edge, and .env.chromium-based (each with their .[mode] variants first). Gecko-family targets also try .env.firefox and .env.gecko-based. A single .env.chromium file therefore covers a chrome, edge, or brave build, and an exact browser file always wins over family files.
Extension.js always merges .env.defaults first when present, then the selected file’s variables. Finally, system process.env takes highest precedence for overlapping keys.
Selection is a single env file from the list above (plus
.env.defaults),
not a full cascade through every file. .env.example is documentation only
and is never loaded as a value source.pnpm-workspace.yaml. Extensions inside monorepos can share root-level env files for bundle injection.
Monorepo constraint: Workspace fallback only runs when a pnpm-workspace.yaml marker exists on an ancestor. If you use pure npm or Yarn workspaces that rely only on package.json "workspaces", this automatic root lookup does not apply. In that case, keep env files beside the extension package, or add a pnpm-workspace.yaml at the repository root.
extension.config.* (Node, before your configuration runs)
extension.config.js / .mjs / .cjs runs in Node. Before Extension.js evaluates the file, it preloads a small set of files into process.env so the configuration can read them:
.env.defaults(merged when present)- Then the first file that exists among:
.env.development,.env.local,.env
.env.chrome or .env.chrome.development for this preload step. Instead, set values through plain process.env in your shell or continuous integration (CI) pipeline.
You can also rely on bundle-time env (described above) to inject EXTENSION_PUBLIC_* values into extension code.
Workspace fallback: If none of those files exist in the extension package folder, the same preload runs from the nearest folder containing pnpm-workspace.yaml (same constraint as above).
This split exists because configuration loading is browser-agnostic at file-read time, while the bundler knows the active browser and mode.
Built-in environment variables
Extension.js injects built-in variables at compile time, so browser and mode are always available in your extension code.
All built-ins above are available through both
process.env.* and import.meta.env.*.
Environment variable inventory
Public/runtime variables (user-defined)
Static placeholder variables
Built-in/alias variables
CLI and dev-server operational variables
Telemetry control variables
See Telemetry and privacy for the full opt-out contract.
Browser transport tuning variables
These variables override internal Chrome DevTools Protocol (CDP) and Remote Debugging Protocol (RDP) timeouts. They are useful for slow continuous integration (CI) environments, Docker containers, or debugging flaky browser connections.Browser-specific environment variables
The rules below apply to compile-time / bundle env selection (see Extension bundles above). They do not apply to the narrowextension.config.* preload in Node.
Need different values per browser? Extension.js supports browser-scoped env files such as .env.chrome (Chrome extension environment variables) and .env.firefox (Firefox extension environment variables). You can also combine browser and mode for a single build variant:
.env.chrome.development: Extension.js applies this only when running the extension in Chrome during development mode..env.firefox.production: Extension.js applies this only when building the extension for Firefox in production mode.
.env.[browser].[mode].env.[browser]- Engine-family variants (for example,
.env.chromiumfor any Chromium-family target) .env.[mode].env.local.env
.env.chromium covers chrome, edge, and the Chromium forks, and .env.firefox covers the Gecko forks. Add an exact browser file (such as .env.edge) only when one target needs different values from the rest of its family.
Example files
Custom environment variables
You can define custom variables in env files at project root.Extension.js only injects variables prefixed with
EXTENSION_PUBLIC_ into JavaScript bundles (process.env / import.meta.env).
EXTENSION_PUBLIC_ into JS bundles.However, placeholders in emitted
.json/.html files can resolve $EXTENSION_* tokens, so avoid referencing secrets in static asset templates.
Using environment variables
You can use environment variables inmanifest.json, locales, HTML, and JavaScript/TypeScript files.
1. In manifest.json
manifest.json does not natively support environment variables, but Extension.js replaces supported placeholders during build. For example:
$EXTENSION_PUBLIC_API_KEY with the resolved env value.
2. In locale files
You can also use placeholders in locale files when values should change by environment. For example:$EXTENSION_PUBLIC_SITE_URL with resolved values.
3. In HTML files
You can also use placeholders in static HTML files (for example, underpages/):
$EXTENSION_PUBLIC_API_KEY in the output HTML.
4. In JSX components
In React/JSX/TS files, read env values withprocess.env:
import.meta support
For ECMAScript Module (ESM) workflows, Extension.js also supports import.meta.env:
service_worker.mjs
import.meta.env and process.env have parity for injected env keys.
Reading a key that no env file defines yields undefined instead of crashing: Extension.js defines the bare import.meta.env as an object of every injected variable (Vite parity), so import.meta.env.MISSING_KEY and destructuring like const {FOO} = import.meta.env are both safe in any output format.
Secrets in browser extension builds
Browser extensions run on your users’ machines. Anyone who installs your extension can inspect any value you bundle into JavaScript, HTML, or the compiledmanifest.json. Treat the bundle as public.
A few rules to follow:
- Never put API secrets, signing keys, or auth tokens behind
EXTENSION_PUBLIC_*. The prefix exists to mark a value as safe to ship, not to hide it. - Avoid
$EXTENSION_*placeholders in staticmanifest.json, locale, or HTML templates when the value is sensitive. Those expand at build time and end up in the artifact. - Move anything privileged behind a backend you own and call it from the extension at runtime.
- For continuous integration (CI), keep build secrets in
process.env(not committed env files) and rely on.env.defaultsfor safe shared values.
Best practices
- Expose only what must ship: Prefix only client-safe keys with
EXTENSION_PUBLIC_. - Use
.env.defaultsfor shared defaults: Keep predictable team defaults while allowing local/system overrides. - Keep secrets out of static placeholders: Avoid putting secret
$EXTENSION_*tokens in HTML/JSON templates. - Version control hygiene: Commit
.env.exampleas documentation (the build never reads it as a value source) and ignore real env files (.env,.env.local, browser/mode variants).
Next steps
- Review browser targeting in Browsers available.
- Configure shared defaults in
extension.config.js. - Build for release with
extension build.

