experimental_taintUniqueValue

Block a specific high-entropy string or byte sequence from being passed to a Client Component.

Signature
taintUniqueValue(message, lifetime, value)

Parameters

ParameterTypeDescription
$messagestringThe error message displayed if value is passed to a Client Component.
$lifetimeobjectAny object indicating how long value should remain tainted. The value is blocked from being sent to Client Components while this object exists. Typically globalThis, process, or an object that contains the value.
$valuestring | bigint | TypedArrayA unique sequence of characters or bytes with high entropy — a cryptographic token, private key, hash or long password — that must be blocked from being sent to Client Components.

Return value

undefinedNothing. The value is registered as tainted for as long as the lifetime object exists; passing it to a Client Component afterwards throws.

Errors

Throws at render time, with the supplied message, when the tainted value is passed to a Client Component while the lifetime object is still reachable.

Changelog

  • PHP experimentalAvailable only in react@experimental; there is no stable release of this API.
  • PHP experimentalExported only from the react-server build; not available inside Client Components.

Where taintObjectReference blocks an object identity, this blocks a value: a specific token, key or hash. Once tainted, that exact string or byte sequence throws if it ever appears in a prop crossing into a Client Component — no matter which object carries it there.

When to reach for it

Taint secrets at the point they enter your process: the environment variable read at boot, a session token minted per request, a signing key loaded from a vault. Give it a lifetime that matches — globalThis or process for a process-wide key, the request or user object for something request-scoped, so the entry is collectable once that scope ends.

What trips people up

Tainting does not follow derivations. Uppercasing, concatenating, base64-encoding or substringing a tainted value yields an untainted one, so Bearer ${token} passes right through. Taint each derived form you also care about.

Never taint low-entropy values such as PINs, phone numbers or short ids: an attacker who can observe which values throw can enumerate the space and read the secret out of the guard itself. And like the object variant, this is one layer of defence, experimental, and available only in the server build.

Example

Example · jsx
// taint-secrets.js — imported once, at server startup
import { experimental_taintUniqueValue } from 'react';

experimental_taintUniqueValue(
  'Do not pass secret keys to the client.',
  process,
  process.env.API_KEY
);

// data/session.js
import { experimental_taintUniqueValue } from 'react';
import db from './db';

export async function getSession(request) {
  const session = await db.sessions.get(request.cookies.sid);
  experimental_taintUniqueValue(
    'Do not pass the session token to the client.',
    session, // tainted for as long as this session object is alive
    session.token
  );
  return session;
}

// Header.jsx
// 'use client';
// export default function Header({ userName }) {
//   return <header>Signed in as {userName}</header>;
// }
import Header from './Header';

// Page is a Server Component.
export default async function Page() {
  const session = await getSession(globalThis.currentRequest);

  // Throws: the tainted token, wherever it is nested.
  // return <Header userName={session.user.name} token={session.token} />;

  return <Header userName={session.user.name} />;
}

When to use it

  • Taint process.env.API_KEY at startup so no component can forward it as a prop.
  • Taint a per-request session token with the request object as its lifetime.
  • Taint a password hash pulled from the database before the record is handed to page code.

Frequently asked questions

Why is my API key still reaching the client after tainting it?

Because the value that crossed the boundary was derived from the tainted one rather than being it — string concatenation, base64 encoding, trimming and substring all produce untainted values. Call taintUniqueValue again on each derived form you want blocked.

What should I pass as the lifetime argument?

An object that lives exactly as long as the value should stay tainted: globalThis or process for a secret read at startup, and the request, session or record object for anything request-scoped. Using globalThis for per-request values keeps every token of every request in memory for the life of the server.

Can I taint a user ID or a PIN?

No — React warns against tainting low-entropy values, because an attacker can enumerate the small space and learn the secret from which values throw. Reserve this API for high-entropy tokens, keys, hashes and long passwords.

More examples

Tainting every form a secret takes

Without the two extra calls, passing authHeader as a prop serializes the key to the browser in plain sight: React sees a string it has never been told about. The example shows the maintenance cost of the API honestly — every representation of a secret needs its own call, which is why tainting at the single point where the secret is created is easier to keep correct than tainting at each use site.

Example · jsx
import { experimental_taintUniqueValue } from 'react';

const KEY = process.env.SIGNING_KEY;

// The raw key…
experimental_taintUniqueValue(
  'The signing key must never reach the client.',
  globalThis,
  KEY
);

// …and each derived form you also want blocked. Tainting does NOT
// propagate through string operations.
const authHeader = `Bearer ${KEY}`;
const keyBase64 = Buffer.from(KEY).toString('base64');

experimental_taintUniqueValue(
  'The Authorization header contains the signing key.',
  globalThis,
  authHeader
);
experimental_taintUniqueValue(
  'This is the signing key, base64 encoded.',
  globalThis,
  keyBase64
);

export { authHeader };

Choosing the right lifetime

The first two calls are the intended shapes: a long-lived key pinned to process, and a per-record hash pinned to the record so the taint registry does not grow without bound across requests. The commented third call is the documented anti-pattern — with only ten thousand possible PINs, the thrown-versus-rendered signal is itself an oracle, so the guard leaks the value it was meant to protect.

Example · jsx
import { experimental_taintUniqueValue } from 'react';

// Process-wide secret: lives as long as the server does.
experimental_taintUniqueValue(
  'Do not pass the database password to the client.',
  process,
  process.env.DB_PASSWORD
);

// Per-request secret: tie it to the object that owns it, so the taint
// entry can be collected when the request is done.
export async function loadUser(id) {
  const user = await db.users.get(id);

  experimental_taintUniqueValue(
    'Do not pass a user password hash to the client.',
    user, // collected with the user record
    user.passwordHash
  );

  return user;
}

// WRONG: low entropy. An attacker can guess four-digit values and learn
// which ones throw, reading the PIN out of the guard.
// experimental_taintUniqueValue('Do not leak the PIN.', user, user.pin);

Discussion

  • Be the first to comment on this lesson.