← All field notes

Where frontend environment variables actually end up

An 11-case Vite and Next.js experiment: synthetic secrets in JavaScript, HTML and RSC output. Download the fixtures, results and exact-match checker.

Sep 18, 2026 · 6 min read


Field Notes from Thunkle, a studio that takes AI-built apps from prototype to secure, production-ready software.

By Thunkle · Controlled local experiment · Lab version 1.0.0

A secret does not have to appear in your JavaScript bundle to reach a visitor. In our Next.js fixture, an unprefixed environment variable was absent from the client assets but present in the generated HTML.

We built three small applications with 11 synthetic canary values to trace where environment variables ended up. The examples cover public prefixes, explicit build configuration, unused variables, server-only use and values rendered or passed to a Client Component.

This is a reproducible demonstration of application and framework behavior, not a newly discovered framework vulnerability or a survey of live apps. We used no real credentials and scanned no customer applications.

Download the lab

The lab is free, ungated and MIT licensed. You do not need to link to us to use it. If you cite the experiment, include the framework versions and distinguish JavaScript from rendered response content.

What we tested

On 18 September 2026, we ran production builds using Vite 8.3.0, Next.js 16.2.9 with webpack, React 19.2.3 and Node.js 26.7.0, on macOS arm64. These are the tested versions, not a claim about the latest releases. The Next.js command explicitly uses --webpack; we did not test Turbopack.

Every input value starts with THUNKLE_SYNTHETIC_CANARY_. The runner builds disposable copies of the fixtures with a small environment allowlist and the synthetic values. It checks Vite's dist, Next.js's .next/static, and the generated .html and .rsc files in .next/server/app. It does not classify server JavaScript as a public artifact.

The recorded JSON contains file-level matches, fixture SHA-256 hashes, versions and limitations. We repeated the builds with unchanged fixture sources after investigating the initial result. The observed present/absent outcomes and their output surfaces were the same in both runs.

Vite: five cases

  1. Referenced VITE_ value: present in the generated JavaScript.
  2. Unprefixed value read through import.meta.env: no literal match in dist.
  3. Unreferenced VITE_ value: no literal match in this fixture's dist.
  4. Unprefixed value injected using define: present in the generated JavaScript.
  5. Referenced value allowed by a custom envPrefix: present in the generated JavaScript.

The practical distinction is between the variable's name and the configuration that makes it available. Our default fixture explicitly injects a value with:

define: {
  __LAB_CONFIGURED_PRIVATE__: JSON.stringify(process.env.LAB_VITE_DEFINE)
}

The client then renders that constant. Calling the source variable private does not undo the explicit injection. Vite documents both its environment-variable exposure rules and its define and envPrefix configuration.

The unused-value observation is not permission to store a secret under a public prefix. A later reference can change the output, and different application code or configuration can behave differently.

Next.js: six cases

  1. Referenced NEXT_PUBLIC_ value: present in client JavaScript and generated HTML.
  2. Unprefixed value rendered inside a Client Component: absent from client JavaScript, but present in generated HTML.
  3. Unprefixed value injected through next.config.js env: present in client JavaScript and generated HTML.
  4. Server-only value used to produce a boolean control: no literal match in either inspected output surface; the HTML confirmed the server had received it.
  5. Server-read value passed as a Client Component prop: present in generated HTML and RSC files, but absent from client JavaScript.
  6. Unreferenced NEXT_PUBLIC_ value: no literal match in either inspected output surface.

Next.js documents the public-prefix build-time substitution. Its separate env configuration documentation explains why explicitly configured values do not need that prefix to enter bundled code.

The result that corrected our initial expectation

We initially expected the unprefixed variable in case two to be absent from every inspected output. The first run disproved that expectation. Here is the relevant pattern from the deliberately unsafe fixture:

"use client";

export default function Client({ serialized }) {
  return <pre>{JSON.stringify({
    privateValue: process.env.LAB_NEXT_PRIVATE,
    serialized
  })}</pre>;
}

The synthetic private value appeared in index.html, even though the exact value did not appear in .next/static.

The distinction matters: "use client" does not mean a component only executes in the browser. Next.js uses Client Components when prerendering HTML, and props crossing the server/client boundary are represented in the RSC payload. That is documented rendering behavior, not a new vulnerability claim.

We retained the initial report with its failed expectation. For the confirmation run, we changed the runner's expectations to name individual output surfaces, without changing the fixtures. This is a correction to our test hypothesis, not an independent discovery claim.

A JavaScript-only check would have missed both the rendered private value and the serialized prop. A later hydration change cannot retract bytes already included in the initial response. We did not measure hydration or browser behavior in this experiment.

Reproduce it locally

Unzip the complete lab into a disposable directory outside your application. Use Node.js 22.12 or later; the recorded runs used 26.7.0. Review the scripts before running them.

cd frontend-secrets-lab
npm ci --ignore-scripts --no-audit --no-fund
npm test
npm run experiment

Dependency installation uses the npm registry. The runner has no upload or analytics endpoint. It retains synthetic build outputs and logs in a new temporary directory and prints the results path. The test suite checks the matcher, including missing or empty inputs, symlinks and oversized files.

For your own disposable synthetic fixture, the dependency-free matcher can inspect a selected output directory:

node scan-canaries.mjs ./dist ./canaries.json

It reports file paths and marker labels, not file contents. Exit 1 means an exact canary was found, 0 means no exact match in the selected files, and 2 means the check was inconclusive. The full experiment runner instead exits 0 when its expected exposure demonstrations match. These are different checks: intentional exposure is a successful lab demonstration, not a safe application.

What to change in a real application

  • Keep privileged operations on the server. The browser should request an authorized action, not receive the credential used to perform it. Enforce authentication, authorization and appropriate usage limits at that server endpoint.
  • Review build configuration as well as variable names. Look for explicit injection, custom prefixes and objects that serialize environment values.
  • Inspect responses as well as bundles. Include generated or captured HTML, page data, RSC payloads and your own API responses in an authorized review. Do not upload credentials or customer responses to this lab.
  • Return only the fields the UI needs. Passing a server-side object into a component is still a data-disclosure decision.
  • Treat confirmed credential exposure as an incident. Revoke or rotate the affected credential, review its use, remove the exposure and check published/cached artifacts. Deleting the current source reference alone does not invalidate a copied credential.

Not every browser-visible key is a secret. Some are intentionally public identifiers whose safe use depends on server-side access controls. For database-backed apps, our two-account Supabase read-isolation exercise tests a separate question: can one ordinary user read another user's private record?

Limits of these results

Exact matching misses encoded, split, transformed or runtime-only values. An absence result is not a security pass. We did not test source-map configuration variants, deployed network responses, middleware, browser storage, other framework versions or hosted Base44, Lovable, Bolt or Replit builds. This is 11 deliberately constructed cases, not 11 randomly sampled applications; it supplies no estimate of how common exposure is.

Use the fixtures to understand the boundary, then test your actual architecture with appropriate authorization and synthetic data. If you need help reviewing those paths, Thunkle's security audits cover application-specific code and access rules.

Working on something like this?

If you're migrating, securing or building an AI-made app, Thunkle can help. Get a fixed quote in 24 hours.

Get a quote