Pada 8 September 2026, dasbor privat di situs ini sempat bisa dimasuki siapa pun yang mengetik [SENSITIVE] sebagai sandinya. Saya tidak pernah memilih sandi itu. Hari itu saya sedang memindahkan variabel lingkungan dari projek Vercel lama situs ini ke projek baru untuk versi Astro-nya, dan teks sebelas karakter itulah yang ikut terpasang sebagai nilai.
Teks yang sama muncul lagi di staging proyek klien yang sedang saya kerjakan. Pada 11 September ia menjadi slug tenant, dan pada 13 September ia menjadi slug tenant sekali lagi, kali ini untuk tenant kedua di samping tenant yang asli. Ketiga kejadian itu sudah diperbaiki, dan keadaan akhirnya sudah diverifikasi di kedua proyek. Tulisan ini soal dari mana teks itu datang, kenapa gejalanya menunjuk ke arah lain, dan penjaga yang sekarang berdiri di antara berkas hasil pull dan apa pun yang menulis.
Yang sebenarnya ditulis vercel env pull
vercel env pull menarik variabel lingkungan sebuah projek Vercel ke berkas lokal. Untuk variabel bertipe Config, isinya seperti yang diharapkan, KEY="value". Untuk variabel bertipe Secret, perintah itu tidak mendekripsi nilainya dan menulis teks harfiah [SENSITIVE], sebelas karakter yang sama untuk setiap variabel Secret.
Yang membuatnya licin, berkas itu tetap terlihat lengkap. Setiap kunci muncul dengan nilai berkutip, termasuk kunci yang nilainya tidak ikut dibawa. Skrip yang membaca berkas itu, entah untuk menyalin isinya ke projek lain atau untuk di-source sebelum seed, menerima [SENSITIVE] sebagai nilai sungguhan, karena memang itu yang tertulis.
Memeriksanya di projek mana pun cukup sepuluh detik: buat satu variabel Secret berisi nilai yang kamu tahu, tarik, lalu lihat apa yang keluar.
printf %s NILAI_UJI | vercel env add UJI production --project X --sensitive
vercel env pull /tmp/uji --project X --environment production --yes
grep '^UJI=' /tmp/uji -> UJI="[SENSITIVE]"
vercel env rm UJI production --project X --yesPanah di baris ketiga bukan bagian perintah, melainkan apa yang dicetak grep. Variabel yang dibuat dengan --sensitive kembali dari pull sebagai [SENSITIVE], dan baris terakhir menghapusnya lagi.
Ini lain dari masalah NEXT_PUBLIC_ yang pernah saya tulis di blog ini, ketika nilai yang benar tidak masuk ke bundle browser karena variabel itu ditanam saat build, bukan dibaca saat runtime. Di kejadian-kejadian berikut, nilai asli tidak pernah hilang dari projek asalnya. Yang keliru adalah salinannya di disk, dan semua yang dibangun di atas salinan itu.
Sandi yang benar ditolak, [SENSITIVE] diterima
Di projek baru untuk versi Astro situs ini terpasang lima variabel: dua variabel KV bertipe Config dan tiga rahasia admin dasbor bertipe Secret. Skrip salin saya membaca berkas hasil pull dari projek lama, lalu mendorong isinya ke projek baru. Untuk dua variabel KV, berkas itu memuat nilai sungguhan. Untuk tiga rahasia admin, isinya [SENSITIVE], dan teks itulah yang terpasang, termasuk sebagai sandi dasbor.
Gejala yang terlihat justru menunjuk ke arah lain. Pemeriksa sandi di situs ini gagal tertutup, jadi sandi yang benar ditolak dengan "Invalid credentials", persis seperti kalau variabelnya kosong. Dari luar, nilai yang tidak terkirim dan nilai yang terkirim tapi salah tampak sama. Bedanya, keadaan yang kedua punya satu sandi yang berhasil, yaitu [SENSITIVE].
Yang membongkarnya adalah membandingkan sha256 setiap nilai di dua projek. Tiga rahasia yang berbeda ternyata punya hash yang sama dan panjang yang sama. Tiga rahasia yang dibuat sendiri-sendiri tidak mungkin identik, jadi kesamaan itu sudah cukup untuk tahu bahwa yang tersalin bukan rahasianya.
Perbaikannya mengambil nilai dari sumber yang memang menyimpan nilai asli, yaitu .env.local di folder projek lama, bukan dari vercel env pull. Setelah terpasang ulang, nilainya saya uji lewat perilaku. Login sungguhan dengan sandi dari .env.local dibalas 200 beserta cookie, dan dasbor merender 300 baris pengunjung, sehingga salt dasbor, salah satu dari tiga rahasia tadi, terbukti benar juga.
Yang tidak ikut pulih adalah data. Waktu itu saya memutuskan tidak membuat basis data Upstash kedua, jadi projek Astro memakai env produksi, dan apa pun yang ditulisnya mendarat di data asli. Sebuah uji rate limit sempat berjalan saat salt masih berisi teks penampung, dan meninggalkan satu baris pengunjung hantu dengan sekitar 20 kunjungan di dasbor, ditambah kenaikan di zset negara dan rujukan. Memperbaiki env tidak menghapus apa yang tertulis selama env-nya salah.
Tenant bernama [SENSITIVE]
Staging proyek klien itu naik dini hari 11 September 2026, dideploy dari checkout lokal dengan vercel deploy --prod --yes. Proyeknya platform multi-tenant di atas Next.js dan Payload CMS, dengan database Postgres di Neon. Variabel aplikasi yang dipasang di Vercel untuk production dan preview adalah PAYLOAD_SECRET, NEXT_PUBLIC_SERVER_URL, dan DEFAULT_TENANT_SLUG yang berisi slug tenant klien.
Untuk menjalankan seed ke Neon dari mesin saya, berkas hasil pull di-source. DEFAULT_TENANT_SLUG ikut terbaca sebagai [SENSITIVE], dan seed membuat tenant dengan slug itu. Deployment-nya sendiri membaca slug yang benar dari Vercel, sehingga tidak ada tenant yang cocok untuknya. Kode akses proyek ini memperlakukan permintaan yang tenant-nya tidak bisa ditetapkan sebagai permintaan yang tidak boleh membaca apa pun, dan itu cocok dengan gejalanya: setiap baca anonim ditolak, API membalas 403 dan setiap halaman 404, sementara /login, /admin, dan route media tetap jalan.
Perbaikannya satu UPDATE di tabel tenants. Catatan staging menamai kejadian ini jebakan yang menghabiskan setengah jam, dan sejak hari itu catatan proyeknya memuat aturan yang lahir dari sana: setiap kali menjalankan apa pun ke database hosting dari mesin lokal, setel DEFAULT_TENANT_SLUG dan PAYLOAD_SECRET secara eksplisit, dan ambil dari berkas hasil pull hanya nilai bertipe Config, yaitu database dan Blob.
13 September: seed yang membuat tenant kedua
Menurut catatan hari itu, staging rusak dua kali dalam satu sore, keduanya karena menulis ke database hosting berdasarkan asumsi yang bisa dibantah dengan sekali membaca. Kerusakan yang satu lagi punya ceritanya sendiri. Yang ini kembali soal [SENSITIVE]: aturan dari 11 September, lengkap dengan resep perbaikannya, sudah ada di catatan proyek saat sesi kerja 13 September dimulai, dan tidak diterapkan. Seed dijalankan dengan berkas hasil pull di-source, sehingga DEFAULT_TENANT_SLUG berisi string sebelas karakter [SENSITIVE]. Per hari itu proyeknya memakai Next.js 16.3.0 dan Payload 3.88.0. Bagian seed yang menentukan tenant, dipotong dan dengan slug serta nama tenant disamarkan, bentuknya begini:
// seed, dipotong; slug dan nama tenant disamarkan
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,
// ...
},
}))Dua hal di potongan itu yang meloloskan teks penampung. Fallback || hanya menyala untuk nilai falsy seperti string kosong atau undefined, sedangkan [SENSITIVE] adalah string sebelas karakter yang truthy. Lalu pola cari-atau-buat membuat slug yang salah tidak berujung galat, melainkan tenant baru. Tidak ada tenant dengan slug itu, jadi seed membuatnya dan menulis 187 dokumen di bawahnya, sementara deployment tetap membaca tenant yang asli.
Akibatnya lebih berantakan daripada kejadian pertama. Database memegang dua tenant, situs masih menampilkan konten yang sebelumnya sudah ditolak, dan run mati di langkah terakhir karena alamat anggota demo sudah terpakai di tenant yang satu lagi. Globals tidak pernah tertulis, dan semua alamat terjemahan tetap 404.
Bukti yang seharusnya menghentikan seed itu sudah ada satu jam lebih awal. Situs menjawab 200 dengan konten tenant lama, padahal slug di berkas hasil pull tidak cocok dengan tenant mana pun, dan fungsi pencari tenant di proyek ini mencocokkan domain lalu slug itu, tanpa fallback lain. Dua pengamatan itu tidak mungkin sama-sama benar, dan jawabannya ada di dalam kontradiksi itu sendiri: berkas tersebut tidak memuat nilai yang dibaca deployment. Kontradiksi itu sempat tercatat sebagai sesuatu yang tidak dipahami, lalu dilewati alih-alih diselesaikan.
Membersihkan dua tenant
Pembersihannya punya jebakan sendiri. Menghapus dokumen nyasar satu per satu lewat API Payload mogok di tengah jalan karena socket putus, lalu menggantung selama satu jam. Yang berhasil adalah satu blok DO yang men-truncate semua tabel kecuali payload_migrations dan tabel admin, disusul seed baru dan skrip penerapan terjemahan. Kedua tenant dikosongkan, satu di-seed ulang dari awal, dan slug tenant kembali ke nilai yang dibaca deployment. Di akhir 13 September staging menjawab 200 di setiap alamat bahasa Inggris, dengan seluruh isinya di bawah satu tenant.
Aturan yang lahir dari bagian ini pendek: pekerjaan massal ke database jarak jauh dikerjakan sebagai satu pernyataan atomik.
Penjaga di antara berkas pull dan database
Sesudah kejadian kedua, seed ke Neon dijalankan lewat sebuah skrip bash yang berdiri di antara berkas hasil pull dan perintah yang menulis. Komentar di kepalanya menyebut alasannya: slug tenant, secret, dan flag push disetel di skrip, bukan diambil dari berkas pull, karena vercel env pull menulis teks harfiah [SENSITIVE] untuk nilai terenkripsi dan sebuah seed pernah membangun tenant kedua di atasnya. Bagian intinya, dengan slug disamarkan:
# skrip seed berpenjaga, dipotong; slug tenant disamarkan
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
doneDari berkas pull, val hanya dipakai untuk tiga nilai bertipe Config: alamat database unpooled, token Blob, dan URL server. Slug tenant ditulis langsung di skrip, dan PAYLOAD_SECRET dibaca dari .env lokal di checkout. Loop sesudahnya berhenti kalau salah satu dari lima variabel itu kosong atau mengandung SENSITIVE. Sebelum bagian ini, skrip juga berhenti kalau berkas hasil pull tidak ada, sambil mencetak perintah vercel env pull yang perlu dijalankan, atau kalau .env tidak ada di checkout. Sesudah loop, ia mencetak host tujuan, slug tenant, dan baris tenant di Neon untuk slug itu, lalu langsung menjalankan perintah yang diminta. Shell di mesin itu zsh, tapi skripnya ditulis untuk bash, karena ekspansi tidak langsung ${!v} di loop itu hanya ada di bash.
Sejak 16 September, berkas hasil pull disimpan di folder payung di samping checkout, di luar git, tempat skrip itu mencarinya, dan tidak pernah di dalam checkout. Skrip itulah yang dipakai untuk seed ke Neon pada 14 September, untuk pre flight ke Neon pada 18 September yang menemukan satu tenant, dan untuk seed delapan menit pada 24 September.
Ada satu batas yang perlu saya akui. Aturan di catatan proyek menyebut tiga tanda berhenti: [SENSITIVE], string kosong, dan nilai apa pun yang tidak cocok dengan isi database. Skrip ini baru mengotomatiskan dua yang pertama. Untuk yang ketiga, ia hanya mencetak baris tenant di Neon untuk dibaca manusia, lalu tetap jalan walaupun baris itu kosong.
Tipe Secret yang tidak diminta
Jebakan ini gampang berulang karena tipe variabel tidak selalu hasil pilihan sadar. Pada 8 September, sesudah urusan dasbor tadi, dua variabel alamat email sengaja saya pasang bertipe Config, bukan Secret, supaya nilainya bisa diverifikasi ulang tanpa masuk ke jebakan yang sama. Hari yang sama, satu variabel lain ternyata disimpan Vercel sebagai Secret walaupun tidak saya minta. Nilainya tidak bisa dibaca balik, jadi verifikasinya wajib lewat perilaku.
Di proyek klien, 26 September, database staging dipindah ke akun Neon milik saya sendiri, dan jebakan yang sama ditemui lagi di jalan: Vercel CLI 59 menjadikan variabel dari env add bertipe Secret secara default, sehingga env pull kembali menulis [SENSITIVE] untuk variabel-variabel itu. String koneksinya disimpan di berkas env terpisah di folder payung dengan mode 600, dan karena jebakan tadi, berkas env lokal ditulis ulang dari berkas itu, tidak pernah dari pull.
Ingatan soal tipe mana yang dulu dipilih bukan pegangan. Yang bisa dipegang adalah isi berkasnya sendiri: setiap baris yang memuat [SENSITIVE] adalah nilai yang tidak ikut terbawa.
Terpasang belum berarti benar
Dua kejadian ini bentuknya sama. Sebuah nilai dibaca dari berkas, lalu dipercaya karena berkasnya terlihat lengkap. Env yang sudah terpasang belum tentu benar, dan verifikasinya harus lewat perilaku: login sungguhan, atau membandingkan sidik jari nilainya dengan sumber yang tepercaya. Di dasbor saya, perbandingan sha256 itulah yang membongkar masalahnya.
Untuk apa pun yang menulis ke database hosting, urutannya sekarang tetap. Cetak dulu nilai yang menentukan ke mana tulisan itu pergi, lalu cocokkan dengan kenyataan, misalnya dengan mengkueri tabel tenant untuk slug itu. Berkas env hasil pull tidak pernah di-source utuh untuk penulisan. [SENSITIVE], string kosong, dan nilai yang tidak cocok dengan apa pun di database diperlakukan sebagai tanda berhenti, bukan nilai bawaan.
Dan kalau dua pengamatan tidak mungkin sama-sama benar, selesaikan itu sebelum menjalankan apa pun. Pada 13 September, kontradiksi yang dilewati itu adalah seluruh jawabannya.