Securely Passing Flags to the Browser

This guide is part of the Frontend Integration & Client-Side Rendering series. Serving flag state to a browser is fundamentally different from evaluating flags on the server: the client is an untrusted environment, so every byte you send it is potentially readable by any script on the page — including third-party code. The goal is to send the minimum required information (resolved variants only), deliver it as early as possible to prevent UI flicker, and protect the payload against tampering in transit.

Secure flag delivery boundary The server evaluates flags using full targeting context, strips PII and rules, signs the minimal payload, and embeds it in the HTML before sending it to the browser. Server (trusted zone) Flag evaluation full context + targeting rules Strip & sign variants only · no rules · no PII Signed payload Browser (untrusted zone) Bootstrap script (nonce) window.__FLAGS__ = {…} Client SDK init reads variants · no network call
The server evaluates flags with full context, strips targeting rules and PII, signs the minimal payload, and inlines it so the browser SDK boots without a round trip.

What This Guide Covers — and What It Does Not

This guide focuses on the delivery boundary: how to construct a minimal signed payload on the server, embed it safely in your HTML, and hand it off to the client SDK initialization layer. It does not cover how to evaluate flags on the server (see SSR flag consistency for that), or CDN-level delivery strategies.

Prerequisites

Core Architecture: Server Evaluates, Browser Consumes

The guiding principle is that the browser never receives targeting rules, user segments, or the evaluation context that produced the variants. It receives only a flat map of flagKey → variant, scoped to the current request. The server has already done the work:

Request → Server evaluates with full context → strips to {flagKey: variant, …} → signs → embeds in HTML → browser reads only variants

This separation means that even if an attacker can read the inline payload (which they can — it is in the HTML), they learn nothing useful: no rules to reverse-engineer, no user attributes to extract.

Step-by-Step Implementation

Step 1 — Evaluate flags server-side and build the minimal payload

Evaluate every flag the page needs in one pass. Collect only the resolved variant strings — do not include the evaluation context, targeting rules, or internal metadata.

// server/flagBootstrap.ts
import { OpenFeature } from '@openfeature/server-sdk';

interface BootstrapPayload {
  v: 1;
  ts: number;
  flags: Record<string, string | boolean>;
}

export async function buildFlagBootstrap(
  userId: string,          // already anonymized / not included in output
  tenantTier: string,
  flagKeys: string[],
): Promise<BootstrapPayload> {
  const client = OpenFeature.getClient('web.checkout');
  const ctx = { targetingKey: userId, tenantTier };

  const flags: Record<string, string | boolean> = {};
  for (const key of flagKeys) {
    // Resolve; fall back to false on any error
    flags[key] = await client.getBooleanValue(key, false, ctx);
  }

  return { v: 1, ts: Date.now(), flags };
  // NOTE: userId and tenantTier stay server-side — never in the payload
}

The flagKeys list should be the exhaustive set of flags the page uses, determined at build time. Avoid dynamic key lists that force re-evaluation per request.

Step 2 — Sign the payload with a server-held key

Attaching an HMAC signature lets the client SDK (or a service worker) detect tampering before the values are trusted. The signing key never leaves the server.

// server/flagSigning.ts
import { createHmac } from 'node:crypto';

const SIGNING_KEY = process.env.FLAG_SIGNING_KEY!; // 32-byte secret, server-side only

export function signPayload(payload: object): { data: string; sig: string } {
  const data = JSON.stringify(payload);
  const sig = createHmac('sha256', SIGNING_KEY)
    .update(data)
    .digest('base64url');
  return { data, sig };
}

// In the request handler:
const bootstrap = await buildFlagBootstrap(userId, tenantTier, PAGE_FLAGS);
const { data, sig } = signPayload(bootstrap);
// Embed both in the HTML — see Step 3

Callout — vendor note: Some providers (LaunchDarkly, Statsig) generate a bootstrap payload server-side via their SDK; the structure differs but the principle is the same: sign it before embedding it.

Step 3 — Embed the payload as an inline script with a CSP nonce

Use an inline <script> tag so the values are available synchronously, before any JavaScript module loads. Attach the per-request nonce so the script passes strict CSP without unsafe-inline.

// server/renderHtml.ts
export function injectFlagBootstrap(
  html: string,
  data: string,
  sig: string,
  nonce: string,
): string {
  const snippet = `<script nonce="${nonce}" id="__flag_bootstrap__" type="application/json" data-sig="${sig}">${data}</script>`;
  // Inject immediately after <head> so it runs before any module script
  return html.replace('<head>', `<head>\n${snippet}`);
}

Using type="application/json" means the script tag is not executed — it is just a data container. The client SDK reads it via document.getElementById. This removes the CSP requirement for the bootstrap element itself (only connect-src matters for the live endpoint).

Pitfall: If you use type="text/javascript" for the bootstrap, you need script-src 'nonce-…' in your CSP header. Using type="application/json" avoids that entirely while keeping the data synchronously available.

Step 4 — Initialize the client SDK from the embedded payload

On the client, read the embedded JSON before the SDK makes any network call. This gives the SDK valid initial state with zero latency.

// client/flagInit.ts
import { OpenFeature } from '@openfeature/web-sdk';
import { InMemoryProvider } from '@openfeature/web-sdk';

export function initFromBootstrap(): void {
  const el = document.getElementById('__flag_bootstrap__');
  if (!el) {
    console.warn('web.checkout.flag-bootstrap: no bootstrap element found');
    OpenFeature.setProvider(new InMemoryProvider({}));
    return;
  }

  const payload = JSON.parse(el.textContent ?? '{}');
  // Optionally verify sig client-side with the public half of an asymmetric key
  // For HMAC: verification must be server-side or via a trusted service worker

  // Convert flat variant map to InMemoryProvider flag definitions
  const flags = Object.fromEntries(
    Object.entries(payload.flags as Record<string, boolean>).map(
      ([key, val]) => [key, { defaultVariant: val ? 'on' : 'off', variants: { on: true, off: false }, disabled: false }]
    )
  );

  OpenFeature.setProvider(new InMemoryProvider(flags));
}

Call initFromBootstrap() at the very top of your entry bundle — before any component that reads a flag renders.

Step 5 — Scope the payload to the authenticated user

The bootstrap payload should reflect the flags for the current user, not a generic anonymous set. Regenerate it on login state changes and invalidate any cached version when the session changes.

// server/sessionAwareBootstrap.ts
export async function bootstrapForSession(
  sessionToken: string,
  flagKeys: string[],
): Promise<{ data: string; sig: string; maxAge: number }> {
  const { userId, tenantTier } = await resolveSession(sessionToken);
  const payload = await buildFlagBootstrap(userId, tenantTier, flagKeys);
  const signed = signPayload(payload);
  // Cache for the session lifetime, but no longer than 5 minutes
  return { ...signed, maxAge: Math.min(sessionTTL(sessionToken), 300) };
}

Set a Cache-Control: private, max-age=N header on the HTML response — never public — so CDN layers do not serve one user’s flag state to another.

Verification & Testing

Confirm the bootstrap is present and correct before the first JavaScript runs:

# Render the page and extract the bootstrap element
curl -s https://your-app.example/dashboard \
  | grep -o 'id="__flag_bootstrap__"[^>]*>[^<]*' \
  | head -1

# Confirm no targeting keys, user IDs, or rule objects appear in the payload
curl -s https://your-app.example/dashboard \
  | python3 -c "import sys,json,re; h=sys.stdin.read(); m=re.search(r'flag_bootstrap__[^>]*>(\{[^<]+\})', h); d=json.loads(m.group(1)); print(list(d.get('flags',{}).keys())[:5])"

Also verify in the browser: open DevTools → Elements, find #__flag_bootstrap__, and confirm the content contains only {v, ts, flags} with string/boolean values and no nested rule objects.

Troubleshooting & FAQ

Why should I embed flags in the HTML rather than fetching them with JavaScript?

A separate fetch creates a waterfall: the page loads, JavaScript parses, the fetch fires, then the SDK initializes. That gap causes UI flicker — the component renders with the default variant before snapping to the real one. Inlining eliminates the round trip entirely.

What if the page is cached by a CDN?

Mark the response Cache-Control: private or use a cache key that includes the session token. If you need CDN caching, evaluate flags at the edge instead — see the edge flag delivery guide. The inline approach is best suited for authenticated, uncacheable pages.

How do I handle a missing or corrupted bootstrap payload?

Fall back to the InMemoryProvider with all-false defaults and then trigger a background fetch against the flag endpoint. Log the bootstrap failure so you can alert on it — a missing bootstrap on every request usually means a regression in the server render path.

Should the payload include flag metadata like descriptions?

No. Send only flagKey → variant. Metadata is useful in development tooling, not in production HTML responses. Every extra byte is a potential information leak and adds to TTFB.