D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

`vercel env pull` Writes `[SENSITIVE]`, Not the Secret Value: That Text Became a Dashboard Password and a Tenant Slug

· · 12 min read
`vercel env pull` Writes `[SENSITIVE]`, Not the Secret Value: That Text Became a Dashboard Password and a Tenant Slug

On 8 September 2026, the private dashboard on this site could at one point be opened by anyone who typed [SENSITIVE] as the password. I never chose that password. That day I was moving environment variables from this site's old Vercel project to the new project for its Astro version, and those eleven characters were what got installed as the value.

The same text turned up again on the staging of a client project I was working on. On 11 September it became a tenant slug, and on 13 September it became a tenant slug once more, this time for a second tenant next to the real one. All three incidents are fixed, and the end state has been verified in both projects. This post is about where the text comes from, why the symptoms pointed somewhere else, and the guard that now sits between a pulled env file and anything that writes.

What vercel env pull actually writes

vercel env pull pulls a Vercel project's environment variables into a local file. For variables of the Config type, the file holds what you would expect, KEY="value". For variables of the Secret type, the command does not decrypt the value and writes the literal text [SENSITIVE] instead, the same eleven characters for every Secret variable.

What makes it slippery is that the file still looks complete. Every key appears with a quoted value, including the keys whose values were never brought along. A script that reads the file, whether to copy its contents into another project or to source it before a seed, takes [SENSITIVE] as a real value, because that is what is written there.

Checking it on any project takes ten seconds: create one Secret variable holding a value you know, pull, and look at what comes out.

printf %s KNOWN_VALUE | vercel env add TEST production --project X --sensitive
vercel env pull /tmp/test --project X --environment production --yes
grep '^TEST=' /tmp/test        ->  TEST="[SENSITIVE]"
vercel env rm TEST production --project X --yes

The arrow on the third line is not part of the command; it shows what grep prints. A variable created with --sensitive comes back from the pull as [SENSITIVE], and the last line removes it again.

This is a different problem from the NEXT_PUBLIC_ one I wrote about on this blog, where the right value never made it into the browser bundle because that variable is baked in at build time, not read at runtime. In the incidents below, the real value never left the project it came from. What was wrong was the copy on disk, and everything built on top of that copy.

The right password is rejected, [SENSITIVE] gets in

The new project for the Astro version of this site got five variables: two KV variables of the Config type and three dashboard admin secrets of the Secret type. My copy script read the file pulled from the old project and pushed its contents into the new one. For the two KV variables, the file held real values. For the three admin secrets it held [SENSITIVE], and that text is what got installed, including as the dashboard password.

The visible symptom pointed the other way. The password check on this site fails closed, so the right password was rejected with "Invalid credentials", exactly as if the variable were empty. From the outside, a value that never arrived and a value that arrived wrong look the same. The difference is that the second state has one password that works, and it is [SENSITIVE].

What exposed it was comparing the sha256 of every value across the two projects. Three different secrets turned out to have the same hash and the same length. Three secrets created independently cannot be identical, so that match alone was enough to know that what had been copied was not the secrets.

The fix took the values from the source that actually holds them, the .env.local in the old project's folder, not from vercel env pull. Once they were reinstalled, I tested them through behavior. A real login with the password from .env.local answered 200 with a cookie, and the dashboard rendered 300 visitor rows, which proved that the dashboard's salt, one of those three secrets, was right as well.

What did not recover was the data. I had decided against a second Upstash database at the time, so the Astro project ran on the production env, and whatever it wrote landed in real data. A rate limit test ran while the salt still held the placeholder, and it left one ghost visitor row with about 20 visits in the dashboard, plus increments in the country and referrer zsets. Fixing the env does not erase what was written while it was wrong.

A tenant named [SENSITIVE]

The client project's staging went up in the small hours of 11 September 2026, deployed from a local checkout with vercel deploy --prod --yes. The project is a multi-tenant platform on Next.js and Payload CMS, with a Postgres database on Neon. The app variables set on Vercel for production and preview were PAYLOAD_SECRET, NEXT_PUBLIC_SERVER_URL, and DEFAULT_TENANT_SLUG, which holds the client's tenant slug.

To run the seed against Neon from my machine, the pulled file was sourced. DEFAULT_TENANT_SLUG came through as [SENSITIVE], and the seed created a tenant with that slug. The deployment itself read the right slug from Vercel, so no tenant matched it. This project's access code treats a request whose tenant cannot be established as a request that may read nothing, and that matches the symptom: every anonymous read was refused, the API answered 403 and every page 404, while /login, /admin, and the media route kept working.

The fix was one UPDATE on the tenants table. The staging notes call it the trap that cost half an hour, and from that day the project notes carried the rule it produced: whenever running anything against the hosted database from the local machine, set DEFAULT_TENANT_SLUG and PAYLOAD_SECRET explicitly, and take only the Config typed values, the database and Blob ones, from the pulled file.

13 September: the seed that made a second tenant

According to that day's notes, staging broke twice in one afternoon, both times from writing to the hosted database on an assumption that a single read would have disproved. The other break has its own story. This one is [SENSITIVE] again: the rule from 11 September, recipe included, was already in the project notes when the 13 September work session started, and it was not applied. The seed ran with the pulled file sourced, so DEFAULT_TENANT_SLUG was the eleven character string [SENSITIVE]. At that point the project ran Next.js 16.3.0 and Payload 3.88.0. The part of the seed that settles the tenant, trimmed and with the tenant's slug and name disguised, looks like this:

// seed, trimmed; tenant slug and name disguised
const tenantSlug = process.env.DEFAULT_TENANT_SLUG || 'main-tenant'
const existingTenant = await payload.find({
  collection: 'tenants',
  where: { slug: { equals: tenantSlug } },
  limit: 1,
  overrideAccess: true,
})
const tenant =
  existingTenant.docs[0] ??
  (await payload.create({
    collection: 'tenants',
    data: {
      name: 'Main Tenant',
      slug: tenantSlug,
      // ...
    },
  }))

Two things in that excerpt let the placeholder through. The || fallback only fires for falsy values such as an empty string or undefined, and [SENSITIVE] is a truthy eleven character string. Then the find-or-create pattern means a wrong slug does not end in an error but in a new tenant. No tenant had that slug, so the seed created one and wrote 187 documents under it, while the deployment carried on reading the real tenant.

The fallout was messier than the first time. The database held two tenants, the site still showed content that had already been rejected, and the run died at its last step because the demo member's address was already taken in the other tenant. The globals were never written, and every translated address stayed at 404.

The evidence that should have stopped the seed was there an hour earlier. The site was answering 200 with the old tenant's content while the slug in the pulled file matched no tenant at all, and the project's tenant resolver matches a domain and then that slug, with no further fallback. Those two observations cannot both be true, and the answer sat inside the contradiction itself: the file did not hold the value the deployment reads. The contradiction was noticed, written down as "something I do not understand", and then walked past instead of being resolved.

Clearing out two tenants

The cleanup had a trap of its own. Deleting the stray documents one at a time through the Payload API crashed partway on a dropped socket and then hung for an hour. What worked was a single DO block that truncated every table except payload_migrations and the admin tables, followed by a fresh seed and the translation apply script. Both tenants were emptied, one was seeded fresh, and the tenant's slug went back to the value the deployment reads. By the end of 13 September, staging answered 200 on every English address, with all of its content under a single tenant.

The rule that came out of this part is short: bulk work against a remote database runs as one atomic statement.

A guard between the pulled file and the database

After the second incident, seeds against Neon went through a bash script that stands between the pulled file and the command that writes. The comment at its top gives the reason: the tenant slug, the secret, and the push flag are set in the script instead of being taken from the pulled file, because vercel env pull writes the literal text [SENSITIVE] for encrypted values and a seed once built a second tenant on it. The core of it, with the slug disguised:

# guarded seed script, trimmed; tenant slug disguised
val() { grep "^$1=" "$F" | head -1 | cut -d= -f2- | tr -d '"'; }
export DATABASE_URL="$(val DATABASE_URL_UNPOOLED)"
export BLOB_READ_WRITE_TOKEN="$(val BLOB_READ_WRITE_TOKEN)"
export NEXT_PUBLIC_SERVER_URL="$(val NEXT_PUBLIC_SERVER_URL)"
export USE_BLOB=1 PAYLOAD_DB_PUSH=0 DEFAULT_TENANT_SLUG=main-tenant
export PAYLOAD_SECRET="$(grep '^PAYLOAD_SECRET=' "$ROOT/.env" | cut -d= -f2-)"
for v in DATABASE_URL BLOB_READ_WRITE_TOKEN PAYLOAD_SECRET DEFAULT_TENANT_SLUG NEXT_PUBLIC_SERVER_URL; do
  case "${!v}" in *SENSITIVE*|"") echo "STOP: $v is empty or [SENSITIVE]"; exit 1;; esac
done

From the pulled file, val is used for only three Config typed values: the unpooled database address, the Blob token, and the server URL. The tenant slug is written into the script itself, and PAYLOAD_SECRET is read from the local .env in the checkout. The loop after that stops if any of the five variables is empty or contains SENSITIVE. Before this part, the script also stops when the pulled file is missing, printing the vercel env pull command to run, or when there is no .env in the checkout. After the loop, it prints the target host, the tenant slug, and the tenant row on Neon for that slug, and then goes straight on to the requested command. The shell on that machine is zsh, but the script is written for bash, because the ${!v} indirect expansion in that loop exists only in bash.

Since 16 September, the pulled file lives in an umbrella folder beside the checkout, outside git, where the script looks for it, and never inside the checkout. That script is what ran the seed against Neon on 14 September, the pre flight on Neon on 18 September that found one tenant, and an eight minute seed on 24 September.

There is one limit I should own. The rule in the project notes names three stop signs: [SENSITIVE], an empty string, and any value that matches nothing in the database. The script automates only the first two. For the third, it just prints the tenant row on Neon for a person to read, and carries on even if that row is empty.

Secret types nobody asked for

This trap repeats easily because a variable's type is not always a deliberate choice. On 8 September, after the dashboard episode, I set two email address variables as Config rather than Secret on purpose, so their values could be verified again without walking into the same trap. The same day, another variable turned out to be stored by Vercel as a Secret although I had not asked for that. Its value cannot be read back, so verifying it has to go through behavior.

On the client project, on 26 September, the staging database moved to a Neon account of my own, and the same trap showed up again along the way: Vercel CLI 59 makes variables from env add Secret by default, so env pull once again writes [SENSITIVE] for them. The connection strings went into a separate env file in the umbrella folder with mode 600, and because of that trap, the local env file was rewritten from that file, never from a pull.

Memory of which type was picked back then is not something to lean on. The file itself is: every line holding [SENSITIVE] is a value that did not come along.

Installed is not the same as correct

Both incidents have the same shape. A value is read from a file and trusted because the file looks complete. An env that is installed is not necessarily correct, and verifying it has to go through behavior: a real login, or comparing the fingerprint of the value against a trusted source. On my dashboard, the sha256 comparison is what exposed the problem.

For anything that writes to a hosted database, the order is now fixed. First print the values that decide where the write goes, then check them against reality, for example by querying the tenant table for that slug. A pulled env file is never sourced wholesale for a write. [SENSITIVE], an empty string, and a value that matches nothing in the database are treated as a stop, not a default.

And when two observations cannot both be true, settle that before running anything. On 13 September, the contradiction that got walked past was the whole answer.