Secret Environment PaaS: Build-Time vs Runtime Injection

Foto oleh Beyond My Ken via Wikimedia Commons (CC BY-SA 4.0)
Tidak. Next.js meng-inline nilai NEXT_PUBLIC_ ke dalam bundle JavaScript saat build, mengganti setiap referensi dengan literal yang sudah dipatok, sehingga nilainya bisa dibaca siapa pun yang membuka network tab di browser. Perlakukan awalan itu sebagai penerbitan, bukan pengaturan izin. Key apa pun yang memberi akses ke sesuatu yang Anda pedulikan sebaiknya menjadi variable server yang di-inject saat runtime.
Tidak bisa. Dokumentasi Next.js menyatakan bahwa setelah di-build, aplikasi Anda tidak lagi merespons perubahan pada environment variable jenis ini, karena nilainya sudah disubstitusikan ke dalam bundle. Mengubahnya menuntut build ulang dan deploy ulang, dan bundle yang sudah sampai ke browser tetap membawa nilai lama. Kalau client memang perlu membaca sebuah nilai saat runtime, sajikan lewat API route.
Biasanya karena nilainya dibaca di module scope pada component atau route yang di-render statis, sehingga terekam ketika halaman di-prerender di mesin build. Pindahkan pembacaannya ke dalam request dengan memilih dynamic rendering lebih dulu, memakai connection, cookies, atau headers. Kegagalannya senyap: build tetap sukses dan di development semuanya normal karena dev server membaca ulang pada setiap request.
Pakai secret mount BuildKit, bukan ARG atau ENV. Panduan Docker sendiri menyebut build argument dan environment variable tidak layak untuk secret karena keduanya bertahan di image final. Secret mount memaparkan nilainya hanya selama satu instruksi RUN di /run/secrets/ dan tidak menaruhnya di layer mana pun. Setel required ke true, karena nilai defaultnya false dan secret yang hilang jika tidak akan lolos tanpa terdeteksi.
Rotasi atau revoke credential-nya sebelum menyelidiki hal lain, karena hanya langkah itu yang menghentikan paparannya. Panduan GitHub soal menghapus data sensitif membuka dengan instruksi yang sama, dan tim support mereka hanya membantu menghapus data kalau risikonya tidak bisa diredam dengan rotasi. Menulis ulang history itu beres-beres, bukan perbaikan: commit-nya tetap hidup di fork dan kembali kalau rekan tim push dari clone lama.

Foto oleh Beyond My Ken via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
Di PaaS, aman atau tidaknya sebuah environment variable ditentukan oleh kapan nilainya dibaca. Apa pun yang berawalan NEXT_PUBLIC_ di-inline ke client bundle saat build dan itu konstanta publik, bukan secret. Secret yang sungguhan harus di-inject saat runtime, dipisah per environment, dijauhkan dari build log, dan bisa dirotasi dengan jendela tumpang tindih.
Pertama kali saya salah soal ini, tidak ada yang rusak. Sebuah key yang saya taruh di balik awalan NEXT_PUBLIC_ ketika mengerjakan prototipe terburu-buru ikut naik ke production, duduk di client bundle selama berminggu-minggu, dan berfungsi sempurna sepanjang waktu itu. Nilainya juga bisa dibaca siapa pun yang membuka network tab, dan merotasinya berarti build ulang, deploy ulang, plus kesadaran bahwa setiap bundle yang sudah terkirim tetap membawa nilai lama.
Tulisan ini soal tiga environment yang benar-benar dipakai tim kecil di managed PaaS — preview, staging, dan production — yang semuanya diatur lewat dashboard yang sama, dengan kotak input yang sama untuk setiap nilai. Perilaku yang dijelaskan di sini berasal dari panduan environment variables Next.js, dokumentasi build Docker, dan referensi keamanan GitHub untuk Actions, semuanya ditautkan di akhir. Sudut pandangnya defensif: bagaimana mencegah sebuah nilai bocor, bukan bagaimana mencarinya.
Semua perdebatan soal secret di PaaS bermuara pada satu pertanyaan: pada momen apa nilai ini dibaca? Kalau dibaca saat build, nilainya menjadi bagian dari sebuah artefak — bundle JavaScript, image layer, satu baris log — dan ia bertahan persis selama artefak itu ada. Kalau dibaca saat runtime, nilainya diserahkan ke proses yang sudah berjalan, dan mengubahnya cukup dengan restart, bukan build ulang.
Dashboard platform menyembunyikan perbedaan itu, karena keduanya muncul dalam satu daftar dengan ikon gembok yang sama di sebelahnya. Antarmuka itulah penyebab terbesar tim salah langkah. Dua variable yang berjajar bisa punya masa hidup yang sama sekali berbeda: yang satu dibekukan ke dalam file yang sudah di-cache browser, yang lain dibaca segar pada setiap request. Tidak ada satu pun di layar yang memberi tahu mana yang mana. Hanya kodenya yang tahu.
Next.js sangat gamblang soal apa yang dilakukan awalan itu. Supaya sebuah nilai bisa diakses di browser, Next.js meng-inline nilai tersebut saat build ke dalam bundle JavaScript yang dikirim ke client, mengganti setiap referensi ke process.env dengan nilai yang sudah dipatok. Awalan itu bukan pengaturan izin. Ia adalah instruksi untuk compiler, dan begitu build selesai nilainya menjadi string literal di sebuah file di CDN.
// Right: a value you are content for anyone to read out of the bundle.
// next build replaces the reference with the literal string, everywhere.
setupAnalytics(process.env.NEXT_PUBLIC_ANALYTICS_ID);
// compiles to -> setupAnalytics("PLACEHOLDER-PUBLIC-ID");
// Wrong: same prefix, private value. The prefix is the entire decision,
// and it ships this key to every browser that loads the page.
const payments = new Payments(process.env.NEXT_PUBLIC_PAYMENTS_KEY);
// Not a fix: dynamic lookups are not inlined. On the client this is simply
// undefined -- you have hidden the value from yourself, not from anyone else.
const name = "NEXT_PUBLIC_ANALYTICS_ID";
setupAnalytics(process.env[name]);Dokumentasinya menyebut konsekuensinya terang-terangan: setelah di-build, aplikasi Anda tidak lagi merespons perubahan pada environment variable jenis ini. Bahkan kasus yang paling sering menjebak tim ikut disebut — build satu image Docker lalu promosikan ke beberapa environment, dan semua nilai NEXT_PUBLIC_ tetap beku pada apa pun yang dipegang mesin build. Jadi merotasi satu nilai berarti build ulang dan deploy ulang, dan itu pun tidak menarik kembali bundle yang sudah terlanjur dikirim. Perlakukan awalan itu sebagai penerbitan, dan jangan taruh apa pun di baliknya yang Anda keberatan dikutip orang lain.
Variable yang hanya ada di server tidak otomatis menjadi variable runtime. Kalau Anda membaca process.env di module scope pada component atau route yang di-render statis, nilainya terekam ketika halaman di-prerender — dan di PaaS itu terjadi di mesin build, di dalam build environment, dengan nilai milik build environment. Build sukses, halaman ter-render, dan konfigurasi milik deployment itu sendiri tidak pernah dibaca.
// app/dashboard/page.tsx
// Wrong: evaluated once, while the page is prerendered. On a PaaS that is
// the build machine, so the deployment's own value is never consulted.
const apiKey = process.env.INTERNAL_API_KEY;
// Right: opt into dynamic rendering first, then read. cookies() and
// headers() do the same thing; connection() is the one with no side effect.
import { connection } from "next/server";
export default async function Page() {
await connection();
const apiKey = process.env.INTERNAL_API_KEY;
return <Panel token={apiKey} />;
}
// The acceptance test for "injected at runtime": can this exact artefact be
// deployed to staging and to production and read two different values?Next.js mendokumentasikan solusinya sebagai memilih dynamic rendering lebih dulu sebelum membaca. Menunggu connection, atau memakai cookies, headers, dan API request-time lainnya, memindahkan pembacaan ke dalam request itu sendiri, dan dokumentasinya menyebut alasan kenapa ini penting: itulah yang membuat satu image bisa dipromosikan lewat beberapa environment dengan nilai berbeda. Properti itulah yang Anda beli dengan runtime injection, dan ia memberi satu tes penerimaan yang bersih. Kalau artefak yang sama tidak bisa dikirim ke staging dan ke production lalu membaca dua nilai berbeda, nilainya tidak di-inject saat runtime, apa pun kata dashboard.
Pembacaan di module scope gagal tanpa suara. Tidak ada error, tidak ada peringatan di output build, dan di development semuanya jalan sempurna karena dev server membaca file pada setiap request. Anda baru menemukannya di production, ketika credential yang baru saja dirotasi terus ditolak sementara dashboard bersikeras nilai barunya sudah terpasang.
Pemisahan per environment adalah kontrol murah yang paling sering dilewati karena merepotkan, bukan karena sulit. Kalau preview, staging, dan production sama-sama memegang API key yang sama, Anda bukan punya satu credential yang dipakai tiga kali — Anda punya satu credential dengan paparan tiga kali lipat dan tanpa cara merotasinya sendirian. Mencabutnya mematikan ketiganya sekaligus, jadi tidak ada yang berani menjadwalkannya, dan ia hidup terus bertahun-tahun.
Tes yang saya pakai adalah apakah saya bisa me-revoke sebuah nilai tanpa memberi tahu siapa pun. Kalau me-revoke credential database staging perlu didahului peringatan di channel tim, berarti itu credential production yang sedang memakai nama lain, dan pemisahannya hanya hidup di konvensi penamaan.

Hampir semua kebocoran nyata yang saya lihat terjadi karena ulah sendiri dan membosankan. Satu langkah debugging yang mencetak seluruh environment. Sebuah shell script yang jalan dengan set -x. Sebuah CLI yang menggemakan argumennya sendiri ketika gagal mem-parsing. Tidak ada yang menyerang apa pun: build mencetak nilainya ke log yang bisa dibaca semua orang di project itu, dan ia mengendap di sana.
Platform memang menyensor nilai secret yang dikenalnya dari log, dan cara GitHub merumuskan sejauh mana itu berlaku layak dibaca sebelum Anda bersandar padanya. Karena sebuah nilai secret bisa ditransformasi dengan banyak cara, penyensoran otomatis tidak dijamin. Structured data disebut secara khusus — membungkus secret dalam blob JSON, XML, atau YAML secara signifikan menurunkan peluangnya disensor, karena penyensoran sebagian besar bergantung pada menemukan kecocokan persis dengan nilainya. Salinan yang di-encode base64 adalah string yang berbeda, jadi ia harus didaftarkan sebagai secret juga. Dan tidak selalu jelas bagaimana sebuah tool yang bukan Anda tulis mengirim error-nya ke standard error, dan begitulah secret berakhir di error log.
# Wrong: the token arrives as a build argument, so it persists in the
# final image -- in its metadata and its history, not just this layer.
ARG REGISTRY_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$REGISTRY_TOKEN" > .npmrc \
&& npm ci
# Right: mounted for the duration of one RUN, present in no layer.
# The default path is /run/secrets/<id>. required=true matters: it
# defaults to FALSE, so an unpassed secret leaves the file missing and
# npm ci quietly carries on against the public registry instead.
RUN --mount=type=secret,id=registry_token,required=true \
npm config set "//registry.npmjs.org/:_authToken=$(cat /run/secrets/registry_token)" \
&& npm ci \
&& rm -f .npmrc
# The value never becomes an ARG or an ENV. env= reads it from the
# builder's own environment rather than a file on disk.
# docker build --secret id=registry_token,env=REGISTRY_TOKEN .Untuk credential yang memang dibutuhkan oleh build — token registry privat, paket berlisensi, key untuk mengunggah source map — jawabannya adalah secret mount. Panduan Docker menyatakan bahwa build argument dan environment variable tidak layak dipakai untuk mengoper secret ke build justru karena keduanya bertahan di image final, sedangkan secret mount membuat nilainya tersedia selama satu instruksi saja dan tidak menaruhnya di layer mana pun. Baca opsi required baik-baik: nilai defaultnya false, jadi mount yang secret-nya tidak pernah dioper akan meninggalkan file itu tidak ada dan perintahnya jalan terus tanpa nilai tersebut. Menyetelnya ke true mengubah salah konfigurasi yang senyap menjadi build yang gagal, dan itu pertukaran yang Anda inginkan.
Secret dibiarkan tidak dirotasi bertahun-tahun, dan penyebabnya jarang kelalaian. Penyebabnya adalah prosedurnya menuntut perubahan serentak: revoke key lama dan semua konsumennya rusak sampai key baru terpasang di mana-mana. Itu butuh jendela maintenance, jadi ia dijadwalkan, lalu digeser, lalu diam-diam dilupakan. Rancang tumpang tindihnya sejak awal dan masalah penjadwalan itu hilang dengan sendirinya.
Panduan GitHub menyarankan rotasi berkala untuk mempersempit jendela waktu selama secret yang bocor masih berlaku, dan meninjau secret yang terdaftar lalu membuang yang sudah tidak dipakai. Bagian kedua mendapat perhatian jauh lebih sedikit dari yang pantas: secret menganggur yang masih duduk di store adalah paparan tanpa manfaat tersisa. Satu-satunya nilai yang tidak punya opsi jendela tumpang tindih adalah konstanta publik yang sudah di-inline, karena bundle yang sudah terkirim tidak bisa ditarik kembali — rencanakan rotasinya sebagai menerbitkan nilai baru lalu me-revoke yang lama di sisi vendor, dengan urutan itu.
Tulis runbook rotasinya pada hari Anda menambahkan secret itu, bukan pada hari Anda membutuhkannya. Ukurannya adalah apakah rekan tim bisa menjalankannya dari awal sampai akhir tanpa bertanya kepada Anda. Kalau ia bergantung pada ingatan Anda, itu bukan runbook, dan ia tidak akan dijalankan jam tiga pagi oleh siapa pun yang sedang on call.
Dashboard yang menampilkan setiap nilai dalam bentuk plaintext ke semua anggota project sudah mengambil keputusan access control atas nama Anda, dan satu-satunya argumen yang mendukungnya adalah kemudahan. Pilih store yang bersifat write-only setelah penyimpanan pertama, di mana antarmuka hanya menampilkan nama variable dan tidak pernah lagi nilainya. Itu hanya menambah satu kali tempel dari password manager dan menghapus seluruh kategori kecelakaan: layar yang di-share, panggilan yang direkam, screenshot yang ditempel ke sebuah ticket.
Pertanyaan audit lebih tajam daripada pertanyaan kebijakan. Tanyakan siapa yang membaca password database production dalam sembilan puluh hari terakhir. Kalau store-nya tidak menyimpan log pembacaan, jawaban jujurnya adalah siapa pun yang punya akses dashboard bisa saja membacanya, kapan saja, dan tidak ada yang akan tahu — dan itu fakta tentang arsitektur Anda, bukan celah di dokumen kebijakan. Dua hal mengikuti kalau Anda menerimanya. Lebih sedikit orang yang boleh memegang akses dashboard production dibanding yang memegang akses repository. Dan credential yang dipakai pipeline harus membawa least privilege yang benar-benar dibutuhkan pekerjaannya, persis seperti yang disarankan GitHub untuk workflow token bawaannya sendiri.

Rotasi dulu, selidiki belakangan. Nalurinya adalah merekonstruksi apa yang terjadi sebelum menyentuh apa pun, dan itu terbalik, karena rotasi adalah satu-satunya langkah yang menghentikan jam berdetak. Panduan GitHub sendiri soal menghapus data sensitif dari repository membuka dengan hal itu persis: kalau data sensitifnya adalah sebuah secret, revoke atau rotasi dulu sebagai langkah pertama, karena begitu di-revoke ia tidak bisa lagi dipakai untuk akses, dan itu saja mungkin sudah cukup menyelesaikan masalahnya.
Baru setelah itu hitung blast radius-nya — apa saja yang bisa dijangkau credential tersebut, log mana yang akan menunjukkan ia dipakai, dan apa lagi yang tinggal di store yang sama sehingga sekarang harus dianggap ikut terpapar. Anggap secret yang sudah ter-commit ke git terkompromi selamanya. Menulis ulang history tidak membatalkannya: commit itu tetap hidup di fork mana pun, dan rekan yang menarik perubahan setelah rewrite Anda lalu push akan mengembalikannya begitu saja. GitHub tegas soal urutannya, dengan menyatakan bahwa tim support mereka hanya membantu menghapus data sensitif kalau risikonya tidak bisa diredam dengan merotasi credential yang terdampak. Menulis ulang history itu beres-beres. Rotasi itu perbaikannya.
Sebelum memasang variable apa pun di dashboard platform, sekarang saya menjawab satu pertanyaan dengan tiga kemungkinan jawaban. Kalau nilainya dibaca browser berarti ia publik, jadi beri nama yang jujur dan jangan pernah menaruh credential di balik awalan itu. Kalau ia dibaca server yang sedang berjalan, ia milik tepat satu environment, dibaca di dalam request, dan punya runbook rotasi yang ditulis sebelum dibutuhkan. Kalau ia dibaca oleh build, ia di-mount untuk satu instruksi dan tidak muncul di layer mana pun maupun di log mana pun. Semua hal lain di tulisan ini hanyalah konsekuensi dari mendapatkan klasifikasi itu dengan benar pada hari nilainya dibuat.
Sumber