Use promo code BETATEST1 for full access
seekrit
Docs/Fermyon Spin

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.

note

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.

ReferenceImportsFor
seekritdev:secrets-spinwasi:config/store@0.2.0-draft-2024-09-27Spin
seekritdev:secretswasi:config/store@0.2.0-draftwasmCloud 1.x
seekritdev:secrets (-rc1 tags)wasi:config/store@0.2.0-rc.1wasmCloud 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.

caution

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())
}
note

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