Environment Variables and Secrets at the Edge
This guide is part of Edge Runtime Fundamentals & Platform Constraints. It covers how configuration reaches code that runs in hundreds of locations with no filesystem, no .env file, and no process you can attach to — and how to keep the secret parts of that configuration out of a bundle that is, for practical purposes, public.
Every edge platform gives you something called “environment variables”, and on every platform they mean at least two different things. Some are substituted into your source at build time and are therefore permanently baked into the artifact. Others are supplied by the runtime when the isolate boots and can be changed without rebuilding. Confusing the two is how API keys end up in JavaScript that a browser can download.
The central distinction: inlined versus bound
Build-time inlining is a text substitution performed by your bundler. process.env.API_URL in the source becomes the literal string "https://api.example.com" in the output. The value is in the artifact. It is in your build cache, in your deployment archive, and — if the module is reachable from client code — in a file the browser fetches. Rotating it requires a rebuild and a redeploy.
Runtime binding attaches values to the execution context when the isolate starts. The value never enters the bundle; it is handed to your code as a property on an env object or a platform-specific accessor. Rotating it is a control-plane operation that takes effect on the next isolate boot, with no rebuild.
The rule that follows is short: anything secret must be a runtime binding, never an inlined value. Non-secret configuration can be either, and inlining is often slightly faster because the value is a constant the optimizer can fold.
Provider mapping
| Concern | Cloudflare Workers | Vercel Edge Middleware | Netlify Edge Functions |
|---|---|---|---|
| Non-secret config | [vars] in wrangler.toml |
Environment variable in project settings | Environment variable in site settings |
| Secret | wrangler secret put NAME |
Environment variable marked “Sensitive” | Environment variable, scoped to functions |
| Access in code | env.NAME on the handler signature |
process.env.NAME |
Netlify.env.get('NAME') |
| Scope | Per Worker, per environment | Per project, per environment | Per site, per deploy context |
| Available at module scope | No — only inside the handler | Yes (inlined at build) | Yes, via the accessor |
| Rotation without redeploy | Yes | No for inlined values; yes for runtime reads | Yes |
| Visible in the dashboard after saving | No, write-only | No for sensitive values | No for sensitive values |
| Local development source | .dev.vars |
.env.local |
.env / CLI flags |
The row that changes how you write code is “available at module scope”. On Cloudflare, env is a parameter, handed to fetch(request, env, ctx). There is no ambient process.env, so anything you want to initialise once — an API client, a key set — cannot read configuration at module load. On Vercel, process.env exists and is inlined, so module-scope initialisation works but bakes the value in.
The lazy-singleton pattern
This is the pattern that reconciles those two worlds: initialise once, but do it on first request, when env is available.
export interface Env {
API_BASE: string;
API_KEY: string;
ENVIRONMENT: 'development' | 'staging' | 'production';
}
interface ApiClient {
get(path: string): Promise<Response>;
}
// Module scope holds only the *slot*, never the value.
let client: ApiClient | null = null;
function getClient(env: Env): ApiClient {
if (client) return client;
client = {
get: (path) =>
fetch(new URL(path, env.API_BASE), {
headers: { authorization: `Bearer ${env.API_KEY}` },
}),
};
return client;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return getClient(env).get('/health');
},
};
The client is built once per isolate and reused for every subsequent request that isolate serves, which is the same win as module-scope initialisation without needing the value at module-load time. It also works unchanged on providers where process.env does exist, so the same source runs everywhere.
One caveat: because the singleton captures env from the first request, it will not observe a rotated secret until the isolate is recycled. That is usually fine — isolates are short-lived — but if you need immediate rotation, read env per request instead and accept the negligible cost.
Validate configuration at the boundary
An edge function that reads env.API_KEY and finds undefined will not fail loudly. It will send Bearer undefined and get a 401 from an upstream service, and you will spend an hour looking at the wrong system. Validate once, at the point of use, and fail with a message that names the missing variable.
export function requireEnv(env: Partial<Env>): Env {
const missing: string[] = [];
for (const key of ['API_BASE', 'API_KEY', 'ENVIRONMENT'] as const) {
if (!env[key]) missing.push(key);
}
if (missing.length > 0) {
// Names only. Never log or return the values themselves.
throw new Error(`Missing required configuration: ${missing.join(', ')}`);
}
return env as Env;
}
Throwing at the edge produces a 500, which is the correct outcome for a misconfigured deployment: loud, immediate, and impossible to mistake for an application bug. What you must not do is fall back to a default for a secret. A hardcoded fallback key is a credential in your source tree.
Per-environment values without per-environment code
Configuration should differ between preview, staging and production; code should not. Keep one code path and let the platform supply different values per environment.
# wrangler.toml
name = "edge-middleware"
main = "src/worker.ts"
compatibility_date = "2026-01-01"
[vars]
ENVIRONMENT = "development"
API_BASE = "https://api.dev.example.com"
[env.staging]
[env.staging.vars]
ENVIRONMENT = "staging"
API_BASE = "https://api.staging.example.com"
[env.production]
[env.production.vars]
ENVIRONMENT = "production"
API_BASE = "https://api.example.com"
# Secrets are never written here. They are set per environment with:
# wrangler secret put API_KEY --env production
Note what is absent: no secret values, and no if (environment === 'production') branches in the code. A conditional on environment inside a handler means the production path is the one you never exercise in testing, which is the path most likely to be broken.
Guarding against client-side leakage
The most damaging mistake is a secret reachable from a browser bundle. The mechanics differ by framework but the shape is always the same: a module imported by both server and client code reads a secret, the bundler follows the import graph, and the value is inlined into a file served to the public.
Three defences, applied together.
Naming conventions. Frameworks expose only prefixed variables to the client — NEXT_PUBLIC_, PUBLIC_, VITE_. Treat those prefixes as a declaration of publication. Never prefix a secret, and never remove a prefix to “make it work”.
Import isolation. Keep secret-reading code in modules that client code cannot import. A single server/config.ts that is imported only from server entry points is far more robust than discipline applied per call site.
A build-output scan. The only defence that actually verifies the outcome is grepping your client bundle for a known token before you ship it. Add it to CI and it fails the build rather than the incident review.
# CI step: fail the build if any secret value appears in client output.
if grep -rqF "$API_KEY" .next/static/ dist/assets/ 2>/dev/null; then
echo "FAIL: a secret value is present in client-side output" >&2
exit 1
fi
Rotation without redeploying
A runtime binding can be replaced while the deployment stays untouched, which is what makes routine rotation practical rather than an emergency procedure.
The safe sequence is overlap, then cut over, then retire. Issue the new credential while the old one is still valid. Update the binding; new isolates pick it up immediately, existing ones on their next recycle. Wait past your longest isolate lifetime plus a margin. Only then revoke the old credential. Skipping the overlap turns a rotation into an outage for every isolate still holding the previous value.
Where an upstream service supports only one active credential at a time, add a second binding — API_KEY and API_KEY_NEXT — and have the code try the primary and fall back once on a 401. It is a small amount of code that converts a hard cutover into a soft one.
Debugging workflow
Locally, every provider reads a dotfile: .dev.vars for wrangler dev, .env.local for Vercel, .env for Netlify. All of them belong in .gitignore, and a committed .env.example listing the names is the right way to document what a new developer needs.
In production, log configuration presence, never values. A single line at boot naming which expected variables resolved, and which did not, answers most configuration questions instantly:
console.log(JSON.stringify({
event: 'config.resolved',
environment: env.ENVIRONMENT,
present: (['API_BASE', 'API_KEY'] as const).filter((k) => Boolean(env[k])),
missing: (['API_BASE', 'API_KEY'] as const).filter((k) => !env[k]),
}));
The pattern generalises to the redaction discipline described in structured logging for edge functions: the log records that a secret exists, never what it is.
Common pitfalls
| Symptom | Root cause | Fix |
|---|---|---|
env is not defined at module scope |
Cloudflare passes env as a handler parameter |
Use the lazy-singleton pattern |
| Secret visible in a client bundle | A shared module read it and the bundler followed the graph | Isolate server-only modules; scan build output in CI |
| Rotation had no effect | The value was inlined at build time | Move it to a runtime binding and redeploy once |
| Works in production, fails in preview | The variable was set for one environment only | Set it per environment; assert presence at startup |
Bearer undefined upstream |
Missing variable used without validation | requireEnv at first use; never default a secret |
| Old key rejected mid-rotation | The old credential was revoked before isolates recycled | Overlap both credentials, then retire |
.dev.vars committed |
Not in .gitignore |
Ignore it; commit a names-only example file |
Runtime-constraints checklist
Frequently Asked Questions
Why can't I read env at module scope on Cloudflare Workers?
Because there is no ambient process object. Bindings are handed to the handler as its second parameter, which does not exist while the module is still being evaluated. Use a lazy singleton that initialises on the first request instead.
Is a build-time environment variable ever safe for a secret?
No. Build-time substitution puts the literal value into the artifact, which lives in your build cache, your deployment archive and potentially a file the browser downloads. Rotating it also requires a rebuild, which makes routine rotation painful enough that it stops happening.
How do I stop a secret leaking into the client bundle?
Combine three defences: never give a secret a public naming prefix, keep secret-reading code in modules client code cannot import, and add a CI step that greps the built client output for the secret value and fails the build on a match.
Do I need to redeploy after rotating a secret?
Not if it is a runtime binding. New isolates pick up the new value at boot and existing ones on their next recycle. Only inlined build-time values require a rebuild and redeploy, which is one of the strongest arguments for never inlining a secret.
Should configuration differences live in code or in the platform?
In the platform. An environment conditional inside a handler means the production branch is the one you never exercise while testing, which is exactly the branch most likely to be wrong. Keep one code path and vary the values.
What belongs in a committed .env.example file?
Names and harmless placeholder values only, so a new developer knows what to set without receiving anything sensitive. The real dotfiles must be gitignored on every provider.