Security and auth
Your box holds the data, and Stackbone does not sit in front of it. Studio and the CLI open connections straight to the box address you registered, so no run, payload or credential passes through Stackbone's servers. Every caller proves itself at the box: your browser and the CLI with a five-minute identity token, the control plane with a signature. The credentials your code needs (workspace secrets, connector tokens, the model key) are encrypted in the box's own database and never leave it.
Your traffic does not pass through Stackbone
The control plane stores the box address you registered and hands it to Studio and to the CLI. Both then call your box themselves.
| What you do | Where the request goes |
|---|---|
| Open a run, read a trace, edit a prompt | Your browser to your box |
stackbone runs list, stackbone secrets set |
Your terminal to your box |
| Chat with your agent from your own service | Your service to your box |
| Invite a member, register a box, install | Your browser to the control plane |
| Push installation config down to a box | The control plane to your box |
Your box does not have to be reachable from the internet, only from the machines that use it: a browser inside your VPN and a CI runner are enough. A Stackbone outage also leaves your agent running, because the box serves its own traffic.
A self-hosted control plane forwards one call, the one that reveals a secret.
The hosted control plane at app.stackbone.ai forwards nothing.
How Studio and the CLI prove themselves to the box
Studio and the CLI present a short-lived identity token, and the box checks it against the control plane's public keys.
The control plane mints that token. When you open a box in Studio, or run a box
command such as stackbone runs list, it signs a token for your session with
its private key (EdDSA). It is valid for five minutes and names you, your
organization, the installation the box was registered under, and your role.
Your browser or the CLI sends it as Authorization: Bearer.
The box verifies it on the other end. It fetches the control plane's public
keys from /api/auth/jwks and caches them. Those keys rotate every seven days, and a
retired one stays published for thirty days after, so a rotation never cuts a
box off mid-request. With a valid signature in hand, the box checks that the
token names the organization and installation it was registered under, then
applies your role.
| The box answers | When |
|---|---|
401 bearer missing or malformed |
No Authorization header on a gated route |
invalid_signature |
The token was not signed by the control plane the box trusts |
invalid_token |
Expired, or missing the organization or installation claim |
invalid_token_tenant |
A valid token for a different organization or installation |
box_not_registered |
The box has never been linked, so it trusts nobody yet |
403 with a capability name |
Your role does not carry that action |
Those two tenant codes differ on purpose, so you can tell a token aimed at the wrong organization from a box nobody has linked yet.
By default a box trusts the hosted control plane at api.stackbone.ai. A box
that belongs to a self-hosted control plane learns which one to trust from
STACKBONE_CONTROL_PLANE_URL on the container. The notes under
stackbone link cover the warning link prints
when a box cannot verify tokens.
How the control plane proves itself to the box
The control plane holds no session with your box. It signs each call with a shared signing secret, and the box checks the signature.
The box mints that secret itself. On its first boot it writes a 32-byte secret
into its own database (the runtime_identity table) and prints it once in its
log at warning level. That secret is what
stackbone link or the
browser wizard asks you for. The
registration signs a challenge with it and the box confirms it can reproduce the
signature. It then tells the box, over the same proven secret, which
organization and which installation it answers for. Only after both steps does
the control plane write the deployment record. A box that cannot be told is not
registered at all, so a failure here leaves no record and no orphan box.
The signature travels in one header. stackbone-signature holds a timestamp
and one or more HMAC-SHA256 digests:
stackbone-signature: t=1755523200,v1=9f2c…The digest covers the timestamp, the HTTP method, the path with its query, and the body. The box recomputes it and refuses anything it cannot reproduce, plus anything more than five minutes old. A missing, malformed or empty signature fails closed.
Rotating the secret does not cut the box off. Register a new one and the control plane keeps the previous secret in a rotation slot, sending both digests for a while. The box accepts the request if either one matches, so a box that still holds the old secret keeps answering.
Set HMAC_SECRET on the container when your platform injects secrets from a
vault and you want to decide the value before the box starts. It wins over
whatever is stored. Mind who can read your container logs and your box's
database: the secret is in the first boot log, and it sits in the clear in the
box's own database so that an operator debugging a failed link can read it back.
The control plane keeps its own copy encrypted at rest.
The chat wires take a workspace API key
The OpenAI, Anthropic and AG-UI endpoints ask for a bearer credential
(Authorization: Bearer <key> or x-api-key: <key>), so the standard SDKs work
unchanged. That credential is a workspace API key, minted in Studio under
Settings → API keys by an owner or an admin and shown once. The box verifies it
on every call.
A key carries the exact agents and workflows it may call, so the credential you
paste into a third-party client reaches those and nothing else. It opens the
three chat wires plus POST /api/workflows/<name>/start and
POST /api/workflows/<name>/chat. Every other route refuses it, valid or not,
because the rest of the box belongs to a person in a seat.
An unknown, revoked or expired key answers 401, and a valid key asking for a
name it was not granted answers 403. Revocation lands on the next request: the
box re-reads the key's row every call and caches no decision. stackbone dev
authenticates nobody, so a local box still serves any credential.
API keys is the reference for the format, the permissions and the lifecycle. The protocol reference lists the error shapes.
Approval hooks and inbound triggers
A workflow that requestApproval() paused resumes
when someone posts a decision to its hook,
POST /api/workflows/hooks/:token/resume. That route is gated like every other
route on the box. The token in the URL names which parked hook to resume; it is
a resume key, not a credential, so a caller still has to prove itself with an
identity token. The box's own in-process resume is the one exception, and it
carries a token minted fresh on each boot that never leaves the container.
Nothing about that changes how you decide. The HITL Inbox and
stackbone hitl approve post to POST /api/approvals/:id/decide, which needs
the hitl:decide capability, and the box posts the resume itself. If you pass
your own token to requestApproval(), keep it unique across every run that
can be pending at once. Two live runs on one token make the pause fall back
instead of waiting.
Nothing calls into your box to start a workflow from outside. Inbound triggers poll the provider from inside the box, and a connector's OAuth callback comes back to the box's own address carrying a signed state, never a credential.
A self-host box takes one operator token
A deployment from
stackbone package --target self-host has no
Stackbone control plane behind it, so nothing mints identity tokens and nothing
publishes keys to verify them against. It authenticates one credential instead:
STUDIO_STANDALONE_TOKEN, the value that folder's .env mints.
stackbone package with no --target writes no such variable, which is what
leaves the identity gate armed on a box you register from app.stackbone.ai.
Both containers read the same line. The control plane checks it on the login and on the discovery call that tells the dashboard where the agent is. The agent compares the whole string in constant time on every gated route. When it matches, the agent treats the caller as the deployment's owner with every capability, since there is one person and one workspace. Both trim the value, so a trailing space in the file does not become two different secrets.
The two strategies are exclusive. An agent that finds STUDIO_STANDALONE_TOKEN
set turns the identity-token gate off and says so in its boot log, and one
without it keeps verifying control-plane tokens as before. That is deliberate: a
bearer is a bearer, so a box trying to honour both would refuse the tokens it
was meant to verify. The signature check the control plane uses is unaffected
and stays armed either way.
What that buys, and what it costs:
| Property | What it means for you |
|---|---|
| The token does not expire | Nothing rotates it for you, and nothing signs anyone out. Rotate it by editing .env and restarting both containers. |
| It carries no identity | Every action is recorded as the same operator, so a self-host audit trail says what happened and not who did it. |
| It is the whole gate | Anyone who has the string has the deployment. Treat it like a root password and keep the containers behind your own network boundary. |
Set the two containers up behind a proxy with Self-host behind a proxy, which also covers the CORS allowlist, the five streaming routes and the health checks.
A local box has no gate
stackbone dev runs the same server on your machine and arms neither check:
nothing on your laptop holds a signing secret or verifies identity tokens. The
exemption is deliberate, so the local loop needs no login round trip.
The tunnel stackbone dev opens so that Studio can reach it is a
*.tun.stackbone.ai address, granted by the control plane to your signed-in
CLI and gone when stackbone dev exits. Part of that address is stable for the
workspace, so a Studio link survives a restart. Treat the address as something
to keep to yourself while it lives.
Credentials your code never holds
Three kinds of credential, kept apart on purpose: the secrets your code reads,
the accounts your connectors act as, and the key that reaches your model
provider. All three live in the box, encrypted under STACKBONE_SECRET_KEY,
the one key you supply to the container.
You write a secret once. The list never shows you a value.
Workspace secrets
Create them in Studio under Settings › Secrets or with
stackbone secrets set NAME, which reads the value from stdin so it never lands
in your shell history. The box encrypts every value with libsodium's
crypto_secretbox (XSalsa20-Poly1305, a fresh nonce per write) and stores the
envelope in its own Postgres.
The list shows a mask. Reveal is a separate action, and what it gives you
depends on which control plane you use. On app.stackbone.ai it stops at a
re-authentication step that is not live yet, so rotate a secret you have lost
rather than looking for it. A local stackbone dev box, and a box behind a
self-hosted control plane, return the plaintext to the dialog. The CLI has no
reveal on any of them: list, set and remove. Studio's DB Explorer runs as
a read-only database role that cannot select from
the secrets table. Names beginning STACKBONE_, and the platform's own
(DATABASE_URL, MODEL_PROVIDER_API_KEY, AWS_*, S3_* and a few more), are
reserved so a workspace secret cannot shadow them.
Your code reads a secret with
stackbone.secrets.get(name): a query on the
box's own database and a local decrypt. That query makes no round trip to
Stackbone and caches nothing, so a rotation lands on the next read. Pass the
value to the client that needs it and never log it.
Connector tokens, which Stackbone never receives
Stackbone Connect runs a broker inside your box, and every provider credential stays there.
You type an integration's client secret once, when you register it. The broker
encrypts that value and never reads it back to the screen. Connecting an account
runs the OAuth exchange between your box and the provider: the callback returns
to your box's own address (STACKBONE_PUBLIC_URL) carrying a signed state, and
the control plane sees neither the code nor the token.
Your box encrypts the access and refresh tokens before they reach its Postgres,
under a key derived from STACKBONE_SECRET_KEY, and writes no plaintext to a
column. It renews them itself too: on demand when a call finds a token close to
expiry, and on a background sweep every fifteen minutes, so a long-idle
connection is still live when you need it.
Your code never touches the token.
callConnector() hands the broker the
operation and gets back only that operation's output. The lower-level
connect(connectorId) adapter exists for connection runtimes that must attach a
credential themselves, and it hands back a bearer for the current principal
only.
You enter the model key once. Studio never shows it again.
The model provider key
Set it under Settings › Model provider, or with MODEL_PROVIDER_API_KEY and
MODEL_PROVIDER_BASE_URL on the container, which win over the stored value and
turn the screen read-only. The box stores it as a system secret in the same
encrypted table, tests the connection server-side so the key never returns to the
browser, and reads it back at boot before your bundle loads. It goes to your
provider and nowhere else: Stackbone does not hold your model key and does not
see your model bill.
The two keys the container runs on are STACKBONE_SECRET_KEY, which encrypts
everything above and cannot live in the database it protects, and the signing
secret from How the control plane proves itself to the
box. The
secrets FAQ covers what changing
each one does.
What Stackbone holds, and what stays in your box
| Where | What lives there |
|---|---|
The control plane (app.stackbone.ai) |
Your account (email, name), sessions, organizations, members and roles, invitations, agent templates, installations with the configuration you typed when installing, the deployment record (box address, image tag, signing secret encrypted at rest), tunnel grants for stackbone dev, and the key pair that signs identity tokens. |
| Your box | Runs, sessions, traces, approvals and their decisions, prompts and their versions, dynamic config, guardrails, eval cases, suites and results, RAG collections, recurring job state, workspace secrets, connector credentials, the model provider key, and the files in your bucket. |
| Your browser and terminal | The session cookie, and ~/.stackbone/credentials.json for the CLI. |
No run, payload, secret or connector token crosses the control plane. Studio reads them from the box, in your browser, with the identity token. Your browser reaches object storage through signed URLs that expire after an hour, and the Studio storage browser cannot issue one that lives longer. See Where does my data live? for the databases and the bucket.
What you set on a deployed box
The container reference lists every variable. These are the ones that decide who gets in.
| Setting | Why it matters |
|---|---|
An https:// address |
Your browser calls the box directly, and the registration form refuses a plain http:// address. The box does not have to be reachable from the internet, only from the machines that use it. |
STACKBONE_SECRET_KEY |
Encrypts every secret, connector credential and the model key. openssl rand -base64 32. Keep it stable: changing it makes everything already stored unreadable, with no recovery. |
HMAC_SECRET |
The signing secret. Both stackbone package folders mint one into the .env. A box started without it mints its own on first boot and prints it in the log once. Anyone holding it can sign requests to the box as the control plane. |
STACKBONE_CONTROL_PLANE_URL |
Only for a box that belongs to a self-hosted control plane. It names whose identity tokens the box trusts. Left unset, the box trusts api.stackbone.ai. |
STACKBONE_PUBLIC_URL |
The box's own public origin. A connector's OAuth callback returns to it, and on a self-host deployment the dashboard is handed it at login and calls the box with it. Absolute http(s), no path, no query; the box refuses anything else at boot. |
STUDIO_STANDALONE_TOKEN |
Self-host only. The one credential guarding the whole deployment, on both containers. Setting it turns the identity-token gate off. See A self-host box takes one operator token. |
| Network exposure | The chat wires verify a workspace API key, so the clients you mint keys for can reach the box from outside your network. Everything else on the box needs an operator identity. Put it behind your own gateway when you want a second layer. |
| Who reads the logs and the database | A box that had to mint its own signing secret prints it once, on that boot, and keeps it in the clear in its own runtime_identity table. A folder from stackbone package supplies the value instead, so nothing is printed. Workspace secrets and connector tokens stay encrypted, and the DB Explorer role cannot read them. |
Not there yet
Things a security questionnaire may ask about that a box does not do today:
- Reading a stored secret back from
app.stackbone.aior from the CLI. Rotate instead. - Key rotation for
STACKBONE_SECRET_KEY. Envelopes carry a version, but only one is decoded today. - Deleting a run, or a subject's data on request. The log window expires on its own; runs, steps and sessions stay until you remove the rows yourself. See Where the evidence lives.
Two things people expect on that list are built, so they are not on it. Every log line the box stores or exports crosses a masking pass first: the values of your own workspace secrets are masked exactly, and bearer tokens, key prefixes and credentials inside URLs are masked by pattern. Treat that as a safety net rather than a licence to print a secret. See What happens to a line. The control plane also records who invited, promoted, demoted or removed a member, and when. A box records each approval decision the same way.
Read more
- Connect your box: registering a deployed container from the browser, and where the signing secret comes from.
- Self-host behind a proxy: the two containers, the operator token on both, and the CORS allowlist.
stackbone linkand what you set on the deployed container.stackbone.secrets: the read surface and its error codes.- Integrations and Connections: the broker, and calling a connector from your code.
- How are secrets handled? and Where does my data live?.
- Governance: what Studio shows about a running box, and who can decide what.
- Install and sign in: how a person signs in to the control plane, and where the CLI keeps its session.