Run Claude Code inside a VM on a Claude
subscription, while the long-lived token from claude setup-token stays in your
trusted controller. An HTTP egress rule injects it
as an Authorization: Bearer header on the VM’s requests to
api.anthropic.com. The VM carries only a placeholder, so the credential never
lands on disk or in a snapshot, and you rotate it by replacing the rule instead
of rebuilding.
This is the third way to authenticate the CLI. The
API-key and interactive-login options
put a live credential in the invoking process or bake it into the snapshot; this
one keeps the subscription token at the edge. For direct Messages API calls with
a Console key — a different credential and the x-api-key header — see
Select Anthropic Models and Inject Authentication
instead.
Mint A Long-Lived Token
On your own machine, with Claude Code installed and signed in to the Claude account whose subscription should pay for the runs:
claude setup-token
This prints a long-lived OAuth token (the value behind CLAUDE_CODE_OAUTH_TOKEN)
tied to your Claude plan — distinct from a Console API key. Treat it as a secret:
it authenticates as you and draws on your plan’s usage limits. Hold it in the
controller, not the VM.
Prepare The Controller And Snapshot
Run the controller outside the sandbox. Install the SDK with
npm install freestyle@latest and supply these environment variables there:
FREESTYLE_API_KEY: your Freestyle API key.FREESTYLE_SNAPSHOT_ID: a snapshot with Claude Code installed. Build one with the Claude Code sandbox guide — install the CLI, but do not sign in with/login, so the snapshot stays free of any credential.CLAUDE_CODE_OAUTH_TOKEN: the token from the previous step, to inject.
Keep the credential out of the snapshot and the VM environment. Do not bake a
/login session, and leave ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, and any
apiKeyHelper unset in the VM — each outranks CLAUDE_CODE_OAUTH_TOKEN, and
a set ANTHROPIC_API_KEY makes the CLI send x-api-key instead of the Bearer
header this rule injects.
Inject The Token
Save this as claude-code-controller.ts outside the VM:
import { Freestyle, type CreateTlsRuleOptions } from "freestyle";
function requiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const freestyle = new Freestyle();
const { vmId } = await freestyle.vms.create({
snapshotId: requiredEnv("FREESTYLE_SNAPSHOT_ID"),
// Claude Code reaches several Anthropic hosts; allow egress, or narrow it to
// the hosts the CLI uses. The rule below is what injects the credential.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
});
function claudeCodeRule(token: string): CreateTlsRuleOptions {
return {
action: "allow",
domain: "api.anthropic.com",
source: { vmId },
destination: { public: true },
// No `match`: Claude Code calls more than POST /v1/messages on this host
// (model listing, feature flags, telemetry). A path match would leave the
// other requests without the injected credential.
transform: [{ headers: { authorization: `Bearer ${token}` } }],
};
}
const rule = await freestyle.tls.rules.create(
claudeCodeRule(requiredEnv("CLAUDE_CODE_OAUTH_TOKEN")),
);
console.log({ vmId, ruleId: rule.id }); // Retain these IDs in your controller.
The edge overwrites the guest’s Authorization header and sends the request to
Anthropic over a separate, certificate-verified HTTPS connection. Header secrets
are sealed and read back as "***". Freestyle overwrites named headers; it does
not strip other authentication headers, so keep the VM from sending its own.
The same rule from the Freestyle CLI:
freestyle tls create \
--domain api.anthropic.com \
--from vm="$VM_ID" \
--to public \
--header "authorization=Bearer $CLAUDE_CODE_OAUTH_TOKEN"
Run Claude Code In The VM
Give the VM a placeholder CLAUDE_CODE_OAUTH_TOKEN. It must be non-empty — the
CLI sends it as the Bearer credential, and the edge replaces it with the real
one — but it is never the live secret.
const { vm } = await freestyle.vms.create({
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
slug: "claude-code-sandbox",
snapshotId: requiredEnv("FREESTYLE_SNAPSHOT_ID"),
idleTimeoutSeconds: null,
});
// create the rule for this vmId as above, then:
const run = await vm.exec({
command:
`CLAUDE_CODE_OAUTH_TOKEN="placeholder-replaced-at-the-edge" ` +
`~/.local/bin/claude -p "Write a haiku about sandboxes"`,
timeoutMs: 120_000,
});
console.log(run.stdout);
Claude Code’s standalone binary verifies TLS against the system trust store,
which already includes Freestyle’s CA once the rule exists, so you do not
need NODE_EXTRA_CA_CERTS for the CLI. The whole run, via the Freestyle CLI:
freestyle vm exec "$VM_ID" -- \
env CLAUDE_CODE_OAUTH_TOKEN="placeholder-replaced-at-the-edge" \
claude -p "Write a haiku about sandboxes"
Verify A Request
A valid token authenticates and the prompt returns. A wrong value comes back straight from Anthropic, which proves the edge delivered the header and only the value was off:
# Inside the VM — the edge injects the Authorization header.
curl -sS -o /dev/null -w "%{http_code}\n" https://api.anthropic.com/v1/models \
-H "anthropic-version: 2023-06-01"
# 200 with a live token; 401 {"type":"authentication_error",
# "message":"OAuth access token is invalid."} with a bad one.
To confirm the edge is terminating TLS, check the certificate the VM sees — its
issuer is Freestyle TLS Egress CA and its subject is api.anthropic.com:
echo | openssl s_client -connect api.anthropic.com:443 \
-servername api.anthropic.com 2>/dev/null | openssl x509 -noout -issuer -subject
Rotate Or Revoke
Mint a fresh token with claude setup-token, then replace the rule in place with
the saved ruleId. update restates the whole rule:
await freestyle.tls.rules.update(ruleId, claudeCodeRule(requiredEnv("CLAUDE_CODE_OAUTH_TOKEN")));
Or from the CLI, restating domain, source, and destination:
freestyle tls update "$RULE_ID" \
--domain api.anthropic.com --from vm="$VM_ID" --to public \
--header "authorization=Bearer $CLAUDE_CODE_OAUTH_TOKEN"
Cached decisions refresh in about five seconds; requests already in flight keep the old value. Verify a fresh request before retiring the previous token. Delete the rule when the VM no longer needs the grant — the VM is then left with only its placeholder:
await freestyle.tls.rules.delete(ruleId);
To invalidate the credential itself, revoke it from your Claude account’s device and session settings; logging out of all devices invalidates long-lived tokens. Freestyle does not renew an expiring token for you.
Scope And Caveats
The grant is available to every process in the VM; it does not identify a particular agent or executable. Keep the VM firewall restricted to the hosts Claude Code needs and review other TLS grants. The token authenticates as your Claude account against your plan’s limits — use your own, and follow Anthropic’s terms for subscription credentials.
The rule injects on all of the VM’s api.anthropic.com traffic. If the same
VM also runs a client that should authenticate with a Console x-api-key, that
is a separate credential and header; scope it with its own rule per
Select Anthropic Models and Inject Authentication.
If an invocation reaches Anthropic through Node rather than the standalone binary
— for example the Claude Agent SDK bridge
— and raises a certificate error, point
NODE_EXTRA_CA_CERTS
at /etc/ssl/certs/ca-certificates.crt, which carries Freestyle’s CA. For a
401, confirm no higher-precedence credential is set in the VM, that the rule’s
domain and source match the VM, and that the token is still valid.