Hydration mismatch terjadi saat client-side JavaScript (React, Vue, atau Svelte) mencoba mengambil alih markup HTML yang di-render server, tetapi menemukan ketidaksesuaian antara struktur DOM aktual dan Virtual DOM yang diinisialisasi di browser. Pada arsitektur hybrid yang menggabungkan Spring Boot (misalnya dengan Thymeleaf atau template engine lain) dan frontend framework modern, injeksi otomatis CSRF (Cross-Site Request Forgery) token sering memicu masalah ini.

Akar Masalah: Mutasi DOM Sebelum Hydration Berjalan

Secara default, saat form HTML diproses oleh engine template Spring atau tag library terkait, Spring Security secara implisit menyuntikkan elemen input tersembunyi ke dalam form:

<input type="hidden" name="_csrf" value="a1b2c3d4-..." />

Jika aplikasi frontend me-mount komponen client ke container root (seperti <div id="root">) yang sudah memuat input tersembunyi ini dari SSR, React/Vue akan membandingkan VDOM client awal dengan node DOM aktual. Karena komponen frontend di sisi client tidak mendefinisikan node <input type="hidden"> tersebut dalam render tree pertamanya, runtime mendeteksi perbedaan struktur (DOM mismatch). React biasanya melempar Error: Hydration failed because the initial UI does not match what was rendered on the server.

Masalah serupa muncul jika server menyuntikkan tag <meta> atau skrip inline di dalam kontainer yang ditargetkan untuk hydration.

Solusi 1: Pindahkan Token ke CookieCsrfTokenRepository

Pendekatan paling bersih adalah memisahkan siklus hidup token CSRF dari tree HTML. Konfigurasikan Spring Security agar menyimpan token dalam cookie yang dapat dibaca oleh JavaScript (non-HttpOnly). Frontend kemudian membaca cookie tersebut dan mengirimkannya via request header.

Konfigurasi SecurityFilterChain di Spring Boot 3.x

Pada Spring Security 6 (Spring Boot 3), resolusi token CSRF bersifat deferred secara default. Gunakan handler eksplisit agar cookie ditulis dengan benar:

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
        requestHandler.setCsrfRequestAttributeName(null);

        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(requestHandler)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/index.html", "/static/**").permitAll()
                .anyRequest().authenticated()
            );

        return http.build();
    }
}

Catatan: CookieCsrfTokenRepository.withHttpOnlyFalse() menyetel cookie XSRF-TOKEN dengan atribut HttpOnly = false, memungkinkan client membaca nilai token tanpa modifikasi markup.

Konsumsi Token di Frontend

Frontend membaca cookie dan menyertakannya ke header X-XSRF-TOKEN pada request mutasi (POST, PUT, DELETE). Metode ini tidak mengubah DOM SSR sama sekali.

function getCsrfToken() {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; XSRF-TOKEN=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
  return null;
}

// Integrasi pada native fetch
export async function secureFetch(url, options = {}) {
  const csrfToken = getCsrfToken();
  const headers = {
    ...options.headers,
    'Content-Type': 'application/json',
    ...(csrfToken && { 'X-XSRF-TOKEN': csrfToken }),
  };

  return fetch(url, { ...options, headers });
}

Solusi 2: Penempatan Token di Luar Root Container

Jika kebijakan keamanan melarang cookie non-HttpOnly, render token ke dalam dokumen HTML tetapi tempatkan secara ketat di luar elemen mounting client (di luar #root atau #app).

Struktur Markup SSR (Thymeleaf)

Letakkan token di dalam <head> atau tepat sebelum tag penutup </body> menggunakan container terisolasi:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <!-- Token di luar #root -->
    <meta name="_csrf" th:content="${_csrf.token}" />
    <meta name="_csrf_header" th:content="${_csrf.headerName}" />
    <title>App</title>
</head>
<body>
    <!-- Root mounting point murni tanpa modifikasi server -->
    <div id="root"><!-- Konten SSR atau placeholder kosong --></div>

    <script src="/bundle.js"></script>
</body>
</html>

Membaca Metadata di Frontend

Akses token melalui selektor DOM standar di dalam logic request, tanpa merender nilainya ke dalam tree React/Vue saat inisialisasi:

function getMetaCsrf() {
  const token = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
  const header = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
  return { token, header };
}

export function apiRequest(url, options = {}) {
  const { token, header } = getMetaCsrf();
  const headers = {
    ...options.headers,
    ...(token && header && { [header]: token }),
  };
  return fetch(url, { ...options, headers });
}

Perbandingan Pendekatan

  • CookieCsrfTokenRepository: Standar industri untuk Single Page Application (SPA). Otomatis didukung oleh client HTTP seperti Axios tanpa konfigurasi manual tambahan. DOM SSR tetap bersih.
  • Meta Tag di Head: Lebih ketat jika cookie dibatasi. Membutuhkan pembacaan manual dari DOM, tetapi menjamin root hydration tree tidak terkontaminasi elemen asing.

Checklist Debugging

  1. Periksa apakah ada form Thymeleaf (th:action) yang membungkus komponen di dalam target render client. Ganti dengan form handler berbasis JavaScript jika form berada di dalam kontrol React/Vue.
  2. Pastikan tidak ada whitespace atau script injection tambahan yang disisipkan oleh server filter tepat di dalam <div id="root">.
  3. Gunakan tab Elements pada Developer Tools dan bandingkan source response mentah (Ctrl+U) dengan DOM tree setelah eksekusi JavaScript untuk memverifikasi atribut atau node yang disuntikkan secara dinamis.