seekrit
Docs/React & Next.js

React & Next.js

A React app has two halves and they need opposite answers. On the server, a secret is ordinary configuration — read it, use it, done. On the client, there is no such thing as a secret: anything in the bundle is published, and a service token in the bundle is a credential you have handed to every visitor.

So this integration is server-side, and the only thing the browser ever holds is a {{seekrit:NAME}} placeholder.

npm install @seekrit/sdk
note

This guide assumes you've done the Quickstart and have a token in SEEKRIT_TOKEN. A token reads everything in its environment, so there is no per-secret setup.

1. Read a secret in a server component

// app/billing/page.tsx
import { secret } from "@seekrit/sdk/react";

export default async function BillingPage() {
  const key = await secret("STRIPE_KEY");
  const charges = await listCharges(key);

  return <ChargesTable rows={charges} />;
}

secret() fails closed: a name your token cannot see throws rather than resolving to undefined, because an undefined credential authenticates as nothing several frames from the cause. Use optionalSecret() when absence is a real case, and secrets() for the whole set.

Both are also available per slice of a composed group:

const key = await secret("STRIPE_KEY", { with: { tenants: "acme" } });

One resolve per render

The resolve is wrapped in React's cache, so a page with a dozen components each asking for a secret makes one /v1/resolve call, keyed by the group overrides asked for:

// three components, one round trip
await secret("STRIPE_KEY");
await secret("DATABASE_URL");
await secrets();

This holds inside a Server Component render, which is what installs React's cache dispatcher. In an SSR pass, a route handler, or plain Node there is no request to key on and cache falls through uncached — still correct, just not deduplicated. Caching across requests is a different feature: that is ttlSeconds on in-process injection.

2. Make a leak a build error

Reading a secret on the server is only half of it. The failure mode in an RSC app is passing one to a client component, where it is serialized into the payload and shipped:

const key = await secret("STRIPE_KEY");
return <PaymentForm apiKey={key} />;   // ← the leak

React has an API for exactly this, and this SDK calls it for you: every resolved value, and the map holding them, is passed to experimental_taintUniqueValue and experimental_taintObjectReference. With taint on, that line throws during render instead of shipping the key.

Turn it on in next.config:

// next.config.ts
export default {
  experimental: { taint: true },
};
caution

The taint API is absent from stable React builds, and when it is missing this SDK warns once and keeps working. If taint is why you are here, say so and get a hard failure instead:

import { createSecretReader } from "@seekrit/sdk/react";

export const { secret, secrets } = createSecretReader({ taint: "require" });

createSecretReader is also how you bind a different token, API URL, or taint setting once and export your own secret for the rest of the app.

3. Let the browser hold a placeholder

Some calls have to leave the browser — a streaming chat completion, an upload that should not transit your server twice. The answer is not to ship the key: it is to ship the placeholder and substitute it in a route handler on the way out.

// app/api/openai/[...path]/route.ts
import { seekritRoute } from "@seekrit/sdk/route";
import { auth } from "@/auth";

export const { GET, POST } = seekritRoute({
  upstream: "https://api.openai.com",
  allow: { "api.openai.com": ["OPENAI_API_KEY"] },
  authorize: async (request) => (await auth(request)) !== null,
});
// components/chat.tsx — "use client"
const openai = createOpenAI({
  baseURL: "/api/openai/v1",
  apiKey: "{{seekrit:OPENAI_API_KEY}}",
});

The bundle contains the string {{seekrit:OPENAI_API_KEY}} and nothing else. The handler rewrites /api/openai/v1/chat/completions onto https://api.openai.com/v1/chat/completions, substitutes the placeholder, and streams the response back.

This is the egress proxy shaped as a web RequestResponse handler — same syntax, same default-deny allowlist, same 403 — so it also runs on Remix, React Router, Hono, and Nitro, anywhere you are handed a Request.

authorize is required

There is no default, and that is the point. This handler is reachable from the internet, on your own origin, under your own cookies; without an authorization check it is an open credential proxy that anyone can drive. Pass the same check the rest of your app makes.

What the handler does for you

  • Gates every request, not only the ones carrying a placeholder. The in-process shim consults the allowlist only when it sees a placeholder, because it is a credential shim rather than an egress firewall. A handler on the public internet cannot afford that reading — otherwise an authorized user reaches any path or method on the upstream simply by leaving the placeholder out. So methods and paths are checked on the way in, every time.
  • Drops cookie and any authorization that is not a placeholder. The browser attaches your session to a same-origin request automatically, and forwarding it hands your session to a third party.
  • Strips set-cookie from the response, because this route is same-origin and an upstream's cookie would be set on your domain.
  • Refuses oversized bodies (1 MiB by default, maxBodyBytes to change it).

Narrowing per request

scope runs per request and can pick a tenant's slice, narrow the allowlist, or both. Narrowing only intersects — a scope can never widen what the static rules permit:

export const { POST } = seekritRoute({
  upstream: "https://api.openai.com",
  allow: { "api.openai.com": ["OPENAI_API_KEY", "STRIPE_SECRET_KEY"] },
  authorize: async (request) => (await auth(request)) !== null,
  scope: async (request) => {
    const session = await auth(request);
    return { with: { tenants: session.orgSlug }, allow: ["OPENAI_API_KEY"] };
  },
});

Where this sits

Three rungs, weakest boundary first:

ApproachThe key livesWeakness
@seekrit/sdk/reactin the render, on the serverapp code on the server can read it
@seekrit/sdk/routein your app's processreachable from the internet; authorize is the only gate
seekrit-proxyin a separate processnone of the above — but you run a process

Nothing here weakens the zero-knowledge model: the API still only ever sees ciphertext, and decryption still happens in your code with a key derived from your token. What changes is how narrow the blast radius is on your own side.

The client hook that does not exist

There is no useSecret(), and there will not be one. A hook that resolves in the browser needs a service token in the browser, which is a credential in a view-source. Both entrypoints here are server-only and refuse to load in a client bundle — first through the browser export condition, so a use client file importing one fails at build, and then at runtime for bundlers that ignore it.

If you want a client component to show whether a secret is configured without seeing it, read that on the server and pass the boolean.