Audit hydration SSR saat UI berbasis repo dan izin berubah perlu dilakukan ketika HTML awal dari server bisa berbeda dengan state aktual di browser. Masalah ini bukan hanya menyebabkan peringatan hydration mismatch, tetapi juga bisa memunculkan UI yang membingungkan, menampilkan aksi yang seharusnya tidak boleh terlihat, atau merender cuplikan data sensitif sebelum state klien mengoreksi tampilan.

Risiko seperti ini muncul pada aplikasi yang merender konten berdasarkan data repository, role, permission, feature flag, atau hasil evaluasi agent/AI. Konteks ancamannya mirip dengan kasus seperti GitLost: inti masalahnya bukan sekadar kebocoran data sebagai berita, melainkan bagaimana frontend SSR bisa merender state sensitif yang berbeda antara server dan klien. Jika server menganggap pengguna boleh melihat sesuatu, lalu klien belakangan mengetahui sebaliknya, ada jendela waktu di mana HTML awal sudah terlanjur dikirim.

Mengapa hydration mismatch pada SSR bisa menjadi masalah keamanan UI

Hydration mismatch terjadi saat markup yang dirender di server tidak cocok dengan hasil render pertama di klien. Pada banyak kasus, ini terlihat sebagai warning. Namun pada UI yang bergantung pada izin akses atau status repo, mismatch dapat berujung pada hal yang lebih serius:

  • Data sensitif tampil sesaat di HTML awal atau di skeleton yang terlalu informatif.
  • Tombol aksi terlarang seperti delete, approve, atau view private logs muncul sebentar sebelum disembunyikan.
  • Label status menyesatkan, misalnya repo terlihat public padahal private, atau hasil agent/AI ditampilkan padahal pengguna tidak memiliki hak akses.
  • Cache antar-pengguna bocor sehingga respons SSR milik satu pengguna dipakai untuk pengguna lain.

Masalah utamanya adalah server dan klien tidak sepakat pada state awal. Jika state tersebut menyangkut authorization atau visibility data, mismatch tidak lagi sekadar bug presentasi.

Penyebab umum mismatch pada UI berbasis repo, izin, dan hasil agent/AI

1. Auth state datang terlambat di klien

Pola umum yang berbahaya adalah server merender halaman dengan asumsi default, lalu klien memuat session atau token setelah mount. Akibatnya, elemen sensitif mungkin sudah dirender terlebih dahulu.

Contoh kasus:

  • Server belum membaca cookie dengan benar dan menganggap pengguna guest.
  • Server menganggap pengguna login, tetapi klien mendeteksi token kedaluwarsa.
  • Permission baru diketahui setelah fetch /me atau /permissions di browser.

Jika izin akses menentukan apakah suatu blok UI boleh terlihat, maka izin itu idealnya sudah final pada saat render server, atau blok tersebut jangan dirender sama sekali sampai state benar-benar pasti.

2. Feature flag berbeda antara server dan klien

Feature flag sering dievaluasi menggunakan konteks yang tidak identik. Server mungkin memakai header, cookie, atau segmentasi backend. Klien mungkin memakai local storage, SDK pihak ketiga, atau hasil bootstrap yang belum sinkron.

Akibatnya:

  • Server menampilkan panel eksperimen untuk pengguna tertentu.
  • Klien mengevaluasi flag sebagai off dan menghapus panel saat hydration.
  • Atau sebaliknya, klien menampilkan fitur yang server tidak render.

Untuk komponen yang menyentuh data sensitif, jangan bergantung pada evaluasi flag yang baru tersedia setelah browser aktif.

3. Cache per-user bocor atau salah kunci

Ini salah satu sumber masalah paling berbahaya. SSR atau edge cache yang tidak memvariasikan respons berdasarkan identitas pengguna, organisasi, atau izin bisa mengirim HTML milik pengguna A ke pengguna B.

Contoh kesalahan umum:

  • Cache key hanya berdasarkan URL, padahal halaman juga bergantung pada session.
  • Meng-cache hasil loader global yang mengandung repo aktif atau permission terakhir.
  • Menggunakan singleton store di server yang menyimpan state request sebelumnya.

Pada kasus seperti ini, hydration mismatch hanya gejala. Masalah sebenarnya adalah isolasi data per request yang gagal.

4. Conditional rendering berbasis data yang belum stabil

Kondisi seperti di bawah ini sering terlihat aman, padahal rentan:

{canViewSecrets && <SecretsPanel />}

Jika canViewSecrets berbeda antara server dan klien pada render awal, hasil markup akan berubah. Bila panel tersebut memuat nama branch private, hasil scan AI, atau file repo, pengguna sudah menerima HTML yang seharusnya tidak dikirim.

5. Data non-deterministic

SSR perlu deterministik. Hindari state awal yang bergantung pada:

  • Date.now(), Math.random(), atau UUID yang dihasilkan saat render.
  • Urutan object/map yang berubah.
  • Hasil agent/AI yang dapat berubah antara request server dan fetch klien.
  • Resolver yang memanggil sumber data berbeda di server dan browser.

Untuk hasil agent/AI, masalahnya bukan hanya mismatch teks. Bisa saja server merender ringkasan repo yang menyebut file sensitif, sementara klien gagal memuat izin dan menyembunyikannya kemudian.

Prinsip render aman untuk mencegah UI salah tampil

1. Gunakan keputusan akses yang final sebelum merender konten sensitif

Prinsip paling aman: jangan render konten sensitif sampai keputusan akses sudah final. Bila status auth atau permission belum pasti, tampilkan placeholder netral, bukan konten sebenarnya.

Placeholder netral berarti:

  • Tidak menyebut nama repo private.
  • Tidak menunjukkan jumlah secret, path file, atau hasil analisis.
  • Tidak menampilkan tombol aksi yang hanya boleh untuk role tertentu.

2. Bedakan “unknown”, “allowed”, dan “denied”

Banyak bug muncul karena state akses hanya berupa boolean. Padahal ada tiga kondisi penting:

  • unknown: belum dipastikan.
  • allowed: boleh tampil.
  • denied: tidak boleh tampil.

Jika Anda memakai boolean default false, UI sering terlihat seperti akses ditolak padahal baru belum termuat. Jika default true, jauh lebih berbahaya karena bisa merender data terlebih dahulu. Model tiga state membuat alur render lebih jujur dan aman.

type AccessState = 'unknown' | 'allowed' | 'denied';

function SecretSection({ access }: { access: AccessState }) {
  if (access === 'unknown') {
    return <SectionSkeleton title="Memuat akses..." />;
  }

  if (access === 'denied') {
    return <p>Anda tidak memiliki akses ke bagian ini.</p>;
  }

  return <SensitiveDataPanel />;
}

3. Pisahkan shell halaman dari data sensitif

Render shell umum seperti judul halaman, breadcrumb generik, atau layout dua kolom boleh dilakukan lebih awal. Namun panel yang isinya bergantung pada repo private, hasil scan, atau izin sebaiknya dipisahkan menjadi blok yang memiliki gate akses sendiri.

Keuntungannya:

  • Hydration mismatch lebih mudah dilokalisasi.
  • Risiko flash of sensitive content berkurang.
  • Komponen sensitif bisa diproteksi dengan strategi loading yang lebih ketat.

4. Jangan anggap menyembunyikan di klien sebagai proteksi

Jika HTML sensitif sudah dikirim dari server, menyembunyikan elemen dengan JavaScript setelah mount bukan solusi. Pengguna masih bisa melihat source, respons network, atau snapshot DOM awal.

Aturan praktis: bila data tidak boleh terlihat, data itu tidak boleh ada di HTML SSR, JSON bootstrap, atau cache yang dapat diambil browser tanpa hak yang benar.

Anti-pattern vs perbaikan di Next.js atau framework SSR serupa

Anti-pattern: permission dicek ulang hanya di klien

export default function RepoPage({ repo }) {
  const { user, permissions, loading } = useCurrentUser();

  return (
    <main>
      <h2>{repo.name}</h2>
      {permissions?.canViewFindings && (
        <FindingsPanel repoId={repo.id} />
      )}
    </main>
  );
}

Masalahnya:

  • Server mungkin sudah merender FindingsPanel atau tidak, tergantung state yang tidak sinkron.
  • permissions bisa undefined saat hydration awal.
  • Bila repo sendiri sensitif, judul halaman sudah lebih dulu bocor.

Perbaikan: hitung akses di server, kirim state akses eksplisit

export async function getServerSideProps(context) {
  const session = await getSessionFromRequest(context.req);
  const repo = await loadRepoBySlug(context.params.slug);

  const access = await resolveRepoAccess({
    session,
    repoId: repo.id,
  });

  if (!access.canViewRepo) {
    return { notFound: true };
  }

  return {
    props: {
      repo: {
        id: repo.id,
        name: repo.name,
      },
      access: {
        canViewFindings: access.canViewFindings,
      },
    },
  };
}

export default function RepoPage({ repo, access }) {
  return (
    <main>
      <h2>{repo.name}</h2>
      {access.canViewFindings ? (
        <FindingsPanel repoId={repo.id} />
      ) : (
        <p>Anda tidak memiliki akses ke temuan repo ini.</p>
      )}
    </main>
  );
}

Pola ini bekerja karena server dan klien memulai dari state akses yang sama. Yang perlu dijaga adalah jangan memuat ulang logika izin dengan sumber berbeda di render pertama klien tanpa sinkronisasi.

Anti-pattern: singleton store di server

// buruk: state global dapat terbawa antar-request
const store = createAppStore();

export function getStore() {
  return store;
}

Dalam SSR, store seperti ini bisa menyimpan repo aktif, organisasi, atau permission dari request sebelumnya. Jika request berikutnya memakai instance yang sama, data antar pengguna bisa tercampur.

Perbaikan: store per request

export function createRequestStore(initialState) {
  return createAppStore(initialState);
}

export async function getServerSideProps(context) {
  const initialState = await buildInitialState(context.req);
  const store = createRequestStore(initialState);

  return {
    props: {
      initialState: store.getState(),
    },
  };
}

Intinya bukan pada library state tertentu, melainkan memastikan tidak ada state server yang dibagi lintas request kecuali memang aman dan bebas data pengguna.

Anti-pattern: fallback yang terlalu informatif

{access === 'unknown' ? (
  <div>Memuat 12 temuan pada repo private...</div>
) : access === 'allowed' ? (
  <FindingsPanel />
) : null}

Walau belum menampilkan isi temuan, fallback ini sudah membocorkan bahwa repo bersifat private dan memiliki 12 temuan.

Perbaikan: skeleton netral

{access === 'unknown' ? (
  <SectionSkeleton title="Memuat data..." />
) : access === 'allowed' ? (
  <FindingsPanel />
) : (
  <p>Bagian ini tidak tersedia.</p>
)}

Strategi loading dan skeleton yang aman

Loading state sering dianggap detail UX, padahal pada SSR ini juga isu keamanan tampilan. Pilih strategi berdasarkan sensitivitas data.

1. Skeleton netral untuk blok sensitif

Gunakan placeholder generik tanpa metadata sensitif. Hindari:

  • Nama repo private.
  • Jumlah issue, findings, branch, atau file.
  • Nama agent, hasil evaluasi, atau status policy internal.

2. Progressive disclosure

Tampilkan shell umum lebih dulu, lalu buka panel sensitif setelah izin dan konteks repo sudah tervalidasi. Ini cocok untuk:

  • Hasil scan security.
  • Ringkasan AI berbasis isi repo.
  • Daftar collaborator internal.
  • Panel admin atau moderation.

3. Fail closed, bukan fail open

Jika terjadi timeout, error session, atau flag service gagal merespons, default aman adalah tidak menampilkan konten sensitif. Ini memang bisa membuat UX terasa lebih ketat, tetapi lebih baik daripada memunculkan konten lalu menariknya kembali.

4. Hindari flicker pada kontrol aksi

Tombol seperti Delete, Promote, Export, atau View secrets sebaiknya tidak muncul sampai izin final tersedia. Jika perlu, tampilkan placeholder tombol disabled dengan label generik, bukan tombol aktif yang kemudian hilang.

Isolasi state per request dan kebijakan cache

1. Pastikan SSR benar-benar per request untuk data pengguna

Semua state yang bergantung pada session, org, repo membership, atau permission harus dihitung dari request saat ini. Jangan menyimpannya dalam variabel modul global kecuali nilainya publik dan identik untuk semua pengguna.

2. Tinjau cache key secara eksplisit

Jika halaman SSR bergantung pada identitas pengguna, cache harus mempertimbangkan faktor tersebut, atau halaman tidak boleh di-cache bersama sama sekali.

Audit minimal mencakup:

  • Apakah response HTML divariasikan oleh cookie, auth header, atau tenant?
  • Apakah edge/CDN meng-cache halaman private?
  • Apakah data fetch di server memakai cache yang dibedakan per user/per org?
  • Apakah ada prefetch yang menggunakan key terlalu umum seperti hanya /repo/slug?

3. Waspadai bootstrap JSON

Banyak framework menyisipkan state awal ke dalam halaman. Walau UI tidak menampilkan field sensitif, field tersebut mungkin tetap ada di payload bootstrap. Audit bukan hanya DOM final, tetapi juga sumber HTML dan data serialisasi awal.

Checklist audit hydration SSR

  1. Petakan komponen sensitif: repo private, findings, path file, collaborator, policy result, hasil agent/AI, tombol admin.
  2. Catat semua input render awal: session, cookie, header, feature flag, tenant, locale, org, repo, eksperimen, dan permission.
  3. Periksa apakah input server dan klien identik pada render pertama.
  4. Ubah boolean akses menjadi state eksplisit: unknown/allowed/denied.
  5. Pastikan tidak ada singleton state server yang menyimpan data pengguna lintas request.
  6. Audit cache key untuk HTML, data loader, GraphQL response, dan hasil prefetch.
  7. Periksa fallback/loading agar tidak mengandung metadata sensitif.
  8. Pastikan komponen sensitif fail closed saat auth, flag, atau AI result belum final.
  9. Audit serialisasi state awal di HTML untuk field yang tidak perlu dikirim.
  10. Bandingkan HTML server vs render awal klien untuk skenario guest, user biasa, admin, dan member repo private.
  11. Uji perubahan cepat state: token expired, switch org, switch repo, flag toggle, dan membership dicabut.
  12. Verifikasi observability: log mismatch, log denial, dan identitas request untuk investigasi.

Langkah debugging untuk mendeteksi mismatch lebih dini

1. Log state keputusan akses di server dan klien

Jangan hanya melog value akhir. Log sumber keputusan:

  • ID request atau trace ID.
  • Session ada atau tidak.
  • Repo target.
  • Org/tenant aktif.
  • Feature flag yang relevan.
  • Status access: unknown, allowed, denied.

Tujuannya adalah menemukan mengapa server dan klien berbeda, bukan sekadar melihat bahwa mereka berbeda.

2. Simpan snapshot HTML awal

Saat ada laporan mismatch, ambil:

  • HTML respons server mentah.
  • Payload data awal atau bootstrap state.
  • DOM setelah hydration.

Perbandingan ini sering langsung menunjukkan apakah kebocoran terjadi di SSR, di bootstrap JSON, atau setelah fetch klien.

3. Simulasikan latensi pada auth dan permission

Banyak bug baru terlihat ketika auth atau flag service lambat. Tambahkan delay terkontrol pada endpoint /me, service permission, atau resolver feature flag untuk memaksa kondisi race.

4. Jalankan aplikasi tanpa cache lalu dengan cache

Jika mismatch hanya muncul pada environment tertentu, kemungkinan besar masalahnya ada pada cache HTML, cache data, atau state bersama di server.

5. Periksa warning hydration sebagai sinyal, bukan gangguan

Jangan menekan warning tanpa investigasi. Pada komponen sensitif, warning hydration bisa menjadi indikator bahwa keputusan akses tidak deterministik.

Strategi pengujian yang relevan

1. Snapshot SSR per role

Buat pengujian yang menghasilkan HTML SSR untuk beberapa profil:

  • guest
  • member repo
  • admin org
  • user tanpa akses

Lalu pastikan string sensitif tidak muncul pada profil yang salah.

2. E2E test untuk “no flash of sensitive content”

Pengujian E2E sebaiknya tidak hanya memeriksa state akhir. Tambahkan assertion pada fase awal navigasi untuk memastikan konten sensitif tidak pernah muncul sesaat.

test('non-member tidak pernah melihat findings private', async ({ page }) => {
  await page.goto('/repo/acme/private-repo');

  await expect(page.locator('text=Critical findings')).toHaveCount(0);
  await expect(page.locator('text=private-repo')).toHaveCount(0);
  await expect(page.locator('text=Bagian ini tidak tersedia')).toBeVisible();
});

Jika framework atau runner mendukung tracing/video, gunakan untuk menangkap flicker singkat.

3. Test race condition

Uji skenario:

  • token habis masa berlaku tepat sebelum navigasi
  • user keluar dari org di tengah sesi
  • feature flag berubah setelah HTML dirender
  • hasil agent/AI tersedia di server tetapi fetch klien gagal otorisasi

Tujuannya memastikan fallback tetap aman.

4. Contract test untuk loader data

Jika ada layer data server, buat pengujian yang menjamin loader tidak pernah mengembalikan field sensitif ketika akses belum sah. Ini penting karena sering kali UI sudah aman, tetapi API internal loader masih terlalu permisif.

Pola keputusan untuk memilih pendekatan render

Pilih render langsung di SSR jika:

  • Keputusan akses sudah final di server.
  • Data tidak sensitif atau aman ditampilkan untuk role tersebut.
  • Cache dapat dipisahkan dengan benar atau tidak dipakai untuk halaman private.

Pilih shell + gate sensitif jika:

  • Permission berasal dari beberapa sistem dan rawan terlambat.
  • Feature flag atau hasil agent/AI belum stabil pada render awal.
  • Anda ingin meminimalkan peluang flash of sensitive content.

Pilih render setelah verifikasi klien hanya untuk bagian non-sensitif jika:

  • Elemen hanya kosmetik atau personalisasi ringan.
  • Tidak ada data rahasia di HTML awal.
  • Mismatch tidak akan mengubah keputusan akses atau visibilitas konten penting.

Kesalahan yang sering terjadi

  • Menggunakan typeof window !== 'undefined' untuk memecahkan mismatch, padahal akar masalahnya state akses yang tidak sinkron.
  • Menyembunyikan konten dengan CSS atau efek setelah mount, sementara HTML sensitif sudah terkirim.
  • Mengirim seluruh objek repo ke klien, padahal UI hanya perlu id dan name.
  • Menganggap skeleton selalu aman, padahal teks placeholder bisa membocorkan metadata.
  • Mencampur data publik dan private dalam cache key yang sama.
  • Mengandalkan hasil agent/AI yang tidak deterministik untuk render awal SSR.

Penutup

Audit hydration SSR saat UI berbasis repo dan izin berubah pada dasarnya adalah audit kesepakatan state awal antara server dan klien. Jika keputusan akses, feature flag, cache, atau hasil agent/AI tidak deterministik, maka mismatch bukan sekadar warning, melainkan potensi UI yang menyesatkan atau membocorkan informasi.

Pendekatan yang paling aman adalah merender konten sensitif hanya setelah akses final dipastikan, memakai skeleton netral untuk state unknown, mengisolasi state per request, dan mengaudit cache serta bootstrap data dengan ketat. Tambahkan pengujian yang secara eksplisit mencari flash of sensitive content, bukan hanya memeriksa tampilan akhir. Dengan begitu, aplikasi SSR Anda tidak hanya bebas warning hydration, tetapi juga lebih konsisten dan aman untuk data berbasis repo dan izin yang dinamis.