DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our .env.example documents 19 variables and the app reads 15, and you can read one of them off the live HTML

I did an audit I would recommend to anybody with a project older than a few months: list every process.env read in the app, list every variable the example file documents, and look at the two sets that do not overlap.

The counts

In app, components, lib, utils, hooks and the proxy, there are 30 reads of process.env, across 19 files, naming 16 distinct variables. The config files at the repo root add two more names, CI and NEXT_RUNTIME, which the platform sets rather than me.

.env.example documents 19 variables.

Of the 16 the app reads, one is NODE_ENV, which is not configuration so much as weather. That leaves 15 variables that are genuinely ours, against 19 documented. Four documented variables are read by nothing in the application code, and the interesting thing is that three of those four are correct.

Where the four went

NEXTJS_INTERNAL_URL: read by the other deployment. This project ships as two processes on two platforms, and they share a secret. The variable is documented in the web app's example file with a note saying, in effect, this one is read by the other process, set it over there. Someone setting the project up for the first time needs the whole map, not the half that happens to live in this directory. Documenting a variable you do not read is the right call when the person reading your example file is going to configure both halves.

SENTRY_AUTH_TOKEN: consumed by a build plugin. Our source never names it. The build wrapper picks it up from the environment by convention when it uploads source maps. If you grep for it you find the comment in the example file and nothing else, which is exactly what a build-time credential should look like from inside the app.

GOOGLE_OAUTH_CLIENT_SECRET: lives in somebody else's dashboard. The OAuth exchange happens in our auth provider, not in our process, so the secret half of that credential pair is pasted into their console and never reaches our environment. Our code sees only the client id, and only in a way I will get to in a second.

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: genuinely dead. Nothing in the repository reads it. It is a leftover from an earlier billing integration, it is the one entry of the four with no defence, and the only reason to be glad it is public-prefixed is that a stale publishable key is the least harmful kind of stale key.

Three out of four is a better hit rate than I expected, and it is a reminder that "variables documented but not read" is a smell rather than a bug list. The direction that is always a bug is the other one: a variable read in code and missing from the example file, which is how a new contributor gets a stack trace instead of a checklist. Our set in that direction is empty.

The variable you can read off the live site

Here is the bit I think is worth stealing. One component decides whether the app offers Google sign-in, and it decides by asking whether the credential exists:

export default function ProviderSigninBlock() {
  const isGoogleEnabled = process.env.GOOGLE_OAUTH_CLIENT_ID ? true : false

  return (
    <>
      {isGoogleEnabled && (
        <form action={signInWithGoogle} className="w-full">
          <Button variant="outline" aria-label="Sign in with Google" type="submit">
            <FaGoogle className="w-5 h-5" />
            Continue with Google
          </Button>
        </form>
      )}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

No feature flag service, no database row, no NEXT_PUBLIC_ mirror of a value that is not secret but is also nobody's business. The feature is on when the thing it needs is present. Clone the repo without Google credentials and the button is simply not there, rather than there and broken.

The load-bearing detail is the missing "use client". Next inlines process.env.X into client bundles only for names prefixed NEXT_PUBLIC_, so in a client component this exact line would read undefined in the browser, the flag would be false forever, and nothing would throw. A silently dead sign-in option is a genuinely nasty failure mode, and the only thing standing between us and it is that this file is a server component.

You can observe the result from outside, which is the part I like:

curl -s https://pub-trivia.app/login | grep -o 'Continue with Google'
curl -s https://pub-trivia.app/forgot-password | grep -o 'Continue with Google'
Enter fullscreen mode Exit fullscreen mode

The first prints a match, the second prints nothing. Two pages in the same auth layout, and the difference is that one renders this component and one does not. That first match is a server-only environment variable telling you, through 26 KB of HTML, that it is set on the production deployment. Visit pub-trivia.app/login and the button is right there under the password field, with its aria-label on it because the Font Awesome glyph inside it does not mark itself decorative.

This is not a leak. Which sign-in methods a site offers is a thing it has to tell you by definition. It is just a nice reminder that a server-rendered feature flag is observable in the output and nowhere else.

The one read with the most consumers

The opposite shape also shows up in the census. NEXT_PUBLIC_WEBSITE_URL is read exactly once, in a module that derives the site's base URL and exports it, with a fallback that follows NODE_ENV so a local dev server never claims to be production and a production build with the variable missing never claims to be localhost. Everything that needs an absolute URL imports the derived constant: the sitemap, robots.txt, the JSON-LD graph, the Stripe redirect URLs, the transactional emails and the QR codes printed for tables.

One read, one fallback, many consumers. The observable version:

curl -s https://pub-trivia.app/tools | grep -o 'rel="canonical" href="[^"]*"'
Enter fullscreen mode Exit fullscreen mode

That prints rel="canonical" href="https://pub-trivia.app/tools", and the same derived value is what the sitemap and the structured data on pub-trivia.app/tools are built from. If the variable were wrong on a deploy, every one of those surfaces would be wrong together, which is a much better failure than six of them being wrong separately.

The audit, as three commands

# every read, by name and frequency
grep -rho "process\.env\.[A-Z_0-9]*" --include="*.ts" --include="*.tsx" . | sort | uniq -c | sort -rn

# every documented variable
grep -o "^[A-Z_][A-Z_0-9]*=" .env.example | tr -d '='

# then diff the two lists by hand and explain every difference
Enter fullscreen mode Exit fullscreen mode

The third step is the whole exercise. Four of our differences had three good reasons and one dead key, and I would not have found the dead key any other way.

Top comments (0)