Fermyon Spin
A Spin component can read secrets without holding any. You declare the
seekrit secrets component as a dependency in spin.toml; Spin composes it
with your component at load time, and your code calls an interface:
use seekrit::secrets::store;
let api_key = store::get("STRIPE_API_KEY")?.expect("STRIPE_API_KEY is in scope");
The seekrit component holds the service token, makes one call to the API, and decrypts inside its own linear memory. Your component never sees the token, and no plaintext exists anywhere in the Spin host.
This page is the Spin-specific setup. The component itself, its interface, and the other runtimes it works on are covered in WebAssembly components.
Setup
Mint a read-only token bound to one environment, and export it the way Spin reads application variables:
seekrit token create --name spin-checkout --app checkout --env production
export SPIN_VARIABLE_SEEKRIT_TOKEN="skt_..."
Add the dependency to spin.toml:
[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" }
Declare the variable at the top of the manifest so Spin refuses to start without it, rather than failing on the first request:
[variables]
seekrit_token = { required = true }
Then spin build && spin up. A full handler is in
A complete example below.
This setup is verified against Spin 4.1.0. The dependency mechanism and Spin's
wasi:config version have both been in place since Spin 3.0.
Four things that must be right
Each of these is a start-up or first-request failure whose message does not point at seekrit.
The package is secrets-spin
seekrit publishes the same component several times, because hosts disagree about how to spell one interface version.
| Reference | Imports | For |
|---|---|---|
seekritdev:secrets-spin | wasi:config/store@0.2.0-draft-2024-09-27 | Spin |
seekritdev:secrets | wasi:config/store@0.2.0-draft | wasmCloud 1.x |
seekritdev:secrets (-rc1 tags) | wasi:config/store@0.2.0-rc.1 | wasmCloud wash-runtime |
The interface is identical in all of them — same functions, same types. Only
the package version differs, and wasmtime gives pre-release versions no
semver-compatible matching, so those three are unrelated names to the linker.
Using the wrong one stops spin up 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
dependencies_inherit_configuration must be on
Spin gives a dependency none of the parent component's permissions by default. Without this flag the seekrit component cannot make its outbound call and cannot read the variable holding its token:
[component.app]
dependencies_inherit_configuration = true
The flag is all-or-nothing across a component's dependencies, so set it only when you trust all of them. Without it the app starts normally and the first request comes back:
not configured: no service token: set SEEKRIT_TOKEN
(or the host's `seekrit_token`) to an skt_ token
The outbound host must be allowed
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.
The token goes in variables, not environment
The seekrit component reads wasi:config first and the environment second.
On Spin, use wasi:config — which is what [component.app.variables] feeds.
component.app.environment looks like an alternative and is not: Spin's
manifest expressions are only supported in a short list of fields, and
environment is not one of them. The only way to set a token there is to write
it literally into a file you commit.
Never put a skt_ token in spin.toml, and never compile one into a
component. Spin applications are pushed to OCI registries; a token in either
place is a published credential.
Where the token comes from
One credential reaches Spin, and everything else is derived from it. In development that is an environment variable:
export SPIN_VARIABLE_SEEKRIT_TOKEN="skt_..."
In a deployment it is whatever your platform already uses to hand a process one
value — a Kubernetes secret, a CI secret, your host's credential store. If your
platform has a Spin variables provider, declaring it in
runtime-config.toml points seekrit_token at it and changes nothing in the
component or the manifest.
Whichever you pick, it carries one short-lived, read-only token scoped to one environment — not the secrets themselves. That is the point of the setup: the thing your platform has to protect is a credential you can revoke, and the values behind it are never in its custody at all.
Rotation and caching
The seekrit component memoizes its decrypted set per component instance. Spin instantiates a component per request, so in a Spin app the cache holds for one request and nothing survives it: a busy app makes one resolve per request, and a rotated secret is picked up on the next one.
Failures are never cached. An API that was briefly unreachable is retried on the next request rather than remembered.
A complete example
Add a wit/world.wit naming what your component imports:
package myapp:handler;
world app {
import seekrit:secrets/store@0.1.0;
}
and generate bindings from it:
use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_service;
spin_sdk::wit_bindgen::generate!({
path: "wit",
world: "app",
runtime_path: "::spin_sdk::wit_bindgen::rt",
generate_all,
});
use seekrit::secrets::store;
#[http_service]
async fn handle(_req: Request) -> anyhow::Result<impl IntoResponse> {
let key = match store::get("STRIPE_API_KEY") {
Ok(Some(v)) => v,
// `ok(none)` means the token resolved and this name is not in scope —
// a configuration answer, not an outage.
Ok(None) => return Ok(text(500, "STRIPE_API_KEY is not in scope")),
Err(e) => return Ok(text(503, &format!("{e:?}"))),
};
charge(&key)?;
Ok(text(200, "charged"))
}
fn text(status: u16, body: &str) -> impl IntoResponse {
Response::builder()
.status(status)
.header("content-type", "text/plain")
.body(body.to_string())
}
spin_sdk::dependencies!() is the shorter way to get these bindings, and it
does not work for this component. 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. This component imports wasi:config/store and exports
seekrit:secrets/store — both called store — so the match lands on the wrong
one and you get cannot find module or crate seekrit. Generating from your own
wit/ sidesteps it. Composition is unaffected: Spin composes against the real
component imports, not that file.
store::Error is a WIT variant, not a Rust std::error::Error, so match on it
rather than reaching for ?. The four cases map to the four setup problems
above: not-configured (no token reached the component), denied (the API
said no), unavailable (could not reach the API — usually the allowlist), and
decrypt (the response arrived and would not decrypt). None of them carries a
value, a token, or ciphertext, so all four are safe to log.
The zero-effort alternative
If you do not need decryption inside the sandbox, seekrit run works on Spin
today with no component at all, because Spin reads SPIN_VARIABLE_* from its
own environment:
seekrit run --app checkout --env production -- spin up
Every secret granted to the token becomes a SPIN_VARIABLE_* value that Spin's
environment provider picks up. The difference: the plaintext exists in the Spin
host process, which is the thing the component approach avoids. Use this for
local development and the component for anything running untrusted code.
Next steps
- WebAssembly components — the interface, the other runtimes, and how the component is published
- Service tokens — scoping and rotating the credential Spin passes in
- Secret references —
${...}expansion, which the component performs before your code sees a value