---
name: credentials
description: Gives a workload in a smolvm machine an API key or token it can use but never read, by binding the credential to named HTTPS hosts so the guest holds only a placeholder and the host substitutes the value on the way out, then proves on the host that the value never entered the machine. Use when an agent or untrusted code inside a machine has to call an API with your key; when deciding between a credential binding and --secret-env; when a request from a credentialed machine comes back 403 or 502 from smolvm itself; or when you need evidence that a key stayed out of a machine. Do not use it for a value the program must read and parse, such as a database URL, which is --secret-env, or for git and ssh keys, which is SSH agent forwarding.
---

# A credential the machine can use and never hold

Verified on **smolvm v1.29.0** on macOS arm64, 2026-10-15, and on **v1.18.2** on Linux aarch64, 2026-09-24, for everything that
is decided on the host; on v1.29.0 on macOS arm64 **the substituted value also arrived at a real API**
that echoes the headers it received, a dummy value. Done means the guest's variable is a placeholder, the value
is nowhere in the guest, the machine's record or smolvm's database, TLS to the bound host goes
through the machine's own CA, and a placeholder anywhere but a request header is refused before it
leaves the host (from v1.27.0 a binding with `:body` also substitutes into a form body).
The Linux runs used the scripts of their date; this version's preflight and cleanup scripts ran
on Linux aarch64 on v1.22.2 on 2026-10-03.

`docs/credential-substitution.md` in the smolvm repository is how the feature works: bindings,
where the value comes from, what the guest sees, what happens to each request, and the limits.
`README.md` has what was measured beside it. This file is how to set it up and prove it.

## Procedure

```
- [ ] 1. preflight.sh             the flag, the network backend, the host variable
- [ ] 2. create-credentialed.sh   a machine bound to one host
- [ ] 3. verify-containment.sh    the value stays out; the interceptor is on the path
- [ ] 4. your own request         the first call that sends the value
- [ ] 5. cleanup.sh
```

**1. Preflight.** Read-only, and it never prints the value or its length.

```bash
export API_TOKEN=...          # however your secrets manager hands it over
scripts/preflight.sh --var API_TOKEN --host api.example.com
```

Every script here reads the value from its own environment, so keep it exported in the shell that
runs `create-credentialed.sh` and `verify-containment.sh` as well as this one.

```
has_credential_flag=yes
default_backend=virtio-net
tsi_stream_intercept=yes
host_var_set=yes
host_form=ok
memory_required_mib=1024
result=ready
```

`tsi_stream_intercept` is read from the symbols of the bundled libkrun: a binding selects virtio-net
by default, and `--net-backend tsi` carries a credential only when libkrun exports
`krun_set_stream_intercept`, which both v1.18.2 release libraries do. `host_form=rejected` means the
host is neither a lowercase DNS name nor, from v1.26.0, a wildcard such as `*.example.com`; `create`
would refuse it anyway, with a clearer message.

**2. Create the machine.** The value is read from the environment of this command's `machine
start`, and that is the value the machine uses until its next start.

```bash
scripts/create-credentialed.sh --var API_TOKEN --host api.example.com
```

```
workload_ready_after_s=1
guest_sees=placeholder
binding=api-token host=api.example.com
result=up
```

The command it runs is `smolvm machine create ... --credential api-token=API_TOKEN@api.example.com`
and then `machine start`. `--credential` implies `--net`. In a Smolfile the same binding is a
`[[network.credentials]]` table, in `docs/credential-substitution.md`.

**3. Prove the value stayed out.** Nothing this sends carries the placeholder where it would be
substituted, so the value goes nowhere during the check. On an Alpine guest it adds openssl and
curl with apk, which step 4 then uses. Run it on the machine `create-credentialed.sh` made, before
any workload of yours runs in it: the guest search hands the value to a `grep` in the guest for the
length of the search, so a workload that can replace `grep` could read it.

```bash
scripts/verify-containment.sh --var API_TOKEN --host api.example.com --other-host example.org
```

```
guest_variable=ok (placeholder)
value_in_guest=ok (0)
value_in_record=ok (0)
interception=ok (on)
passthrough=ok (real)
query_refused=ok (403)
refusal_text=smolvm credentials: placeholders are substituted in request headers only
result=contained
```

`value_in_guest` hands the value to a `grep` inside the guest on standard input for the length of
the search, and looks in every process environment and the writable trees inside the guest;
`value_in_record` searches the machine's directory and smolvm's database on the host;
`interception` reads the issuer of the bound host's certificate as the guest sees it, which is
`smolvm <machine> credential CA`, while `--other-host` keeps its real issuer.

**4. Use it.** The workload sends the placeholder in a header, exactly where the real key would go.
`smolskill-cred` is the machine `create-credentialed.sh` makes unless you pass `--name`:

```bash
smolvm machine exec --name smolskill-cred -- sh -c \
  'curl -sS -H "Authorization: Bearer $API_TOKEN" https://api.example.com/v1/me'
```

That is the first request that carries the value, and it goes only to the host you bound. Judge it
by the API's answer, the way you would without smolvm.

**5. Clean up.**

```bash
scripts/cleanup.sh --purge
```

`cleanup.sh` waits up to 20 seconds, polling the machine list, and prints `waiting=up to 20s`
first: an ephemeral machine's entry retires after its run returns.

## When the workload has to read the value

A binding keeps the value out of the guest, which is what this packet is for. When the workload
itself has to hold the key, `--secret-env` puts it in the workload's environment instead, resolved
from a host variable when the machine launches:

```bash
smolvm machine run --image alpine --secret-env API_TOKEN=API_TOKEN -- sh -c 'test -n "$API_TOKEN" && echo set'
```

On v1.23.1 on macOS arm64, with a dummy value in `API_TOKEN`, that printed `set`. With the host
variable unset the run stopped before the workload with `Error: config operation failed: resolve
secret 'API_TOKEN': env_unset`. The value is plaintext in the guest, where any process can read it
and send it anywhere the network allows; that is why the binding is the default here.

## Traps

Full detail in `references/traps.md`.

- **An environment value behaves as the one the machine started with**; `README.md` has the
  v1.18.2 measurement. Rotate it with a restart, or use a file reference,
  read per request.
- **Never put the real value on an `exec` command line.** smolvm writes exec commands into the
  machine's console log on the host, so a check that interpolated the value into its own `grep`
  planted it in `agent-console.log` and failed itself. `verify-containment.sh` hands the value to
  `grep` on standard input, in the guest and on the host, for that reason.
- **A 403 from smolvm is a refusal, not the API.** The body says which rule: a placeholder in the
  path or query, or in a body without `:body`; in a routing or framing header such as `Cookie`;
  more than one in a request; or one the machine did not mint. A `403` with `this credential is not allowed for this host`
  means the placeholder belongs to a binding for another host; a `405` means the binding does not
  allow the method.
- **On v1.18.2 a machine created from a pack ignores `--credential`**, silently; on v1.22.2 it
  binds. `references/traps.md` has both.
- **Port 80 is not intercepted.** Plaintext HTTP is relayed untouched, so a placeholder there
  travels as the literal string and the API sees garbage.
- **The machine's CA is the only one the guest trusts for the bound host.** curl, Python, Node,
  Deno and Git pick it up from the variables smolvm sets; a client with its own trust store needs
  `/run/smol/credentials/ca.pem` added.

## Security defaults, and why they are the defaults

- **A binding is narrower than a secret.** `--secret-env` puts the plaintext in the workload's
  environment, where any code in the guest can read and send it anywhere the network allows. A
  binding lets the workload use the key only in a header, or a form body when you add `:body`, only
  toward the hosts named.
- **Bind to the narrowest hosts that work.** A host is an exact name or, from v1.26.0, a whole-label
  wildcard such as `*.example.com`, which matches its subdomains and not `example.com` itself; a
  one-label parent such as `*.com` is refused. When the machine also has `--allow-host`, every
  credential host has to sit inside that list, and a wildcard only when the list admits its whole
  subtree; a create that breaks either rule is refused.
- **Substitution is not data-loss prevention.** An API that echoes your key back in a response
  body hands it to the guest. Bind keys only to APIs you trust with them.
- **The scripts never print the value** or its length, and the preflight reports only whether the
  variable is set.
- **Cleanup deletes only machines the scripts recorded** under the `smolskill-` prefix.

## Platform arms

`references/platforms.md`. macOS arm64 ran every script on v1.29.0 and v1.22.2, and both hosts gave the same
results on v1.18.2, on the default virtio-net backend and on `--net-backend tsi`. Linux x86_64 and
Windows were not run.

## Eval prompts, and what they produced

Run 2026-09-24 PT against v1.18.2 from the published release, under an isolated `HOME`, on macOS
26.6.2 arm64 and Lima `linux-kvm` (Ubuntu 24.04 aarch64), with a random throwaway value and
`example.com` as the bound host.

**1. "Let the agent in this machine call the API with my token without the machine ever having the
token."**

`preflight.sh`, `create-credentialed.sh` and `verify-containment.sh` in order, both hosts:
`result=ready`, `guest_sees=placeholder`, then `result=contained` with all six checks as shown
above. The guest's variable read `SMOL_PLACEHOLDER_DEMO_E0226293E472287B661DFAB687C5DE0C` on macOS.

**2. "Every request from my machine to the API now comes back 502 from smolvm."**

The machine was started without the variable in its environment. Reproduced on both hosts by
stopping a working machine and starting it again with the variable unset:

```
smolvm credentials: credential unavailable [502]
```

and by contrast, unsetting it only for `machine exec` on a machine started with it did not produce
the 502. Start the machine with the variable set.

**3. "Prove the key cannot be sent anywhere but the API I named."**

`verify-containment.sh` shows the bound host's certificate is the machine's own CA and another
host's is its real one, and these refusals were measured on both hosts with the placeholder, none
of which forwarded anything:

```
?k=<placeholder> in the query            -> 403 placeholders are substituted in request headers only
-d k=<placeholder> in the body           -> 403 placeholders are substituted in request headers only
two headers carrying the placeholder      -> 403 a request may carry one placeholder
a forged SMOL_PLACEHOLDER_DEMO_...        -> 403 unknown placeholder
Cookie: a=<placeholder>                   -> 403 placeholders are not substituted in routing or framing headers
```

From v1.27.0 the body case reads `403 smolvm credentials: body substitution is disabled for this
binding`, and a binding created with `:body` substitutes into an `application/x-www-form-urlencoded`
body instead; that is read from the source, not run here.

Create-time rules, both hosts, on v1.22.2: `*.example.com` and an IP address are refused with
`must be an exact lowercase DNS name`, and a credential host outside the machine's `--allow-host`
with `is not reachable under the machine's network allow_hosts`. From v1.26.0 `*.example.com` is
accepted, and an IP address is refused with `must be a lowercase DNS name, optionally` followed by
the wildcard form.

## Re-verified on v1.29.0

Run 2026-10-15 PT against v1.29.0 from the published release, checksum checked, under a fresh
isolated `HOME` on macOS 27.0.1 arm64, the scripts twice, the second time from a fresh `HOME`, and
the checks beyond them once. Linux was not run, so the Linux stamp stays on its earlier release.

The preflight said `result=ready` with `tsi_stream_intercept=yes` once `SMOLVM_LIB_DIR` was set,
`create-credentialed.sh` `guest_sees=placeholder`, and `verify-containment.sh` gave every line `ok`
and `result=contained`. Bound to `httpbin.org`, the step 4 request to `https://httpbin.org/headers`
came back with `Authorization: Bearer <the value>`, a dummy value made for the run, so the value
reached the API. `--secret-env` printed `set`, and with the host variable unset the run stopped with
`resolve secret 'API_TOKEN': env_unset`.

## Re-verified on v1.22.2

Run 2026-10-03 PT against v1.22.2 from the published release, checksum checked, under an isolated
`HOME` on macOS 27.0.1 arm64, twice, the second time from a fresh `HOME`. Apart from `preflight.sh`
and `cleanup.sh`, this packet was not run on v1.22.2 on Lima `linux-kvm` (Ubuntu 24.04 aarch64),
where guests above 2048 MiB timed out on 2026-10-03.

macOS, bound to a local HTTPS server with a throwaway value: `result=contained` on all six
checks, the `Cookie` and forged placeholders refused with `403`, and the request carrying the
placeholder answered `502 smolvm credentials: upstream request failed`, because the server's
certificate was self-signed; the server received nothing. With the disk images left out of the
record search, `verify-containment.sh` and the rest of the procedure took 25 s.

## What was not run

- **The value arriving at an API, before v1.29.0.** Seeing it needs an HTTPS endpoint with a
  publicly trusted certificate that reports the header it received; on v1.29.0 on macOS arm64 one
  did ("Re-verified on v1.29.0"), and it was not observed on Linux. On v1.18.2 on macOS arm64 a
  substituted request to `example.com` was forwarded and answered `200`, which `example.com` gives
  whatever the header holds; on v1.22.2 on macOS arm64 the substituted request to a local server
  with a self-signed certificate was answered `502 smolvm credentials: upstream request failed` and
  reached nothing. Every check on the host side ran on macOS arm64 on both releases and on Linux
  aarch64 on v1.18.2.
- **File references and rotation in place**, which `docs/credential-substitution.md` describes;
  nothing here changed a value under a running machine except by the variable.
- **Credentials over the HTTP API**, branches and checkpoints carrying bindings, and portable
  restores on another host.
- **Linux x86_64 and Windows.**

## Related packets

- `throwaway-machine` for `--allow-host`, which a binding must fit. Give a machine a binding with
  the commands in this packet, which are the ones verified with it.
- `local-api` for the `credentials` field on a create body.
- `teardown` for the wider cleanup.
