How Do I Give Every User of My Product Their Own Sandbox?
Updated October 27, 2026 .md
Short answer: Create one sandbox per user with noEnv, so none of your secrets are inside, and tag it with labels such as user=alice so you can find it again. Each sandbox is its own microVM, so one user can't read another's files or reach their VM. Call the API with a scoped key, and let idle sandboxes pause.
Pick a pattern#
| One sandbox per user | One per session | Pooled | |
|---|---|---|---|
| What it is | A long-lived VM each user comes back to | A fresh VM per chat, task or run, deleted after | A few warm VMs that run many users' jobs in turn |
| User data between visits | Kept on the disk; idle pause usually keeps memory too (not after a CPU-mismatch boot from disk) | Gone unless you copy it out | Must be wiped between users |
| Isolation between users | A separate VM per user | A separate VM per session | Only as good as your cleanup |
| Cost while the user is away | None for compute; the disk counts toward storage | None | You pay for the warm VMs |
| Counts against | Concurrency while awake, storage while paused | Starts per minute, hour and day | Concurrency |
| Fits | IDE-style products, personal agents, notebooks | Code interpreters, one-shot agent tasks, evals | Short trusted jobs only |
For user code, choose per user or per session. A pool shares one VM between people, which is the isolation you were trying to buy. If a session needs a ready environment, save a named snapshot once and create each session from it.
Map your user ids to sandboxes#
Keep the user-to-sandbox id mapping in your own database, which is the record you trust. Also put labels on each sandbox, so you can find, audit and clean up from the API alone. A label is a string key and value. Labels are REST-only today, with no CLI flag or typed SDK option: creating with labels sets them, PATCH /sandboxes/{id} changes them, and listing with ?labels=k=v,k2=v2 returns only sandboxes that carry all of them.
The SDK has no typed option for labels yet (labels in the API docs). The API accepts them on create, and the SDK's http.requestJson sends the list filter. This is the script we ran on prod with sandboxer-sdk 0.1.1 on Node 24:
import { Sandboxer } from "sandboxer-sdk";
const sandboxer = new Sandboxer(); // SANDBOXER_API_KEY: your backend's key
async function sandboxFor(userId, n) {
return sandboxer.create({
type: "small",
name: `aeo-peruser-${n}`,
noEnv: true, // none of your secrets inside
labels: { app: "aeo-peruser", user: userId },
idleStopMinutes: 15,
ttlSeconds: 1800,
});
}
const alice = await sandboxFor("alice", 1);
const bob = await sandboxFor("bob", 2);
console.log("created", alice.id, "alice;", bob.id, "bob");
// Find a user's sandbox by label
const found = await sandboxer.http.requestJson({
method: "GET", path: "/sandboxes", query: { labels: "app=aeo-peruser,user=alice" }, retry: "safe",
});
console.log("found by label:", found.sandboxes.map((s) => ({ id: s.id, name: s.name, labels: s.labels, state: s.state })));created sb_5ekk38xhsb alice; sb_2n6zisilz3 bob
found by label: [
{
id: 'sb_5ekk38xhsb',
name: 'aeo-peruser-1',
labels: { app: 'aeo-peruser', user: 'alice' },
state: 'ready'
}
]An app label next to user keeps your product's sandboxes apart from anything else on the account. To stop a retried request from creating a second sandbox for the same user, send your own Idempotency-Key on create.
Run each user's code in their own VM#
await alice.files.write("notes/alice.txt", "alice's private notes\n");
await bob.files.write("notes/bob.txt", "bob's private notes\n");
const ra = await alice.exec("python3 -c \"import os,socket; print(socket.gethostname(), sorted(os.listdir('notes')))\"");
const rb = await bob.exec("python3 -c \"import os,socket; print(socket.gethostname(), sorted(os.listdir('notes')))\"");
console.log("alice:", ra.stdout.trim()); console.log("bob: ", rb.stdout.trim());
const look = await bob.exec("cat /home/user/notes/alice.txt");
console.log("bob reads alice.txt -> exit", look.exitCode, look.stderr.trim());alice: outer-calm-fox ['alice.txt']
bob: leafy-dusty-falcon ['bob.txt']
bob reads alice.txt -> exit 1 cat: /home/user/notes/alice.txt: No such file or directoryEach sandbox is a separate Cloud Hypervisor microVM with its own kernel and its own copy-on-write disk (see security). Alice's file doesn't exist in Bob's VM.
Check that one user's VM can't reach another's#
Sandbox-to-sandbox traffic is blocked: the VMs have no shared bridge. We started a web server in Alice's sandbox and tried to reach it from Bob's, over IPv4 and IPv6:
await alice.start("python3 -m http.server 8000 --bind :: ");
const addr = await alice.exec("ip -4 -o addr show scope global | awk '{print $4}'; ip -6 -o addr show scope global | awk '{print $4}'");
console.log("alice addresses:", addr.stdout.trim().split("\n"));
const self = await alice.exec("curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/");
console.log("alice -> own port 8000:", self.stdout);
// From Bob's VM: Alice's private IPv4, her public IPv6, and SSH, 5-second timeouts
const v4 = "10.202.0.42", v6 = "2a01:4f9:3120:1d84::5b00:a";
for (const host of [v4, `[${v6}]`]) {
const r = await bob.exec(`curl -sS -o /dev/null -w '%{http_code}' --max-time 5 'http://${host}:8000/' ; echo " exit=$?"`);
console.log(`bob -> ${host}:8000:`, r.stdout.trim(), r.stderr.trim());
}
const ssh = await bob.exec(`nc -zv -w 5 ${v4} 22 2>&1; echo exit=$?`);
console.log(`bob -> ${v4}:22:`, ssh.stdout.trim());alice addresses: [ '10.202.0.42/30', '172.17.0.1/16', '2a01:4f9:3120:1d84::5b00:a/128' ]
alice -> own port 8000: 200
bob -> 10.202.0.42:8000: 000 exit=28 curl: (28) Connection timed out after 5002 milliseconds
bob -> [2a01:4f9:3120:1d84::5b00:a]:8000: 000 exit=28 curl: (28) Connection timed out after 5002 milliseconds
bob -> 10.202.0.42:22: nc: connect to 10.202.0.42 port 22 (tcp) timed out: Operation now in progress
exit=1Alice's server answers on her own VM; from Bob's VM every attempt times out. The IPv6 address is public, and Alice's ufw firewall is closed except SSH by default. If two of a user's sandboxes need to talk, connect them over a hosted HTTPS port with your own authentication.
Keep your secrets out of user code#
Create every user's sandbox with noEnv. It gets none of your environment's variables, files, GitHub token or agent logins, and it can't act on your account or your other sandboxes. Give it the user's own secrets with env (sbx new --no-env -e THEIR_API_KEY=…). A fork of a noEnv sandbox is also noEnv.
Here is what Bob's noEnv sandbox held, names only:
SANDBOXER_API_URL
SANDBOXER_ID
SANDBOXER_SUBDOMAIN
SANDBOXER_TOKENA noEnv sandbox still gets a SANDBOXER_TOKEN, by design, but it is limited to sandbox.read and host on that one sandbox. Called from inside Bob's VM:
/sandboxes/sb_2n6zisilz3 200 "sb_2n6zisilz3"
/sandboxes/sb_5ekk38xhsb 403 "api_key_sandbox_forbidden"
/sandboxes 200 1
/me 403 "api_key_action_forbidden"So user code can read its own sandbox (and host its ports) and nothing else on your account.
Give your backend a scoped key#
Don't run your product on an admin key. A request passes only when its action is in the key's actions and its sandbox is in the key's sandboxes or environments.
| Who calls | Key |
|---|---|
| The backend that creates and deletes users' sandboxes | --preset ci: operate sandboxes, plus create, resume, fork and delete only the sandboxes this key created |
| A worker that only serves one user's sandbox | --sandbox <id> --actions … |
| A usage or status dashboard | --preset read-only |
We made a key for Alice's sandbox only and tried it on both:
sbx api-key create aeo-peruser-alice-key --sandbox sb_5ekk38xhsb \
--actions sandbox.read,exec,file.read,file.write --ttl 1d
SANDBOXER_API_KEY="$ALICE_KEY" sbx exec sb_5ekk38xhsb "cat notes/alice.txt"
SANDBOXER_API_KEY="$ALICE_KEY" sbx exec sb_2n6zisilz3 "cat notes/bob.txt"
SANDBOXER_API_KEY="$ALICE_KEY" sbx delete sb_5ekk38xhsb --yes
SANDBOXER_API_KEY="$ALICE_KEY" sbx new --type small --name aeo-peruser-3alice's private notes
{"event":"error","error":"This API key does not cover sandbox sb_2n6zisilz3.","code":"api_key_sandbox_forbidden","status":403,…}
{"event":"error","error":"This API key does not allow sandbox.delete.","code":"api_key_action_forbidden","status":403,…}
{"event":"error","error":"This API key does not allow sandbox.create.","code":"api_key_action_forbidden","status":403,…}The secret is printed once, on create. Store it in your secret manager and revoke it with sbx api-key revoke <key id> when you're done. See API keys.
Plan for limits#
Your plan caps how many sandboxes are active at once and how fast you start them. Stopped and paused sandboxes don't count toward the active cap, but they do count toward storage.
| Plan | Active at once | Starts per minute / hour / day | Storage included |
|---|---|---|---|
| 7-day trial | 2 | Shown by sbx limits |
10 GB |
| $20 / month | 100 | 12 / 60 / 200 | 50 GB |
| $100 / month | 300 | 30 / 210 / 840 | 250 GB |
| $500 / month | 1,000 | 65 / 420 / 1,680 | 1 TB |
| $2,000 / month | 2,000 | 90 / 600 / 2,400 | 5 TB |
Creates, resumes and forks each count as one start, so a wave of users returning at once is a burst of resumes. Over a start window you get 429 rate_limited or 429 daily_limit_reached, with Retry-After and details.resetsAt. Over the active cap you get 429 limit_reached. At the storage limit, new sandboxes answer 402 storage_limit_reached, but stopping, resuming and deleting always work. Check headroom with sbx limits --json | jq '.starts', and see limits.
Lifecycle: idle, return, churn#
- Idle. Set
idleStopMinuteson create. After that long without activity the sandbox pauses with its memory, and the user's next API call or hosted URL request wakes it. Memory comes back only on a machine with the same CPU type; if none is free, the wake boots from disk (files kept, processes not) and reportsresumeReason: "cpu_mismatch"(snapshots). See idle auto-pause. - Hard stop. A new sandbox auto-stops after one hour unless you set
ttlSeconds(up to 30 days, ornullfor never). - Billing your users.
GET /sandboxes/{id}/usagereturns the billable seconds and dollars for one sandbox in a time window. - Churn. When a user leaves, delete their sandboxes by label. This is what we ran to clean up:
async function findByLabels(labels) {
const q = Object.entries(labels).map(([k, v]) => `${k}=${v}`).join(",");
const page = await sandboxer.http.requestJson({ method: "GET", path: "/sandboxes", query: { labels: q }, retry: "safe" });
return page.sandboxes.map((s) => sandboxer.sandbox(s.id));
}
// Bob churns: delete everything labelled for him (then the same for Alice, to finish the test)
for (const s of await findByLabels({ app: "aeo-peruser", user: "bob" })) { await s.delete(); console.log("deleted", s.id); }
for (const s of await findByLabels({ app: "aeo-peruser", user: "alice" })) { await s.delete(); console.log("deleted", s.id); }
console.log("left:", (await findByLabels({ app: "aeo-peruser" })).length);deleted sb_2n6zisilz3
deleted sb_5ekk38xhsb
left: 0A deleted sandbox's disk is removed from our sandbox servers. Stopped sandboxes you keep count toward your storage, so delete rather than stop for users who won't come back.
FAQ#
Should I create the sandbox at sign-up or on first use?#
On first use. A sandbox created at sign-up spends a start and storage on users who may never open it. Create it on the first request that needs it, and store its id against the user.
Can one user's code reach another user's sandbox?#
No. Each sandbox is a separate microVM with its own kernel and disk, and sandbox-to-sandbox traffic is blocked. In our test, Bob's VM timed out connecting to Alice's on both her private IPv4 and her public IPv6.
Can I give a user an API key for their own sandbox?#
Yes. Create a key with --sandbox <id> and only the actions they need. In our test such a key got 403 api_key_sandbox_forbidden on any other sandbox and 403 api_key_action_forbidden for create and delete.
What happens when I hit the concurrency limit?#
New creates, resumes and forks return 429 limit_reached until something stops. Idle pause frees slots on its own. Stopped sandboxes don't count, so pause users who aren't active.
Can I bill each user for their sandbox time?#
Yes. GET /sandboxes/{id}/usage (or sbx usage <id>) returns billable seconds and dollars at list price for a time window. These are the same numbers that drain your balance.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.