Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions docs/src/pages/en/(pages)/framework/server-function-encryption.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
title: Server function encryption
category: Framework
order: 3
---

import Link from "../../../../components/Link.jsx";

# Server function encryption

`@lazarv/react-server` encrypts all server function identifiers by default using **AES-256-GCM** encryption. This prevents clients from discovering or calling server functions that were not explicitly exposed during rendering.

Without encryption, server function identifiers are plain-text strings that reveal your source file paths and function names (e.g. `src/actions#deleteUser`). Any client could craft a request to invoke any server function in your application, even functions that were never rendered for that user. Encryption ensures that only server functions returned by the server during rendering are callable.

<Link name="how-it-works">
## How it works
</Link>

There are two types of server function tokens:

**Inline tokens** are generated when a server function reference is first used. The encrypted token is cached on the function instance so that React's internal form state matching (e.g. `useActionState`) works correctly. Inline server functions and server functions passed as props to client components use these tokens, which are embedded in the RSC stream.

**Static tokens** are generated at build time (or Vite transform time in development) for server function modules — files with `"use server"` at the top level. These tokens are embedded in the client JavaScript bundle and are used when client components import server functions directly.

Both types are encrypted with the same key and decrypted transparently on the server when a server function is invoked.

<Link name="zero-configuration">
## Zero configuration
</Link>

Encryption works out of the box with no configuration needed:

- **In development**, an ephemeral key is generated automatically when the dev server starts.
- **In production builds**, a random 32-byte secret is generated during the build and stored as a build artifact. The production server loads this key on startup.

This means server function encryption is always active. You only need to configure it if you want to provide your own secret or enable key rotation.

<Link name="custom-secret">
## Custom secret
</Link>

You can provide your own encryption secret using environment variables or the framework configuration. This is useful when you need deterministic keys across deployments or when running multiple server instances.

The secret is resolved in the following priority order:

1. `REACT_SERVER_FUNCTIONS_SECRET` environment variable
2. `REACT_SERVER_FUNCTIONS_SECRET_FILE` environment variable (path to a key file)
3. `serverFunctions.secret` in the framework configuration
4. `serverFunctions.secretFile` in the framework configuration (path to a key file)
5. Build artifact (generated automatically during production builds)
6. Ephemeral random key (development fallback)

<Link name="environment-variable">
### Environment variable
</Link>

```sh
REACT_SERVER_FUNCTIONS_SECRET=my-secret-key pnpm start
```

Or point to a key file:

```sh
REACT_SERVER_FUNCTIONS_SECRET_FILE=./keys/action-secret.pem pnpm start
```

<Link name="framework-configuration">
### Framework configuration
</Link>

```mjs filename="react-server.config.mjs"
export default {
serverFunctions: {
secret: "my-secret-key",
},
};
```

Or using a key file:

```mjs filename="react-server.config.mjs"
export default {
serverFunctions: {
secretFile: "./keys/action-secret.pem",
},
};
```

> Environment variables and configuration values always take priority over the build artifact. This allows you to rotate secrets without rebuilding your application.

<Link name="key-rotation">
## Key rotation
</Link>

When you rotate your encryption secret, clients that still have pages open with tokens encrypted using the old key would get errors. To avoid this, you can provide previous secrets that the server will try as fallbacks during decryption.

```mjs filename="react-server.config.mjs"
export default {
serverFunctions: {
secret: "new-secret-key",
previousSecrets: ["old-secret-key"],
},
};
```

You can also use key files for previous secrets:

```mjs filename="react-server.config.mjs"
export default {
serverFunctions: {
secret: "new-secret-key",
previousSecretFiles: ["./keys/old-secret.pem"],
},
};
```

Both `previousSecrets` and `previousSecretFiles` accept arrays. You can combine them to support multiple previous keys simultaneously.

The rotation workflow is:

1. Add your current secret to `previousSecrets`
2. Set a new value for `secret`
3. Deploy — all existing tokens still work via the fallback
4. After all clients have refreshed (e.g. after a deployment window), remove the old secret from `previousSecrets`

<Link name="error-handling">
## Error handling
</Link>

When a server function invocation fails decryption — for example, because the token was tampered with, the key was rotated without providing the previous key, or the token is otherwise invalid — the server throws a `ServerFunctionNotFoundError`. This error is propagated through RSC to the client, where it can be caught using an error boundary.

This prevents leaking information about which server functions exist in your application.

<Link name="security-model">
## Security model
</Link>

The encryption provides the following security properties:

- **Server function hiding** — clients cannot discover server function file paths or names from the encrypted tokens.
- **Capability protection** — clients can only invoke server functions that the server explicitly exposed during rendering or bundled into the client code. They cannot forge tokens for arbitrary server functions.
- **Token uniqueness** — each server function reference receives a unique encrypted token (random initialization vector), making tokens unpredictable.
- **Key rotation** — secrets can be rotated at runtime without downtime or rebuilds by using the `previousSecrets` configuration.

> Encryption protects _which_ server functions are callable. It does not validate the _arguments_ passed to those functions. Always validate and sanitize inputs inside your server functions.
5 changes: 5 additions & 0 deletions packages/react-server/dist/server/action-secret.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { importDist } from "@lazarv/react-server/dist/import";

const mod = await importDist("server/action-secret.mjs");

export default mod.default;
20 changes: 20 additions & 0 deletions packages/react-server/lib/build/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ export default async function build(root, options) {
});
}

// Generate the action encryption secret early so the
// use-server plugin can encrypt action IDs in client/SSR stubs.
const { initSecret, initSecretFromConfig, generateSecret } =
await import("../../server/action-crypto.mjs");
// Generate a fresh secret and set it as the baseline key.
const actionSecret = generateSecret();
initSecret(actionSecret);
// Let user-provided secret (env var, config, .pem) override
// the generated key. At runtime the same env/config will
// override the build artifact, keeping the keys in sync.
await initSecretFromConfig(config[CONFIG_ROOT]);

// Create event bus for parallel builds
// This allows RSC build to emit client component entries
// that SSR and Client builds consume dynamically
Expand Down Expand Up @@ -176,6 +188,14 @@ export default async function build(root, options) {
"utf8"
);

// Persist the action encryption secret as a build artifact so it
// survives serverless cold starts and edge restarts.
await writeFile(
join(cwd, options.outDir, "server/action-secret.mjs"),
`export default ${JSON.stringify(actionSecret)};\n`,
"utf8"
);

if (options.edge) {
// empty line
console.log();
Expand Down
8 changes: 8 additions & 0 deletions packages/react-server/lib/build/edge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ export default async function edgeBuild(root, options) {
return sys.normalizePath(
join(cwd, options.outDir, "server/build-manifest.mjs")
);
case "@lazarv/react-server/dist/server/action-secret":
return sys.normalizePath(
join(cwd, options.outDir, "server/action-secret.mjs")
);
}
},
load(id) {
Expand Down Expand Up @@ -362,6 +366,10 @@ export default async function edgeBuild(root, options) {
return sys.normalizePath(
join(cwd, options.outDir, "server/build-manifest.mjs")
);
case "@lazarv/react-server/dist/server/action-secret":
return sys.normalizePath(
join(cwd, options.outDir, "server/action-secret.mjs")
);
}
},
load(id) {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-server/lib/dev/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ export default async function dev(root, options) {

runtime$(CONFIG_CONTEXT, config);

// Resolve the action encryption secret once at startup
// (from env vars, config, or .pem file — not per-render).
const { initSecretFromConfig } =
await import("../../server/action-crypto.mjs");
await initSecretFromConfig(configRoot);

const isNonInteractiveEnvironment =
!process.stdin.isTTY ||
process.env.CI === "true" ||
Expand Down
7 changes: 7 additions & 0 deletions packages/react-server/lib/dev/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export function reactServer(root, options = {}, initialConfig = {}) {

await runtime_init$(async () => {
runtime$(CONFIG_CONTEXT, config);

// Resolve the action encryption secret once at startup.
const { initSecretFromConfig } =
await import("../../server/action-crypto.mjs");
const { CONFIG_ROOT } = await import("../../server/symbols.mjs");
await initSecretFromConfig(config[CONFIG_ROOT]);

const server = await createServer(root, options);
if (config.server?.hmr !== false) server.ws.listen();
resolve(server);
Expand Down
5 changes: 5 additions & 0 deletions packages/react-server/lib/loader/bun.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export async function reactServerBunAliasPlugin(options) {
outDir,
"server/build-manifest.mjs"
),
"@lazarv/react-server/dist/server/action-secret": join(
cwd,
outDir,
"server/action-secret.mjs"
),
"@lazarv/react-server/dist/server/server-manifest": manifestLoaderPath,
"@lazarv/react-server/dist/server/client-manifest": manifestLoaderPath,
"@lazarv/react-server/dist/client/browser-manifest": manifestLoaderPath,
Expand Down
5 changes: 5 additions & 0 deletions packages/react-server/lib/loader/deno.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export async function generateDenoImportMap(options = {}) {
outDir,
"server/build-manifest.mjs"
),
"@lazarv/react-server/dist/server/action-secret": join(
cwd,
outDir,
"server/action-secret.mjs"
),
"@lazarv/react-server/dist/server/server-manifest": manifestLoaderPath,
"@lazarv/react-server/dist/server/client-manifest": manifestLoaderPath,
"@lazarv/react-server/dist/client/browser-manifest": manifestLoaderPath,
Expand Down
8 changes: 8 additions & 0 deletions packages/react-server/lib/loader/node-loader.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export async function resolve(specifier, context, nextResolve) {
return nextResolve(
pathToFileURL(join(cwd, outDir, "server/build-manifest.mjs")).href
);
case "@lazarv/react-server/dist/server/action-secret":
try {
return await nextResolve(
pathToFileURL(join(cwd, outDir, "server/action-secret.mjs")).href
);
} catch {
return nextResolve("@lazarv/react-server/dist/server/action-secret");
}
case "@lazarv/react-server/dist/server/server-manifest":
case "@lazarv/react-server/dist/server/client-manifest":
case "@lazarv/react-server/dist/client/browser-manifest":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ export async function resolve(specifier, context, nextResolve) {
return nextResolve(
pathToFileURL(join(cwd, outDir, "server/build-manifest.mjs")).href
);
case "@lazarv/react-server/dist/server/action-secret":
try {
return await nextResolve(
pathToFileURL(join(cwd, outDir, "server/action-secret.mjs")).href
);
} catch {
return nextResolve("@lazarv/react-server/dist/server/action-secret");
}
case "@lazarv/react-server/dist/server/server-manifest":
case "@lazarv/react-server/dist/server/client-manifest":
case "@lazarv/react-server/dist/client/browser-manifest":
Expand Down
5 changes: 3 additions & 2 deletions packages/react-server/lib/plugins/use-server.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { extname, relative } from "node:path";

import { encryptActionId } from "../../server/action-crypto.mjs";
import * as sys from "../sys.mjs";
import { codegen, parse } from "../utils/ast.mjs";

Expand Down Expand Up @@ -159,7 +160,7 @@ export default function useServer(type, manifest) {
arguments: [
{
type: "Literal",
value: `${actionId}#${name}`,
value: encryptActionId(`${actionId}#${name}`),
},
],
},
Expand Down Expand Up @@ -219,7 +220,7 @@ export default function useServer(type, manifest) {
arguments: [
{
type: "Literal",
value: `${actionId}#${name}`,
value: encryptActionId(`${actionId}#${name}`),
},
{
type: "Identifier",
Expand Down
28 changes: 27 additions & 1 deletion packages/react-server/lib/start/manifest.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { join } from "node:path";

import { getContext } from "../../server/context.mjs";
import { runtime$ } from "../../server/runtime.mjs";
import { getRuntime, runtime$ } from "../../server/runtime.mjs";
import {
COLLECT_CLIENT_MODULES,
COLLECT_STYLESHEETS,
CONFIG_CONTEXT,
CONFIG_ROOT,
HTTP_CONTEXT,
MAIN_MODULE,
MANIFEST,
Expand Down Expand Up @@ -76,6 +78,30 @@ export async function init$(options = {}) {
// build-manifest may not exist for older builds
}

// Load action encryption secret from the build artifact.
// Users can override via env var or config (resolved in initSecretFromConfig).
try {
const { default: actionSecret } =
await import("@lazarv/react-server/dist/server/action-secret");
if (actionSecret) {
const { initSecret } = await import("../../server/action-crypto.mjs");
initSecret(actionSecret);
}
} catch {
// action-secret may not exist for builds without server functions
}

// Resolve the action encryption secret from env vars / config / .pem file.
// This runs once at startup — env/config take priority over the build artifact.
try {
const { initSecretFromConfig } =
await import("../../server/action-crypto.mjs");
const config = getRuntime(CONFIG_CONTEXT);
await initSecretFromConfig(config?.[CONFIG_ROOT]);
} catch {
// ignore
}

const mainModule = `/${Object.values(manifest.browser).find((entry) => entry.name === "index")?.file}`;
runtime$(MAIN_MODULE, [mainModule]);

Expand Down
Loading