Skip to content

Access control

A Modal Function or Server calls Modal as its own container, over the API socket Modal gives every container. No token is stored in it. Every other host (a Worker, a Lambda, a Sandbox) gets its own scoped service user, and a binding grants it viewer or contributor on the bound resource’s environment. Keep workloads apart with environments.

src/Environments.ts
import * as Modal from "@alchemy.run/modal";
export const Production = Modal.Environment("Production", {
managed: true,
defaultMemberRole: "viewer",
roles: [{ userId: "us-1234", role: "contributor" }],
blockUnauthenticatedResources: true,
});

Members not listed get defaultMemberRole.

src/Identities.ts
import * as Modal from "@alchemy.run/modal";
import { Production } from "./Environments.ts";
export const Deployer = Modal.ServiceUser("Deployer", {
environments: [{ environment: Production, role: "contributor" }],
});

A scoped service user acts only in the environments you list. Its tokenId and tokenSecret are a MODAL_TOKEN_ID / MODAL_TOKEN_SECRET pair.

Service users need Modal’s Team or Enterprise plan. On Starter, a deploy that binds a Modal resource fails with Modal.ServiceUsersNotAvailable. Add Modal.DeployerHostIdentity to the Stack’s providers to bind your own token into each host instead:

alchemy.run.ts
import * as Cloudflare from "@alchemy.run/cloudflare";
import * as Modal from "@alchemy.run/modal";
import * as Alchemy from "alchemy";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
export default Alchemy.Stack(
"App",
{
providers: Layer.mergeAll(Cloudflare.providers(), Modal.providers(), Modal.DeployerHostIdentity),
},
Effect.gen(function* () {
return {};
}),
);

Hosts then act with your token’s workspace-wide access, not just the roles their bindings ask for.

const embed = yield* Modal.InvokeFunction(Embed);
const api = yield* Modal.FetchServer(Api);

Each binding mints its own key for that caller and target. The caller keeps the key as a secret, the target keeps only its SHA-256, and every RPC call carries the key. A Function or Server answers RPC calls only from callers whose binding issued a key, so anything else in the workspace, including code with Modal access, gets 401.

The key applies inside Modal too. A Function calling another Function logs in to Modal as its container, but that login names no caller the target can check, so the key is what limits a target to the callers you bound.

{ main: import.meta.url, app: Sandboxed, restrictModalAccess: true }

A program with restrictModalAccess can’t call Modal as its container. Its bindings get a scoped service user instead, so it can reach only what it binds.

const { url, token } = yield* sandbox.connectToken({ port: 8080, userMetadata: user });

The token opens that port only.

src/Tokens.ts
import * as Modal from "@alchemy.run/modal";
import { Production } from "./Environments.ts";
export const Caller = Modal.WebhookToken("Caller", { scoped: true, environments: [Production] });

Send tokenId as Modal-Key and tokenSecret as Modal-Secret.

src/Settings.ts
import * as Modal from "@alchemy.run/modal";
import { Production } from "./Environments.ts";
export const Settings = Modal.WorkspaceSettings("Settings", {
defaultEnvironment: Production,
blockUnauthenticatedResources: true,
});

Destroying it restores the settings it found.