D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

New Token in `.env` but the Next.js Build Still Uses the Revoked One? A Leftover `.env.local` on the Server Wins

· · 11 min read
New Token in `.env` but the Next.js Build Still Uses the Revoked One? A Leftover `.env.local` on the Server Wins

The plan I wrote down before revoking the old Sanity read token had one condition, and for staging I wrote it down twice: the old token could only go once the dashboards on staging and on production both showed the new token's fingerprint. The reason was simple. Sanity rejects requests that carry a revoked token and does not fall back to anonymous access, so revoking too early breaks every Sanity read.

The replacement was a Viewer token I created and tested myself, and after the release on 29 September at 06:45 UTC, the production dashboard showed its fingerprint for both the runtime and the build. The staging fingerprint could not be read, because the staging dashboard API answered 401. I assumed staging matched production, and at 07:00 UTC I revoked the old token.

Production stayed fine: 13 paths answered 200, the sitemaps listed 546 articles and 869 glossary terms, and the runtime and build fingerprints pointed at the new token. The staging sitemaps suddenly became an empty urlset, 0 and 0, where they had been 546 and 869 at 06:47 UTC. Staging had still been on the old token, and the staging dashboard's API could not be read with the token at hand to check it.

Failing without a sound

What made this slippery is that nothing failed loudly. The Sanity dataset in this project is private, and a private dataset does not answer anonymous queries with an error but with a 200 and an empty result. A count query for glossary documents without a token returned 0 on both apicdn and api, while the Viewer token got 869. A missing or revoked token fails silently as empty data, not as a 5xx.

The rejection of the old token did get logged, in the staging pm2 logs the client's DevOps engineer showed: [sanity] fetch failed: Unauthorized - Session not found. From the outside, the staging sitemaps simply came back empty, with no error and no alert, while the listings were still filled from the Next.js Data Cache. What needs checking is the content, meaning the number of <loc> entries in a sitemap and the links in a listing, never just the status code.

A rebuild, then pm2

The running staging build was older than the token swap, so at my request an empty commit was pushed to develop to rebuild staging. Staging rebuilt at 07:15 UTC and its sitemaps stayed empty, while a glossary term page rendered not-found on staging even though it was fine on production.

The client's DevOps engineer then showed that CodeBuild and the .env on the staging server both already held the new Viewer token, while the pm2 log kept saying Unauthorized. My first diagnosis, later that same day, blamed pm2: the process still carried the old token in its own environment, because pm2 keeps the environment a process was started with, and Next's .env loading never overrides a variable that is already set. At the time I recorded that as the root cause.

The client's DevOps engineer applied the fix at 10:02 UTC. The folder and app names are anonymized here:

cd ~/app-staging && set -a && . ./.env && set +a && pm2 restart app-staging --update-env && pm2 save --force

After that, the glossary term pages on staging rendered again. The sitemaps were still empty at that moment, but only because the ISR sitemap routes (revalidate 3600, x-nextjs-cache: HIT) were holding the empty version from the broken window. The handover note at the end of that session recorded staging as broken from 07:00 to 10:02 UTC, three hours and two minutes, and fully back with sitemaps at 548 and 869.

That pm2 lesson is still true: pm2 restart --update-env only copies the environment of the shell that calls it, so a changed .env value only reaches the app after the shell sources .env first. It just was not the root cause, and I did not learn that until two days later.

Every build came out empty

That evening a pattern started to show. Right after every staging build, the sitemaps and the search index were empty, both prerendered at build time and served with x-nextjs-cache: HIT, while pages rendered at runtime read Sanity fine, and the state healed by itself within the 3600-second ISR window. In one comparison right after the builds, at 13:06 UTC, staging served 0/0 sitemaps and a 14-byte search index, while production served 549/869 and 263,572 bytes.

A comment in the project's next.config.ts already explained the mechanism. Pages prerendered at build time read Sanity at that moment, so a build still holding a wrong or revoked token ships them empty. That is why the config inlines a fingerprint of the token the build ran with, the first eight hex characters of its SHA-256, or an empty string when there is no token, and never the token itself. A simplified version, not a quote of the file:

// simplified; neutral local names
import { createHash } from "node:crypto";
 
const raw = process.env.SANITY_API_READ_TOKEN; // raw value, no trim
const fingerprint = raw
  ? createHash("sha256").update(raw).digest("hex").slice(0, 8)
  : "";
// only `fingerprint` is inlined into the build, never `raw`

The dashboard from earlier shows this kind of fingerprint for the runtime and for the build, but it later turned out the dashboard did not exist on staging at all. Its page answered 404, which only happens when its debug token variable is unset in that environment, and the API answered 401 along with it.

One grep on the server

So the staging build fingerprint had to be read straight from the build output on the server. The value lives in the JavaScript files under .next/server, and one grep is enough to print it. I verified this locally before asking the client's DevOps engineer for the output. The field name is anonymized:

grep -rhoE --include='*.js' 'tokenFingerprintAtBuild:[^,}]{0,30}' .next/server | sort -u

The client's DevOps engineer ran it on the staging server on the evening of 30 September, and on 1 October the conclusion was recorded: the staging build fingerprint differed from the Viewer token's fingerprint in the production runtime and build. Because next.config hashes the raw env value without trimming it, I tested variants of the Viewer token locally, one by one: a trailing newline, CRLF, spaces, quotes, a Bearer prefix, the whole env line, one character missing. None of them produced the staging fingerprint, and neither did any variable in my local .env.local.

The repo side was proven clean: no env file was tracked in git, there was no buildspec or deploy script in the repo, and SANITY_API_READ_TOKEN was only ever read, never assigned. The same tree, built 8 minutes apart for production and for staging, gave different fingerprints, so the value came from the staging build environment, not from the code.

My working theory that day was that the staging build most likely held the old token from a source not visible in the client's DevOps console, whether an SSM or Secrets Manager entry, a CodePipeline override, or a buildspec step. The client's DevOps engineer pushed back with a short reply, "env var is changed, nowhere to update". My next list of suspects included .env.local, .env.production, and .env.production.local, which Next prefers over .env if the build runs on the server.

Two files in one folder

The root cause was found by the client's DevOps engineer on 1 October. By their account, the staging app folder on the server held two env files: .env with the new Viewer token, and a leftover .env.local from the early staging setup, only 7 variables, that still held the old token. Production, according to the same finding, had only .env.

Next.js loads .env.local before .env, so the staging build, which ran on the server, took the old token. The order is spelled out in the @next/env 16.3.6 installed in the repo, a single minified line cut here to the relevant parts:

// @next/env 16.3.6, dist/index.js (minified, trimmed)
if(!a){a=Object.assign({},process.env)}
// ...
const p=Object.assign({},a)
// ...
if(typeof u[t]==="undefined"&&typeof p[t]==="undefined"){u[t]=(l=e.parsed)===null||l===void 0?void 0:l[t]}
// ...
const d=c?"test":n?"development":"production"
const f=[`.env.${d}.local`,d!=="test"&&`.env.local`,`.env.${d}`,".env"].filter(Boolean)

In production mode the file list is .env.production.local, .env.local, .env.production, then .env, and a value from a file is used only if the variable is not already in the copy of the process environment taken at the start and has not been set by an earlier file. That means the process environment beats every file, and among the files, the one highest on the list wins. This is a different trap from NEXT_PUBLIC_* values being baked into the client bundle at build time, which I wrote about separately. SANITY_API_READ_TOKEN has no NEXT_PUBLIC_ prefix and the token is never inlined, so what went wrong here is which file wins.

Those two rules also explain why the pm2 diagnosis felt right. The correction I recorded on 1 October: the real cause was the leftover .env.local, read by the build and also by the runtime whenever the process environment lacked the variable, while the pm2 note is still true but was not the root cause. The set -a; . ./.env command on 29 September most likely worked precisely because the process environment beats every file, the leftover one included.

For two days, from 29 September to 1 October, every staging build used the revoked token, while the running app looked healthy because its process environment held the new one. The file did not come from the repo either. Line 34 of .gitignore is .env* and no env file was ever committed on any branch, so the staging .env.local had been created by hand, directly on the server.

Proof from the next build

The client's DevOps engineer reported deleting .env.local on staging and restarting pm2 without a rebuild. The running build at that point was still the 29 September one with the old fingerprint, so the proof had to wait for the next staging build. That build ran at 2026-10-01T08:43:36Z, and the first read 80 seconds later already served full prerendered sitemaps and a 240,094-byte search index, where every build since 29 September had come up empty. Production, built seven minutes and 23 seconds later at 2026-10-01T08:50:59Z, served exactly the same sitemap counts and index size.

A guard at build and start

Deleting the file closed this case, but not the class of bug. The guard I added that same day lists the .env* files in the app folder during next build and during next start, because next.config.ts is read by both, so the warning lands in the build log and the server log at every boot, and the report travels with the Telegram deploy message. The helper always writes one neutral line, and a warning appears only when two files set the same variable to different values. The two text fragments, quoted as they are:

const line = `env files at build: ${report.files.length > 0 ? report.files.join(", ") : "none"}`;
// ...
`Warning: ${names} ${verb} different values in ${report.files.join(" and ")}. ` +
  "Next.js uses the first file in that list that sets a variable, so keep a single env file on each server.",

The warning names variables only. A simulation in a temp folder, with .env.local holding the old token and .env holding the new one, printed a warning that named only SANITY_API_READ_TOKEN, with no values in the output. The guard went live on production on 1 October at 08:52 UTC with no visible change in output, and the production deploy message line reads env files at build: .env.

Two things are not proven. The guard's warning has only been tested through a local simulation, because the leftover file on staging was deleted before the guard was released, so there is no record of the warning appearing on a real server. And one question about production was never sent. My guess is that the production pm2 process does not store SANITY_API_READ_TOKEN in its own environment, so Next fills it from .env at every start. Running pm2 env <id> | grep -c SANITY_API_READ_TOKEN on the production server would answer it: 0 means safe, 1 means the same trap is waiting for the next token change.

Build logs read on 7 October later showed the distance between where a value is set and where it is used. CodeBuild runs pnpm build only as a check and its buildspec writes .env from CodeBuild's own variables, then the deploy script on the server runs pnpm build again and pm2 restart --update-env, so the build that serves visitors is the server's own. The reply "env var is changed, nowhere to update" was right about the places the client's DevOps engineer had looked, because CodeBuild and .env really did hold the new token. What could not be seen from there was the .env.local in the folder where that build ran.

The rules I wrote down from this case: ask for a fingerprint printed where the value is used, not evidence from where it is set, because the place a value is set cannot show which source wins. When an env change does not take, list every .env* file in the app folder and compare fingerprints, not just .env. And as long as a token can go missing without an error, check the content, not the status code.

The most expensive lesson is also the shortest. Staging could not be read, it was assumed to match production, and revoking the old token emptied it.