Skip to content

Secrets

A Modal Secret is a named set of environment variables stored in an environment. Functions, Servers and Sandboxes that attach it see its variables. A Sandbox can also leave them out of its environment entirely, and have Modal add them to its outgoing requests instead.

Give a Secret its values from Config, so they come from your .env or secrets manager and stay out of logs and state:

src/secrets.ts
import * as Modal from "@alchemy.run/modal";
import * as Config from "effect/Config";
import { MyEnv } from "./environments.ts";
export const OpenAI = Modal.Secret("OpenAI", {
environment: MyEnv,
env: { OPENAI_API_KEY: Config.Redacted("OPENAI_API_KEY") },
});

Every deploy writes the whole set. Containers started afterwards see the new values, and running ones keep theirs.

.env is only the default. A Stack’s secrets option reads the same Config values from a secrets manager instead, like Doppler or Infisical, so production keys never sit in a file on anyone’s machine. Pick the source per stage:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "@alchemy.run/cloudflare";
import * as Doppler from "@alchemy.run/doppler";
import * as Modal from "@alchemy.run/modal";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { OpenAI } from "./src/secrets.ts";
export default Alchemy.Stack(
"MyApp",
{
providers: Layer.mergeAll(Cloudflare.providers(), Modal.providers()),
state: Cloudflare.state(),
secrets: ({ stage }) =>
stage === "prod"
? Doppler.Secrets({ project: "my-app", config: "prd" })
: Alchemy.Secrets.DotEnv(),
},
Effect.gen(function* () {
const openai = yield* OpenAI;
return { secret: openai.name };
}),
);

Config.Redacted("OPENAI_API_KEY") doesn’t change. See Secret providers.

A Function or Server lists the Secrets its containers see:

src/embed.ts
export default class Embed extends Modal.Function<Embed>()(
"Embed",
{ main: import.meta.url, app: Inference, secrets: [OpenAI] },
Effect.gen(function* () {
const key = yield* Config.Redacted("OPENAI_API_KEY");
return { fetch: Effect.succeed(HttpServerResponse.text("ok")) };
}),
) {}

A sandbox your code creates takes them by id. Read the id while the Worker is constructed, and pass it in the handler:

// construction phase
const openaiId = yield* (yield* OpenAI).secretId;
// in a handler
const openai = { secretId: yield* openaiId };
const sandbox = yield* sandboxes.create({ secrets: [openai] });

Add it to requests, never into the sandbox

Section titled “Add it to requests, never into the sandbox”

Code an agent writes can print its environment. To keep a key from it, leave the Secret out of secrets and give the sandbox an outbound policy: for requests to the hosts you name, Modal replaces headers with the Secret’s value on their way out.

request+ Authorizationsession-samSandboxagent codeno keyModal egressoutbound policyapi.openai.comrequest+ Authorizationsession-samSandboxagent codeno keyModal egressoutbound policyapi.openai.com
The agent's request leaves the sandbox without a key. Modal's egress adds the Authorization header from the Secret, only for api.openai.com.
const sandbox = yield* sandboxes.create({
outboundPolicy: {
routes: [
{
https: {
domains: ["api.openai.com"],
headerReplacements: [{ secret: openai, headers: { Authorization: "Bearer $OPENAI_API_KEY" } }],
},
},
{ any: {} },
],
},
});

$OPENAI_API_KEY names a variable in the Secret. The last route, { any: {} }, lets every other request through unchanged; leave it out to allow only the routes you list.

A user’s own token doesn’t belong in your Stack. Bind CreateSecret to an environment and put one by name while the Worker runs:

const secrets = yield* Modal.CreateSecret(MyEnv);
const github = yield* secrets.put(`github-${user}`, { GITHUB_TOKEN: token });
const sandbox = yield* sandboxes.create({ secrets: [github] });

Provide Modal.CreateSecretHttp. The Worker’s service user gets contributor in the environment. Secrets created this way carry the stack, stage and binding in their name, and stay when the Stack is destroyed.

WriteSecret replaces the values of one Secret the Stack declares, like a token you rotate on a schedule. Modal never returns a Secret’s values, so there is no read binding:

const openai = yield* Modal.WriteSecret(OpenAI);
yield* openai.put({ OPENAI_API_KEY: next });

Give a sandbox a cloud identity, not a key

Section titled “Give a sandbox a cloud identity, not a key”

For a cloud that trusts an OIDC issuer, like AWS or Google Cloud, a sandbox needs no stored key at all. Create it with includeOidcIdentityToken, and Modal sets MODAL_IDENTITY_TOKEN: a token Modal signs as https://oidc.modal.com, whose subject names the workspace, environment and app.

const sandbox = yield* sandboxes.create({
image: "amazon/aws-cli",
includeOidcIdentityToken: true,
});
const role = yield* sandbox.exec(
`aws sts assume-role-with-web-identity --role-arn ${roleArn} --role-session-name sandbox --web-identity-token "$MODAL_IDENTITY_TOKEN"`,
);

The role trusts Modal’s issuer and matches the token’s subject, so only your workspace’s sandboxes can assume it, and the credentials it returns expire on their own. includeX509Svid serves a certificate that names the sandbox instead, for AWS IAM Roles Anywhere.