esbuild can inject environment variables into your bundles, but doing it directly with process.env is a security risk.
Let’s see this in action. Imagine you have a simple web app that needs to know its API endpoint, which is stored in an environment variable.
// src/index.js
const API_URL = process.env.API_URL;
console.log(`Using API at: ${API_URL}`);
document.getElementById('api-status').innerText = `API endpoint: ${API_URL}`;
And you’re building this with esbuild:
# This is the BAD way
API_URL="https://api.example.com" esbuild src/index.js --bundle --outfile=dist/bundle.js --define:process.env.API_URL="'https://api.example.com'"
If you inspect dist/bundle.js, you’ll see:
// dist/bundle.js (excerpt)
const API_URL = "https://api.example.com"; // <-- Injected directly!
console.log(`Using API at: ${API_URL}`);
document.getElementById('api-status').innerText = `API endpoint: ${API_URL}`;
The problem is that process.env.API_URL is replaced directly with the string "https://api.example.com". This means the sensitive value is baked into your client-side JavaScript bundle, visible to anyone who inspects the source code. If API_URL were a secret API key, you’d have a major security leak.
The safe way to handle this is to use esbuild’s --define flag, but to not expose the variable directly in your source code. Instead, you define a different global variable that esbuild will inject.
Here’s the secure approach:
-
Define a custom global in your esbuild command: Instead of trying to replace
process.env.API_URL, define a new global variable, saywindow.APP_CONFIG.API_URL.# This is the GOOD way API_URL="https://api.example.com" esbuild src/index.js --bundle --outfile=dist/bundle.js --define:window.APP_CONFIG.API_URL="'https://api.example.com'"Notice the single quotes around the value:
'https://api.example.com'. This is crucial because esbuild will treat the value as a string literal. Without them, it would try to evaluatehttps://api.example.comas a JavaScript expression, which would fail. -
Access the global in your source code: Modify your JavaScript to read from this new global configuration object.
// src/index.js (updated) // Ensure APP_CONFIG is initialized if it doesn't exist window.APP_CONFIG = window.APP_CONFIG || {}; const API_URL = window.APP_CONFIG.API_URL; console.log(`Using API at: ${API_URL}`); document.getElementById('api-status').innerText = `API endpoint: ${API_URL}`;
Now, when esbuild runs, the dist/bundle.js will look like this:
// dist/bundle.js (excerpt)
// Ensure APP_CONFIG is initialized if it doesn't exist
window.APP_CONFIG = window.APP_CONFIG || {};
const API_URL = window.APP_CONFIG.API_URL; // <-- Reads from a global
console.log(`Using API at: ${API_URL}`);
document.getElementById('api-status').innerText = `API endpoint: ${API_URL}`;
This is safe because the actual value https://api.example.com is not present in the JavaScript bundle. The bundle only contains code that reads from window.APP_CONFIG.API_URL. The sensitive value is then provided at runtime, typically by your server or a separate configuration file that is not served to the client directly.
You can chain multiple --define flags to inject multiple configuration values. For example, to inject an API key that should not be exposed to the client:
# This is how you'd handle a sensitive key that should NEVER be in the bundle
API_KEY="sk_test_12345" esbuild src/index.js --bundle --outfile=dist/bundle.js --define:window.APP_CONFIG.API_URL="'https://api.example.com'"
In this case, API_KEY is used to set window.APP_CONFIG.API_URL but API_KEY itself is not referenced in the JavaScript, nor is its value directly injected into the bundle. The server hosting your bundle.js would then inject the API_KEY into window.APP_CONFIG before the JavaScript executes, or the JavaScript would fetch it from a secure endpoint.
The key takeaway is that esbuild --define performs a simple text substitution. If you substitute sensitive values directly, they become part of your static asset. By substituting into a global object that is populated later with sensitive data, you keep the sensitive data out of the client-side bundle.
If you forget the quotes around the value in the --define flag, esbuild will error out with a ParseError: expected expression because it can’t parse https://api.example.com as valid JavaScript.