# How Do I Give Every User of My Product Their Own Sandbox?

> 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.

Source: https://sandboxer.com/guides/give-every-user-their-own-sandbox. Updated 2026-10-27.

**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](/guides/turn-a-sandbox-into-a-reusable-template) 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](/docs/api#labels)). 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:

```javascript
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 })));
```

```text
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

```javascript
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());
```

```text
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 directory
```

Each sandbox is a separate Cloud Hypervisor microVM with its own kernel and its own copy-on-write disk (see [security](/docs/security#isolation)). 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:

```javascript
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());
```

```text
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=1
```

Alice'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](/guides/give-a-sandbox-port-an-https-url) 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:

```text
SANDBOXER_API_URL
SANDBOXER_ID
SANDBOXER_SUBDOMAIN
SANDBOXER_TOKEN
```

A `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:

```text
/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:

```bash
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-3
```

```text
alice'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](/docs/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](/docs/limits).

## Lifecycle: idle, return, churn

- **Idle.** Set `idleStopMinutes` on 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 reports `resumeReason: "cpu_mismatch"` ([snapshots](/docs/snapshots)). See [idle auto-pause](/guides/idle-auto-pause-and-wake-on-use).
- **Hard stop.** A new sandbox auto-stops after one hour unless you set `ttlSeconds` (up to 30 days, or `null` for never).
- **Billing your users.** `GET /sandboxes/{id}/usage` returns 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:

```javascript
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);
```

```text
deleted sb_2n6zisilz3
deleted sb_5ekk38xhsb
left: 0
```

A 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

- [API keys](/docs/api-keys)
- [Environments and noEnv](/docs/environments)
- [Security](/docs/security)
- [Idle auto-pause and wake on use](/guides/idle-auto-pause-and-wake-on-use)
