How to Secure React Hydration
React hydration bridges the gap between server-rendered HTML and client-side interactivity, but it introduces unique security risks like Cross-Site Scripting (XSS) and data tampering. This article explores how to secure the React hydration process by safely escaping serialized state, preventing hydration mismatches, and implementing robust Content Security Policies (CSP).
1. Safely Serialize the Server-Side State
The most common security vulnerability in React hydration occurs when
passing the initial state from the server to the client. This is
typically done by embedding a JSON payload inside a
<script> tag on the server-rendered page:
<!-- DANGEROUS: Vulnerable to XSS -->
<script>
window.__PRELOADED_STATE__ = ${JSON.stringify(state)};
</script>If the state object contains user-generated input with
closing script tags (e.g.,
</script><script>alert('XSS')</script>),
the browser will execute the malicious script.
The Solution: Escape the JSON Payload
To prevent this, you must escape HTML entities within the JSON
string. Do not use standard JSON.stringify() alone.
Instead, use a specialized serialization library like
serialize-javascript or htmlescape.
import serialize from 'serialize-javascript';
// SAFE: Characters like <, >, and / are properly escaped
const safeState = serialize(state, { isJSON: true });
const html = `
<script>
window.__PRELOADED_STATE__ = ${safeState};
</script>
`;2. Eliminate Hydration Mismatches
A hydration mismatch occurs when the HTML generated by the server does not match the HTML rendered by the client on the first render. While React attempts to recover from these mismatches, they can cause visual artifacts, layout shifts, and in severe cases, security vulnerabilities like clickjacking or structural DOM manipulation.
Common Causes of Mismatches
- Environmental Data: Using
window,localStorage, or browser-specific APIs (like current time or geolocation) during the initial render. - Timezones and Dates: Rendering dates on the server using a different timezone than the client.
The Solution: Defer Client-Only Rendering
Ensure that the initial client-side render matches the server-side
render exactly. Use the useEffect hook to trigger
client-specific updates after the initial hydration has
completed.
import { useState, useEffect } from 'react';
function DateComponent() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
// Server and client match on the first render (null)
if (!isMounted) {
return <div>Loading...</div>;
}
// Client-only code executes safely after hydration
return <div>{new Date().toLocaleTimeString()}</div>;
}3. Implement a Strict Content Security Policy (CSP)
A Content Security Policy (CSP) is a crucial layer of defense that restricts the resources (such as JavaScript) that the browser is allowed to load and execute.
Since React hydration often relies on inline
<script> tags to pass the initial state, you must
configure your CSP to allow these specific scripts while blocking
unauthorized inline scripts.
Use a Nonce (Number Used Once)
Generate a unique, cryptographically strong random value (nonce) on the server for every request. Apply this nonce to both your HTTP header and the inline script tag containing the hydrated state.
HTTP Header:
Content-Security-Policy: script-src 'nonce-rAnd0m123' 'strict-dynamic' https:;
HTML Template:
<script nonce="rAnd0m123">
window.__PRELOADED_STATE__ = ${safeState};
</script>Any inline script that does not possess the matching nonce will be blocked by the browser, rendering injected script attacks harmless.
4. Sanitize Input on Both Server and Client
Never assume that data is safe because it has passed through a
database. If your React application renders user-submitted HTML (for
example, using dangerouslySetInnerHTML), you must sanitize
it on the server before rendering, and re-verify it on the client.
Use a library like dompurify to clean the HTML
input:
import DOMPurify from 'isomorphic-dompurify';
function SafeHtmlComponent({ userContent }) {
const cleanHtml = DOMPurify.sanitize(userContent);
return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
}