Medium wave · 540–1600 kHz58 stn
1491kHz

Every Env Var in a Static Build Is Public

A colossal brass vault door hanging wide open in a fog-filled steampunk foundry, its heavy tumblers cast in glass so every glowing neon-amber pin inside is plainly visible from the outside, copper conduits pouring molten light onto the floor

Someone adds an API key to the hosting dashboard, references it in a component, and ships. The variable had no PUBLIC_ prefix, so the framework refused to expose it and the value came back undefined. The fix that gets applied, nine times out of ten, is to add the prefix.

That is the moment the key became public. Not when it showed up in a network request, not when someone found it — right then, at build time, when a string replacement wrote it into a file that a CDN now serves to anybody who asks.

The prefix is not a permission system. It is a spell-check for a decision you already made.


There is no runtime to hide in

A server-rendered app reads process.env on every request, inside a process that lives on a machine you control. The secret is in memory. The response is derived from it. That is a real boundary, and it is the boundary everyone’s mental model was built around.

A static build has none of that. astro build or next build runs once, on a CI box, and its only output is a directory of files. When the build ends, the process exits and takes its environment with it. Nothing is left behind to consult a variable later.

So a bundler cannot defer an env var. It can only do one of two things:

  1. Substitute the literal value into the output at build time, or
  2. Leave you with undefined.

Vite — which both Astro and modern Next.js builds sit on — does the first, through define-style replacement. This is textual, and it happens before minification:

// what you wrote
const key = import.meta.env.PUBLIC_API_KEY

// what ships in dist/_astro/index.a1b2c3.js
const key = 'sk_live_9f3a...'

There is no lookup, no indirection, no lazy resolution. The identifier is gone. What remains is a string constant in a file with a year-long Cache-Control header on it.

If a value has to be present for the page to work in a browser, that value is published. The only question is whether you meant to publish it.


What the prefixes actually mean

Every framework has the same convention with a different spelling:

FrameworkExposed to the clientRead at build only
AstroPUBLIC_*any other name
Next.jsNEXT_PUBLIC_*any other name
ViteVITE_*any other name
SvelteKitPUBLIC_*$env/static/private

Read the right-hand column carefully, because that is where the real distinction lives. An unprefixed variable is not protected — it is simply unavailable to code that runs in the browser. You can still use it freely in build-time code: a content fetch in getStaticPaths, a script that pulls from a CMS, an Astro component’s frontmatter that executes on the build machine and never ships.

That is the entire useful design. The prefix marks which side of the build/browser line a value may cross. It says nothing about whether the value is a secret, and it will carry a live database credential across without complaint if you name it correctly.

Which means the rule is not “don’t use secrets in the browser.” It is:

Anything a browser needs, the build must inline. Anything the build inlines is published. Therefore nothing a browser needs can be a secret.

The corollary that trips people up: a value that never touches the client is still not safe by accident. It is in the CI process environment, it is in the build log if anything echoes it, and it is one careless console.log(process.env) away from the output.


Some keys are supposed to be public

This is not an argument that no credential belongs in static output. Plenty of services issue keys designed for exactly this:

  • A Supabase anon key is a public identifier. Row-level security is the actual boundary; if RLS is off, the key was never the problem.
  • A Stripe publishable key (pk_live_…) is meant to be in the page. The secret key (sk_live_…) is not, and that naming exists because people mixed them up often enough to warrant it.
  • Analytics site IDs, Sentry DSNs, map tile tokens, form endpoint URLs — all designed to be visible, all defended by origin allowlists and server-side rate limits rather than by obscurity.

The test is not does this look like a secret. It is: if this string were on a billboard, what could someone do with it? If the honest answer is “nothing, because the service enforces limits on their end,” inline it and move on. If the answer involves reading or writing data you own, it cannot go in the bundle at any prefix.

And when a vendor gives you exactly one key that does everything, you do not have a naming problem. You have an architecture problem, and the fix is a proxy — an edge function or a small serverless route that holds the key and exposes precisely the one operation the page needs. That is the narrow, legitimate case for putting a function in front of a static site.


Grep your output, not your source

Code review does not catch this reliably. The dangerous diff is usually one missing character of prefix in a .env.example, or a variable renamed in a dashboard nobody has open. What catches it is checking the artifact.

After a build, the answer is sitting in dist/:

grep -rE 'sk_live_|sk_test_|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}' dist/ || echo "clean"

Better, because it does not depend on you predicting every token format: diff the environment against the output. Take every variable the build could see, and check whether any of their values appear in the files you are about to publish.

// scripts/check-secrets.mjs
import { readFileSync } from 'node:fs'
import { globSync } from 'node:fs'

const PUBLIC_PREFIXES = ['PUBLIC_', 'NEXT_PUBLIC_', 'VITE_', 'npm_', 'GITHUB_']

const secrets = Object.entries(process.env)
  .filter(([name]) => !PUBLIC_PREFIXES.some((p) => name.startsWith(p)))
  .filter(([, value]) => value && value.length >= 12)

const files = globSync('dist/**/*.{js,html,json,css,map}')
const leaks = []

for (const file of files) {
  const contents = readFileSync(file, 'utf8')
  for (const [name, value] of secrets) {
    if (contents.includes(value)) leaks.push(`${name} → ${file}`)
  }
}

if (leaks.length) {
  console.error('secret values found in build output:')
  for (const leak of leaks) console.error(`  ✗ ${leak}`)
  process.exit(1)
}
console.log(`✓ ${files.length} files clean`)

The length floor matters. Without it, a variable set to 1 or true matches every file in the directory and the check becomes noise you learn to ignore. The prefix allowlist matters for the same reason: PUBLIC_ values are supposed to be there, and so are the dozens of npm_ and GITHUB_ variables CI injects into every job.

Wire it after the build, before the deploy:

{
  "scripts": {
    "build": "astro build && pagefind --site dist",
    "postbuild": "node scripts/check-secrets.mjs"
  }
}

Now a leak is a red pipeline instead of a disclosure. It is the same principle as failing a build on invalid content: the cheapest place to catch a mistake is the last moment before it becomes public, and that moment is automatable.


Sourcemaps are the other half

A clean bundle scan still misses things if you ship sourcemaps. Minification may have mangled a variable into oblivion, but the .map file sitting next to it carries the original source, the original names, and — if the value was inlined before minification — the original literal.

Static hosts serve whatever is in the directory. There is no .gitignore for a CDN. If dist/_astro/index.js.map exists, it is a URL.

You do not have to give them up. Generate them, upload them to your error tracker, then delete them from the deploy artifact:

astro build
node scripts/upload-sourcemaps.mjs
find dist -name '*.map' -delete

The same applies to everything else a build leaves lying around: a .env copied by an over-broad public/ directory, a config.json written for local dev, a .git folder included because someone deployed the repo root instead of the output folder. Static hosting has exactly one rule — every file in the published directory is a public URL — and it applies to the files you forgot about with the same enthusiasm as the ones you meant to ship.


The takeaway

Treat the build boundary the way you would treat a push to a public repo, because functionally that is what it is. Ask of every variable: does the browser need this? If yes, it is published, and it had better be a key the vendor designed to be published. If no, leave it unprefixed, keep it in build-time code, and add a job that proves it stayed there.

The prefix convention is good ergonomics. It is not a boundary. The boundary is the moment the build writes a file, and the only way to know what crossed it is to look at what came out.