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.

The threat model here is not a determined attacker breaking your encryption — it is the ordinary reality of the browser. Any analytics tag, session-replay script, chat widget, or browser extension the user has installed runs in the same JavaScript context as your bootstrap and can read window and the DOM at will. That is why the design rule is subtractive: you do not try to hide the payload, you make it worthless to read. A flat map of checkout.web.express-lane → "on" tells an observer nothing they could not learn by clicking around the UI, whereas the targeting rule that produced it — tenantTier == "enterprise" AND country IN ["US","CA"] — is genuinely sensitive business logic you should never ship. Keep that distinction sharp and most of the hard decisions on this page answer themselves.

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.

In scope versus out of scope This guide covers building the minimal signed payload, embedding it, and handing it to the client SDK; it excludes server-side evaluation and CDN delivery, which are covered elsewhere. In scope Build minimal signed payload Embed safely in HTML Hand off to client SDK Covered elsewhere Server-side evaluation see SSR consistency CDN-level delivery see edge & CDN guide
The boundary this guide owns is delivery — evaluation happens upstream and caching happens downstream.

Prerequisites

Prerequisites for secure flag delivery A server SDK that evaluates before HTML generation, a per-request CSP nonce, a server-held signing key, and a web SDK ready to consume the bootstrap. Server SDK evaluates first Per-request nonce for CSP Signing key server-side only Web SDK consumes bootstrap
The signing key never leaves the server — that asymmetry is what lets the browser trust a payload it can also read.

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:

Server evaluates, browser consumes The server evaluates with full context, strips the result to a flat flag-to-variant map, signs it, and embeds it in HTML; the browser reads only variants and never sees the targeting rules or context. Server (full context) Browser Evaluate rules + context Strip to variants {key: variant} Sign + embed HMAC in HTML Reads only variants no rules, no context
Everything left of the divider stays on the server; the browser receives a flat, signed variant map and nothing more.

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.

The subtlety most teams miss is that a variant name can itself leak intent if you let it. A flag resolving to "beta-pricing-2026-q3" or "internal-employee-override" tells a curious reader about unreleased work and about privileged code paths that exist. Keep the values on the wire opaque — "on"/"off", or a short enum like "a"/"b"/"control" — and resolve their meaning in code the SDK already ships. The same discipline applies to the flag keys: a key such as billing.web.suppress-dunning-for-vip is a targeting rule spelled out in an identifier. If your naming convention encodes segment logic, alias the keys to neutral tokens before they cross the boundary and map them back in a lookup table that lives in your bundle, not in the payload.

There is also a second, quieter boundary at play: the payload is a snapshot, not a subscription. The variants baked into the HTML are correct as of the millisecond the server evaluated them, and they will drift as flags change, kill switches fire, or the user’s segment shifts mid-session. The bootstrap’s job is to get the first paint right with zero latency; keeping the client current afterward is the job of the live SDK connection, which reconciles against the streaming or polling channel once it comes up. Treat the bootstrap as trusted initial state and the live channel as the source of truth, and the two never fight.

Step-by-Step Implementation

The five steps carry one payload from server to browser: build it minimal, sign it, embed it under a nonce, consume it, and scope it to the user.

The five implementation steps Build the minimal payload, sign it with a server-held key, embed it as an inline script with a CSP nonce, initialize the client SDK from it, then scope the payload to the authenticated user. 1 Build minimal variants only 2 Sign HMAC / Ed25519 3 Embed + nonce inline script 4 Init client from payload 5 Scope to user no cross-cache
Signing (step 2) and user-scoping (step 5) are the two security-critical steps; the rest is plumbing.

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. A practical way to keep the list honest is to generate it from the same manifest your bundler uses — a route-to-flags map emitted during the build — so that a component reading a flag that is not in the bootstrap becomes a build-time error rather than a runtime flash-of-default. Over-fetching is a real cost here, not a rounding error: every flag you resolve adds to the evaluated payload size and to server render time, and a bootstrap that carries 300 flags because “the SDK had them anyway” is both slower to serialize and a broader surface to audit. Scope the list to what this route actually reads.

Notice the single fallback value in getBooleanValue(key, false, ctx). That third-argument default is not decoration — it is what runs when the provider is unreachable, the key is retired, or evaluation throws. Choosing false (or whatever your safe-by-default state is) means a degraded server still emits a valid, closed bootstrap rather than a partial object that breaks JSON parsing on the client. Never let an evaluation error propagate out of the loop; a single unresolved flag must not blank the whole page. If you resolve string or number variants alongside booleans, give each the same explicit, conservative default and keep the union type narrow so the client’s deserialization stays total.

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.

Two properties matter when you compare the signature the client sees against the one the server produced. First, use a constant-time comparison — Node’s crypto.timingSafeEqual — anywhere you verify server-side; a naive === on the signature string leaks byte-by-byte timing that, over enough requests, lets an attacker forge a valid tag. Second, decide deliberately between HMAC and Ed25519 based on who needs to verify. HMAC is symmetric: the same secret signs and checks, so verification can only happen somewhere you already trust with the key — the server, or a service worker you provision. Ed25519 is asymmetric: you sign with the private key server-side and can safely ship the public key to the browser, which lets client code detect tampering without holding anything secret. If your only goal is to stop a compromised CDN or a proxy from rewriting variants in flight, HMAC checked in the service worker is enough and cheaper; if you want the page’s own JavaScript to refuse a mutated payload, you need the asymmetric scheme.

Include the timestamp and version fields inside the signed bytes, not alongside them. The ts field lets a verifier reject a stale payload that was captured and replayed hours later, and the v field lets you rotate the payload schema without a verifier silently accepting an old shape. Because both are covered by the signature, an attacker cannot roll the timestamp forward or downgrade the version to slip past validation. Rotate the signing key on the same cadence as your other secrets and keep the previous key valid for one overlap window so in-flight pages signed with the old key still verify during the rollover.

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.

The one non-negotiable when you inline user-influenced data is escaping. Even though a type="application/json" block is not executed, the browser’s HTML parser still scans its text for the literal sequence </script, and the first one it finds closes the tag early — so a flag value or key that happens to contain that substring truncates your payload and dumps the remainder into the live DOM as markup. Serialize with a function that escapes < to < (and > and & for good measure) before it reaches the HTML, not with a bare JSON.stringify. The same rule defends against a <!-- or ]]> sequence confusing the parser. This is the single most common way a “just data” bootstrap turns into a stored-XSS vector, and it is entirely preventable at serialization time.

Placement inside <head> is deliberate for ordering, but be aware of what runs between the parser reaching the tag and your init code executing. Because the block is inert JSON, nothing runs when the parser hits it — the data simply becomes available to getElementById the moment the element is parsed. That is exactly what you want: by the time your entry module executes, the element is already in the DOM, so there is no race and no need for a DOMContentLoaded guard around the read. Keep the id stable and namespaced (__flag_bootstrap__) so a second team embedding their own bootstrap on a shared shell does not collide with yours.

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. In a framework with hydration, this ordering is what keeps the client’s first render byte-for-byte identical to the server’s: if the provider is populated before React (or your framework of choice) walks the tree, every useFlag call returns the same variant the server used, and hydration proceeds without the mismatch warning that signals a flash-of-default. Getting this wrong is subtle because it still “works” — the UI eventually settles on the right variant — but you pay for it with a visible flicker and, in strict-hydration frameworks, a full client re-render of the affected subtree.

Note what the client deliberately does not do with an HMAC signature. Because the browser cannot hold the symmetric secret, JavaScript here cannot meaningfully verify the payload — a signature check written in page code could be bypassed by the same attacker who could have rewritten the payload. Client-side verification only buys you something with an asymmetric key, where the public half is safe to ship; with HMAC, do the check in a service worker or upstream and let the page trust what it reads. Treat the payload.v version field as a compatibility gate too: if the embedded version is one your bundle does not recognize (an old page served from bfcache after a deploy, say), fall back to the live fetch rather than misinterpreting an unfamiliar shape.

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. The failure mode this prevents is the worst kind of flag bug: silent and cross-tenant. If an intermediary caches a page carrying user A’s billing.web.enterprise-dashboard → "on" and serves it to anonymous user B, you have not just shown the wrong UI, you have leaked the existence of a feature and possibly a paying customer’s entitlement. private alone is necessary but not always sufficient — some corporate proxies ignore it — so for genuinely sensitive pages pair it with a Vary header on the session cookie, or better, keep the flag-bearing HTML on an uncacheable route entirely and let static shells cache freely.

The five-minute ceiling in Math.min(sessionTTL(...), 300) is a deliberate compromise between two costs. Too long, and a kill switch you flip in the console can take the full window to reach users who already have a page open; too short, and you regenerate and re-sign the bootstrap on nearly every navigation, adding evaluation load for little benefit. Five minutes bounds the staleness of the initial state while the live channel handles anything more urgent — a flag you need to kill instantly should propagate over the streaming connection, not by waiting for the next bootstrap. Tie the cache key to the session token rather than the user ID so that a logout-then-login as a different user cannot be served the previous session’s variants from the browser’s own back-forward cache.

Verification & Testing

Three checks for a safe bootstrap Confirm the bootstrap element is present in raw HTML, confirm the payload contains only flag keys and variants with no user IDs or rule objects, and confirm the response is marked Cache-Control private. Present in HTML curl before any JS #__flag_bootstrap__ Only variants no user IDs no rule objects Cache-Control private, never public no cross-user reuse
The middle check is the security assertion — if a user ID or rule object appears in the payload, the delivery boundary has leaked.

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.

Make the leak check an automated assertion, not a one-time manual pass. The most durable version is a contract test that renders a representative page, extracts the bootstrap JSON, and fails the build if any value is an object, if any key matches a denylist of PII-shaped field names (email, userId, ip, phone), or if the serialized size exceeds a budget you set — a few kilobytes for a typical route. Regressions here are insidious precisely because the page keeps working: someone adds a richer flag object “for debugging,” the payload quietly starts carrying rule metadata, and nothing breaks until a security review or an incident finds it months later. A test that runs on every commit is what keeps the subtractive discipline from eroding.

It is also worth verifying the negative security properties directly. Capture a valid payload, flip one character in the signature or one variant value, replay it, and confirm your verifier rejects it rather than trusting the mutated data — an untested signature check is often a no-op that silently passes everything. Similarly, request the same page as two different sessions and diff the Cache-Control and Set-Cookie headers to prove the response is genuinely per-user and not being coalesced by a shared cache key.

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.

Can the browser verify an HMAC signature itself?

Not meaningfully. HMAC is symmetric, so verifying it in page JavaScript would require shipping the secret to the browser, at which point any script that can read the payload can also read the key and forge a valid signature. If you need the page’s own code to reject a tampered payload, sign with an asymmetric scheme like Ed25519 and ship only the public key. Otherwise, verify HMAC in a service worker or upstream proxy and let the page trust what it reads.

How do I stop a </script> inside a flag value from breaking the page?

Escape the serialized JSON before it enters the HTML. The parser scans even an inert type="application/json" block for the literal </script, and the first match closes the tag early, spilling the rest of your payload into the DOM as markup. Replace <, >, and & with their unicode escapes (< and friends) at serialization time rather than using a bare JSON.stringify. This is the most common way a data-only bootstrap becomes a stored-XSS vector, and it is fully preventable.

Should variant names be human-readable in the payload?

Keep them opaque. A value like "beta-pricing-2026-q3" or "internal-override" leaks unreleased work and privileged code paths to anyone who opens DevTools. Ship a short enum — "on"/"off" or "a"/"b"/"control" — and resolve the meaning in code your bundle already contains. Apply the same rule to keys whose names encode segment logic: alias them to neutral tokens before they cross the boundary.

How often should the bootstrap be regenerated, and what handles urgent flag changes?

Bound the initial state’s staleness with a short cache window — five minutes is a reasonable ceiling — so navigation does not re-sign the payload on every request while still limiting how long a stale variant can persist. The bootstrap is a snapshot for the first paint, not a subscription; anything you need to change instantly, like a kill switch, should propagate over the live streaming or polling connection the SDK opens after init, which reconciles against the source of truth.

Does the timestamp in the payload actually protect anything?

Yes, provided it is inside the signed bytes. A signed ts lets a verifier reject a payload that was captured and replayed later, and because it is covered by the signature an attacker cannot roll it forward to bypass the check. Pair it with the signed v version field so you can rotate the payload schema without a verifier silently accepting an outdated shape.