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
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 },
};
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
Request → Response 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
methodsandpathsare checked on the way in, every time. - Drops
cookieand anyauthorizationthat 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-cookiefrom 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,
maxBodyBytesto 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:
| Approach | The key lives | Weakness |
|---|---|---|
@seekrit/sdk/react | in the render, on the server | app code on the server can read it |
@seekrit/sdk/route | in your app's process | reachable from the internet; authorize is the only gate |
seekrit-proxy | in a separate process | none 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.