Managing secrets in Spin applications
Frameworks
A Spin component can read secrets without holding any. Declare a secrets component as a dependency, and Spin composes it with yours at load time:
[component.app.dependencies]
"seekrit:secrets/store" = { version = "0.3.0", package = "seekritdev:secrets-spin", registry = "ghcr.io" }
let api_key = store::get("STRIPE_API_KEY");
Your component never sees a token. The dependency holds it, calls the API once, and decrypts inside its own linear memory.
This post covers that setup, the five things about it that fail in ways which do not point at the cause, and the one case where you would not bother.
Why not just use a Spin variable
Spin already has a variables system, and it is a good one: [variables]
declares them, a component reads them through wasi:config/store, and
runtime-config.toml decides where the values come from. That is more than
most runtimes offer, and for plenty of apps it is the whole answer.
What it settles is delivery. What it does not settle is custody. Whatever provider you configure, it hands the value to Spin, and Spin hands it to your component — so the plaintext is in the host process either way, and in the manifest's configured provider before that. If your Spin app runs code you did not write — a plugin, a generated handler, an agent's tool call — the value is in the same address space as that code.
The setup below changes custody. The value is fetched as ciphertext, decrypted inside a Wasm sandbox, and handed to exactly one component that asked for it by name. Spin never sees it, and neither does anything you did not compose in.
Resolving inside the sandbox
The seekrit secrets component is a WASI component that exports one interface:
package seekrit:secrets@0.1.0;
interface store {
variant error { not-configured(string), denied(string), unavailable(string), decrypt(string) }
get: func(name: string) -> result<option<string>, error>;
get-all: func() -> result<list<tuple<string, string>>, error>;
list-names: func() -> result<list<string>, error>;
forget: func();
}
Spin passes it a service token through wasi:config. It calls
GET /v1/resolve, gets ciphertext and wrapped keys back, and decrypts in its
own memory. Your component asks it for a name.
[component.app]
source = "target/wasm32-wasip2/release/app.wasm"
allowed_outbound_hosts = ["https://api.seekrit.dev"]
dependencies_inherit_configuration = true
[component.app.variables]
seekrit_token = "{{ seekrit_token }}"
[component.app.dependencies]
"seekrit:secrets/store" = { version = "0.3.0", package = "seekritdev:secrets-spin", registry = "ghcr.io" }
What changes: the value exists inside a sandbox, held by a component whose
whole public surface is four functions. There is no environment variable to
read, no file to open, and no token anywhere for code in your component to
find. What does not change: once get returns, the plaintext is in your
component's memory, and where it goes from there is your code's business.
The five things that break this
Each one fails in a way that does not point at the cause.
The package name
seekrit publishes the same component under several references, and on Spin you
need seekritdev:secrets-spin. Use seekritdev:secrets and spin up stops before it serves anything:
Error: component imports instance `wasi:config/store@0.2.0-draft`,
but a matching implementation was not found in the linker
Caused by:
0: instance export `get` has the wrong type
1: function implementation is missing
The two builds import the same interface — same functions, same types, byte
identical WIT — under two package versions. wasi:config is still a draft, and
hosts pinned different snapshots of it. Spin registers
wasi:config/store@0.2.0-draft-2024-09-27. wasmCloud 1.x registers
wasi:config/store@0.2.0-draft. wasmCloud's newer runtime registers
wasi:config/store@0.2.0-rc.1.
Those are three unrelated names as far as the linker is concerned. wasmtime
resolves wasi:cli/environment@0.2.9 against a host offering @0.2.6, because
patch versions on a stable track are one compatibility bucket. It refuses to do
the same across pre-releases, and every one of those three versions is a
pre-release. From wasmtime's own matcher:
If there's a prerelease then don't consider that compatible with any other version number.
So there is no single artifact that works everywhere, and "close enough" is not close at all. seekrit publishes one build per spelling; match it to the host.
dependencies_inherit_configuration
Spin gives a dependency none of your component's permissions by default. It is a sensible default and it means the secrets component cannot make its outbound call or read its own token until you say so:
[component.app]
dependencies_inherit_configuration = true
It is all-or-nothing across a component's dependencies, so read the list before
setting it. Leave it off and the app starts fine; the first request returns
not configured: no service token, which reads like a missing variable rather
than a missing permission.
The outbound allowlist
Spin's outbound HTTP is default-deny. Without https://api.seekrit.dev in
allowed_outbound_hosts, the component returns unavailable and names the
allowlist in the message. This one is the easy failure — it says what to fix.
environment is not a substitute for variables
[component.app.environment] looks like the simpler place to put the token.
Spin's manifest expressions are supported in allowed_outbound_hosts and the
Redis trigger fields and nowhere else, so environment can only hold a literal
string. Putting a token there means committing it.
One more, if you write Rust
spin_sdk::dependencies!() is the documented way to get bindings for a
dependency, and it does not work here. It generates from the root world of
the spin-dependencies.wit that spin build writes, and Spin selects that
world's interface by bare name. The seekrit component imports
wasi:config/store and exports seekrit:secrets/store. Both are called
store, the match lands on the wrong one, and you get:
error[E0433]: cannot find module or crate `seekrit` in this scope
Write your own wit/world.wit with the import and call
spin_sdk::wit_bindgen::generate! on it instead. Composition is unaffected —
Spin composes against the real component imports, not that generated file — so
this costs one small WIT file and nothing else.
When not to bother
If the Spin host holding the plaintext is not a problem for you — your code end to end, no plugins, no generated handlers — there is a one-line version that needs no component and no manifest change:
seekrit run --app checkout --env production -- spin up
Spin's environment provider reads SPIN_VARIABLE_*, so every secret granted to the
token arrives as a Spin variable and your component reads it the way it already
does. Nothing is composed, nothing is imported, no .env file exists on disk.
The trade is exactly the custody point above: the values are in the Spin process's environment, so anything that can read that process can read them. Use it for local development, and the component for an app that runs code you did not write.
Either way the secrets live in one place, encrypted, with one credential to rotate — that part is not what the choice is about.
Where to go next
The full Spin setup, including where the token comes from in a deployment and what each error variant means, is at /docs/guides/spin. The component itself and the other runtimes it runs on are at /docs/guides/wasm.