| |

React 114 ⚛️ Taint APIs (experimental_taintUniqueValue / experimental_taintObjectReference)

⏱️ Estimated read: 14 min | |

The Server-Client boundary in React Server Components is one-directional by design: the server sends serialized data to the client, and the client cannot reach back into the server’s memory. But the boundary is also a leak surface. A Server Component that fetches a user record and passes it to a Client Component sends every field of that record across the wire, including the ones that should never leave the server. The taint APIs are React’s defense against exactly this mistake: they mark objects and values as “do not serialize,” and React throws an error if a tainted item is about to cross the boundary .

This chapter covers the two experimental taint APIs, the difference between tainting an object reference and tainting a unique value, and the limitations that make tainting a safety net rather than a security boundary. It assumes the Next.js configuration flag that enables the APIs, the "use server" / "use client" split from the previous chapter, and the serialization rules of the Flight protocol. The taint APIs do not replace authorization or data modeling; they catch the refactoring mistake that slips past both.


Key point: Tainting does not prevent the data from existing on the server. It prevents the exact object or value from being serialized and sent to the client. A copy of the tainted data is not tainted, and a derived value is not tainted. The protection covers the reference, not the information.


Why the taint APIs exist

The accidental serialization problem. A Server Component fetches a user object from the database and passes it to a Client Component for display. The client needs the user’s name and avatar URL, but the object also contains the user’s email, hashed password, and internal flags. React serializes the entire object because the Server Component passed the entire object. The client receives everything, and the developer may not notice until a security review or a leak .

The refactoring hazard. Even a well-designed data layer can be defeated by a refactor. A function that returned a minimal DTO gets changed to return the full database row, and a Client Component that previously received only safe fields now receives the whole record. The taint APIs exist to catch this at the boundary: the data layer taints the sensitive object, and any attempt to pass it through throws an error with a developer-written message .

The two granularities. Some leaks are about an object: the entire user record, the process.env object, a configuration file. Others are about a single value: an API key, a session token, a password hash. experimental_taintObjectReference handles the first; experimental_taintUniqueValue handles the second . The distinction matters because the protections and the failure modes differ.

The experimental status. Both APIs are available only in experimental versions of React and only inside React Server Components . Next.js exposes them through the experimental.taint configuration flag, which also enables the React experimental channel for the app directory . They are not stable APIs, and the documentation warns against relying on them as the only mechanism for preventing sensitive data exposure .


a. experimental_taintObjectReference

experimental_taintObjectReference(message, object) registers a specific object instance as forbidden from crossing to a Client Component. The message is the error text shown if the object is passed. The object is the reference to taint .

import { experimental_taintObjectReference } from 'react';

async function getUserDetails(id: string) {
  const user = await db.queryUserById(id);

  // Taint the entire user object.
  // Passing it to a Client Component throws with this message.
  experimental_taintObjectReference(
    'Do not pass the entire user object. Select only the fields you need.',
    user
  );

  return user;
}

The tainted object can still be used inside the Server Component. Individual fields can be read and passed to the client, because those fields are primitives or new objects, not the tainted reference .

export async function ContactPage({ params }) {
  const user = await getUserDetails(params.id);

  // This works: reading fields and passing primitives.
  return (
    <UserCard
      firstName={user.firstName}
      lastName={user.lastName}
    />
  );
}

The taint fires only when the tainted reference itself is passed to a Client Component. This is the intended use case: the data layer protects the whole object, and the rendering layer selects the safe fields.

The caveats are significant. Cloning the object with {...user} creates a new, untainted object. Constructing a new object with { name: user.name } creates an untainted object. The taint tracks the reference, not the information . If the client needs a subset of fields, the Server Component must build a new object from those fields, and that new object is not tainted. The protection is against passing the original object through, not against extracting its contents.


b. experimental_taintUniqueValue

experimental_taintUniqueValue(message, lifetime, value) registers a specific value—a string, bigint, or TypedArray—as forbidden from crossing to a Client Component. The lifetime argument is an object that determines how long the taint lasts: the value remains tainted while the lifetime object is reachable .

import { experimental_taintUniqueValue } from 'react';

function getSystemConfig() {
  const config = configLoader.load();

  // Taint the API key. The config object is the lifetime:
  // the key stays tainted as long as config is reachable.
  experimental_taintUniqueValue(
    'Do not pass configuration tokens to the client.',
    config,
    config.SERVICE_API_KEY
  );

  return config;
}

The lifetime choice is the critical decision. Passing globalThis or process keeps the value tainted for the entire lifetime of the application . Passing the encapsulating object ties the taint to that object’s lifetime: if the config is garbage collected, the taint ends. For a server process that holds configuration for its entire runtime, globalThis is the appropriate choice.

The taint follows the value through variables. Assigning config.SERVICE_API_KEY to a new variable does not remove the taint; passing that variable to a Client Component still throws . But deriving a new value does remove it. config.SERVICE_API_KEY.toUpperCase(), `prefix-${config.SERVICE_API_KEY}`, and Buffer.from(config.SERVICE_API_KEY).toString('base64') all produce untainted values . The taint is on the exact sequence of characters, not on the information content.

The documentation includes a warning about low-entropy values. Tainting a PIN code or a phone number is ineffective because an attacker who controls a request value can enumerate the possibilities and infer which value is tainted . The API is for high-entropy secrets: cryptographic tokens, private keys, long passwords.


c. The security posture

The taint APIs are a defense-in-depth layer, not a security boundary. The Next.js documentation states this explicitly: do not rely on taint as the only mechanism for preventing sensitive data exposure . The React documentation repeats the warning: tainting protects against simple mistakes, not against every possible derived value .

The correct mental model is three layers. The first layer is data modeling: the data-fetching function returns only the fields the client needs. The second layer is authorization: the Server Function or Server Component verifies that the caller is permitted to see the data. The third layer is tainting: if a mistake in the first two layers slips through, the taint throws before the data crosses the wire. Tainting catches the refactoring error that the first two layers were supposed to prevent .

The taint APIs also do not replace the server-only package. server-only prevents a module from being imported into a Client Component at build time; tainting prevents an object or value from being serialized at runtime. They address different failure modes: one is an import graph problem, the other is a data flow problem. Both are useful, and neither is sufficient alone.


Complete Example Session

// ============================================
// PART 1: THE NEXT.JS CONFIGURATION
// ============================================
// The taint APIs require the experimental flag.

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    taint: true,
  },
};

export default nextConfig;

// ============================================
// PART 2: THE DATA LAYER WITH TAINTED OBJECT
// ============================================
// The user object contains sensitive fields.
// Taint the reference before returning it.

// lib/user.ts
import { experimental_taintObjectReference } from 'react';

export async function getUser(id: string) {
  const user = await db.users.findUnique({ where: { id } });

  experimental_taintObjectReference(
    'Do not pass the entire user object to the client. ' +
    'Select only the fields you need.',
    user
  );

  return user;
}

// ============================================
// PART 3: THE SAFE SERVER COMPONENT
// ============================================
// Reading fields is fine. Passing primitives is fine.

// app/profile/page.tsx
import { getUser } from '@/lib/user';

export default async function ProfilePage({ params }) {
  const user = await getUser(params.id);

  return (
    <ProfileCard
      name={user.name}        // string, not tainted
      avatar={user.avatarUrl} // string, not tainted
    />
  );
}

// ============================================
// PART 4: THE MISTAKE
// ============================================
// Passing the entire object throws at render time.

// This line:
// return <ProfileCard user={user} />;
//
// Throws: Do not pass the entire user object to the client.
// Select only the fields you need.

// ============================================
// PART 5: THE TAINTED UNIQUE VALUE
// ============================================
// Configuration contains an API key.

// lib/config.ts
import { experimental_taintUniqueValue } from 'react';

export function loadConfig() {
  const config = {
    API_VERSION: 'v2',
    SERVICE_API_KEY: process.env.SERVICE_API_KEY!,
  };

  experimental_taintUniqueValue(
    'Do not pass the service API key to the client.',
    globalThis,  // taint for the lifetime of the app
    config.SERVICE_API_KEY
  );

  return config;
}

// ============================================
// PART 6: THE SAFE PASS
// ============================================
// Passing a non-secret field is fine.

// app/dashboard/page.tsx
import { loadConfig } from '@/lib/config';

export default async function Dashboard() {
  const config = loadConfig();
  return <ClientDashboard version={config.API_VERSION} />;
}

// ============================================
// PART 7: THE LEAK
// ============================================
// Passing the key, directly or via a variable, throws.

// This throws:
// return <ClientDashboard version={config.SERVICE_API_KEY} />;

// This also throws:
// const key = config.SERVICE_API_KEY;
// return <ClientDashboard version={key} />;

// ============================================
// PART 8: THE DERIVED VALUE HOLE
// ============================================
// A derived value is NOT tainted.

// This does NOT throw, even though it leaks the key:
// const masked = `key:${config.SERVICE_API_KEY}`;
// return <ClientDashboard version={masked} />;

// The taint tracks the exact string, not the information.

// ============================================
// PART 9: THE CLONE HOLE
// ============================================
// Cloning a tainted object creates an untainted object.

// This does NOT throw:
// const safeUser = { name: user.name, id: user.id };
// return <ProfileCard user={safeUser} />;
//
// The clone is a new object, and it is not tainted.
// This is fine if the clone contains only safe fields.
// It is a leak if the clone contains sensitive fields.

// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// Tainting is a layer. Model data, authorize access,
// and taint as a backstop.

export default function App() {
  return <ProfilePage params={{ id: '1' }} />;
}

The ten parts build the configuration, the data layer with a tainted object, the safe and unsafe render paths, the tainted unique value, and the two holes—derived values and clones—that the protection does not cover. The pattern is consistent: taint at the data layer, pass primitives to the client, and do not rely on the taint to catch derived or cloned data.


Quick Reference

API Signatures

APISignatureValue Type
experimental_taintObjectReference(message, object)Any object
experimental_taintUniqueValue(message, lifetime, value)string, bigint, TypedArray

Lifetime Choices

LifetimeDurationUse Case
globalThisEntire appConfig, secrets
processEntire processEnvironment secrets
Encapsulating objectObject’s lifetimePer-request secrets

What Tainting Covers

ScenarioTainted?
Passing the original objectYes, throws
Passing the original valueYes, throws
Passing a field from a tainted objectNo
Passing a clone of a tainted objectNo
Passing a derived valueNo
Passing the value via a variableYes, throws

Next.js Configuration

SettingLocationEffect
experimental.taint: truenext.config.tsEnables both APIs

Best Practices

✅ Do This:

// Taint at the data layer, before returning.
experimental_taintObjectReference('...', user);                      // ✅

// Use globalThis for app-lifetime secrets.
experimental_taintUniqueValue('...', globalThis, key);               // ✅

// Pass primitives to Client Components.
<UserCard name={user.name} />                                        // ✅

// Combine taint with data modeling and authorization.
// Taint is the third layer, not the first.                          // ✅

// Write a message that tells the developer what to do.
'Select only the fields you need.'                                   // ✅

❌ Don’t Do This:

// Don't rely on taint alone for security.
// It does not block derived values or clones.                       // ❌

// Don't taint low-entropy values like PINs.
experimental_taintUniqueValue('...', globalThis, pin);               // ❌

// Don't assume a clone is tainted.
const clone = { ...taintedUser };                                    // ❌

// Don't assume a derived string is tainted.
const derived = `${taintedValue}-suffix`;                            // ❌

// Don't use taint in production without the experimental flag.
// The APIs are unavailable otherwise.                               // ❌

Common Pitfalls

PitfallWhy It HappensFix
Taint has no effectexperimental.taint not enabledAdd the flag to next.config.ts
Derived value leaksString operations create new valuesTaint the derived value or avoid deriving
Clone leaksSpread creates a new objectTaint the clone or pass primitives
Taint ends too earlyLifetime object garbage collectedUse globalThis for long-lived secrets
Low-entropy taint is ineffectiveAttacker can enumerateDo not taint PINs or phone numbers
Production instabilityExperimental APIPin React version or use Canary

Real-World Examples

1. Taint User Object

experimental_taintObjectReference('Do not pass user.', user);

2. Taint API Key

experimental_taintUniqueValue('No API keys.', globalThis, key);

3. Safe Field Pass

<UserCard name={user.name} />

4. Taint at Data Layer

async function getUser(id) { /* taint */ return user; }

5. Taint with Object Lifetime

experimental_taintUniqueValue('...', user, user.token);

6. Enable in Next.js

experimental: { taint: true }

7. Taint Environment

experimental_taintObjectReference('...', process.env);

8. Taint Session Token

experimental_taintUniqueValue('...', session, session.token);

9. Derived Value Hole

const masked = `***${key}`; // NOT tainted

10. Clone Hole

const safe = { name: user.name }; // NOT tainted

Visual

The Two Taint APIs

┌─────────────────────────────────────────────────────────┐
│  taintObjectReference(message, object)                  │
│                                                         │
│  ┌───────────────────────────────────────────────────┐  │
│  │  object: user record, config object               │  │
│  │  Protection: the exact reference                  │  │
│  │  Passing the object → ERROR                       │  │
│  │  Passing a field → OK                             │  │
│  │  Passing a clone → OK (untainted)                 │  │
│  └───────────────────────────────────────────────────┘  │
│                                                         │
│  ─────────────────────────────────────────────────────  │
│                                                         │
│  taintUniqueValue(message, lifetime, value)             │
│                                                         │
│  ┌───────────────────────────────────────────────────┐  │
│  │  value: string, bigint, TypedArray                │  │
│  │  Protection: the exact sequence                   │  │
│  │  Passing the value → ERROR                        │  │
│  │  Passing via a variable → ERROR                   │  │
│  │  Passing a derived value → OK (untainted)         │  │
│  └───────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘

The Three-Layer Defense

┌─────────────────────────────────────────────────────────┐
│  LAYER 1: DATA MODELING                                 │
│  The data function returns only safe fields.            │
│  Best defense: the client never receives secrets.       │
│                                                         │
│  LAYER 2: AUTHORIZATION                                 │
│  The Server Component verifies the caller.              │
│  The client cannot request data it should not see.      │
│                                                         │
│  LAYER 3: TAINTING                                      │
│  The data layer marks sensitive references.             │
│  A refactoring mistake throws at the boundary.          │
│                                                         │
│  Taint is the backstop. It catches the mistake that     │
│  the first two layers were supposed to prevent.         │
└─────────────────────────────────────────────────────────┘

The Derived Value Hole

┌─────────────────────────────────────────────────────────┐
│  TAINTED VALUE: "secret-key-abc123"                     │
│                                                         │
│  Passing it directly → ERROR ✅                         │
│                                                         │
│  const key = value;                                     │
│  Passing key → ERROR ✅                                 │
│  (variable assignment keeps the taint)                  │
│                                                         │
│  const upper = value.toUpperCase();                     │
│  Passing upper → OK ❌                                  │
│  (string operation creates a new, untainted value)      │
│                                                         │
│  const concat = `prefix-${value}`;                      │
│  Passing concat → OK ❌                                 │
│  (concatenation creates a new, untainted value)         │
│                                                         │
│  The taint tracks the exact string, not the meaning.    │
└─────────────────────────────────────────────────────────┘

The Clone Hole

┌─────────────────────────────────────────────────────────┐
│  TAINTED OBJECT: user (reference at 0x1000)             │
│                                                         │
│  Passing user → ERROR ✅                                │
│                                                         │
│  const clone = { ...user };  (new object at 0x2000)     │
│  Passing clone → OK ❌                                  │
│                                                         │
│  const subset = { name: user.name };                    │
│  Passing subset → OK ❌                                 │
│                                                         │
│  The clone is a new object. The taint was on the        │
│  reference, not on the data it contained.               │
│                                                         │
│  If the clone contains sensitive fields, it leaks.      │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Object APIexperimental_taintObjectReference(message, object)
Value APIexperimental_taintUniqueValue(message, lifetime, value)
Next.js flagexperimental.taint: true
AvailabilityExperimental React, Server Components only
Object protectionExact reference
Value protectionExact string/bigint/TypedArray
Derived valuesNot protected
ClonesNot protected
Low-entropy valuesIneffective
Security roleDefense-in-depth layer, not a boundary

Key takeaways:

  • Tainting prevents the exact reference or value from crossing to the client. It does not prevent the information from existing or from being reconstructed through derivation or cloning .
  • taintObjectReference is for whole objects. A user record, a config object, process.env. The client can still receive individual fields extracted from the object.
  • taintUniqueValue is for secrets. API keys, session tokens, long passwords. The value must be high-entropy; low-entropy values like PINs are vulnerable to enumeration .
  • The lifetime argument controls duration. globalThis and process keep the taint for the application’s lifetime. An encapsulating object ties it to that object’s existence.
  • Derived values are not tainted. String operations, base64 encoding, and concatenation create new values that React does not recognize as sensitive .
  • Clones are not tainted. Spreading an object or constructing a new object from its fields creates an untainted reference .
  • Tainting is the third layer. Data modeling and authorization come first. Tainting catches the refactoring mistake that slips past both.
  • The APIs are experimental. They require the experimental React channel and the Next.js taint flag. Do not build production security solely on them .

Remember: The taint APIs are a compiler-assisted code review. They catch the developer who passes the entire user object to a Client Component because the object was already in scope. They do not catch the developer who clones the object, derives a string from a secret, or writes a new data-fetching function that returns the sensitive fields without tainting. The protection is precise and narrow: the exact reference, the exact value. That precision is what makes it useful as a backstop—it throws loudly on the mistake that is easiest to make—and what makes it insufficient as a security boundary. Model the data, authorize the access, and taint the sensitive references. The three layers together are stronger than any one alone.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!