Use promo code BETATEST1 for full access
seekrit
← all posts

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.