Use promo code BETATEST1 for full access
seekrit
Docs/WebAssembly components

WebAssembly components

WebAssembly breaks the usual ways of getting a secret into a program. A guest cannot spawn a process, so there is nothing for seekrit run to wrap. It never opens a socket — outbound requests go through the host — so there is nothing for the proxy to intercept. And the one mechanism that does survive, an environment variable set by the host, puts the plaintext in the host's configuration, which is the thing you were trying to avoid.

The seekrit secrets component is the WASM-native answer. It is a WASI 0.2 component that holds the service token, calls seekrit once, and decrypts inside its own linear memory. Your guest imports an interface and asks for a name:

use seekrit::secrets::store;

let api_key = store::get("STRIPE_API_KEY")?.expect("STRIPE_API_KEY is in scope");

No token in your code, no token in your bundle, and no plaintext anywhere in the host.

note

This is the tightest boundary seekrit offers. seekrit run puts values in a child process's environment, where anything that can read /proc can read them. Here they exist only inside a sandbox the host cannot reach into, and only the guest that imported the interface can ask for one.

The 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();
}

The @0.1.0 there is the interface version, and it moves only when the interface itself changes — it is not the version of the artifact you pull, which has its own release train. Compose against seekrit:secrets/store@0.1.0 whatever release you are on.

get returns ok(none) for a name that is simply not in scope, which is different from an error — your guest can tell "no such secret" from "I could not ask", and fail closed on the second without treating the first as an outage.

list-names returns names without materializing any value. Names are the half of a secret seekrit treats as loggable, so this is the call for a health check or a startup assertion that a required variable exists.

forget drops the decrypted set; the next call resolves again. Use it after a rotation, or to shorten the window a value sits in memory in a long-lived instance.

Getting it

The component ships through two channels, because its two audiences reach for different things. Same bytes either way.

As an OCI artifact, which is what Spin, wasmCloud, and wkg take. The same component is published to both registries — use whichever your tooling already authenticates to:

ghcr.io/seekritdev/secrets:0.3.0
docker.io/seekritdev/secrets:0.3.0

If you pull from CI, prefer ghcr.io — Docker Hub rate-limits anonymous pulls per IP, and a shared CI runner will hit that ceiling long before you do.

As a plain file, which is what wac and a Dockerfile want:

curl -fsSLO https://wasm.seekrit.dev/latest/seekrit-secrets.wasm

Everything is versioned (:0.3.0 / /v0.2.0/) and carries a latest. The rc.1 and environment-only builds are the same reference with -rc1 and -env: ghcr.io/seekritdev/secrets:latest-rc1, or https://wasm.seekrit.dev/latest/seekrit-secrets-env.wasm. The Spin build is a package of its own — ghcr.io/seekritdev/secrets-spin:0.3.0, or https://wasm.seekrit.dev/latest/seekrit-secrets-spin.wasm — so that a Spin dependency can name it with an ordinary version constraint.

The OCI artifact carries the component's own imports and exports in its metadata, so the registry page shows that seekrit:secrets/store is the only thing it exports — and which WASI version it needs.

Composing it into your app

Link it into your own component with wac before you deploy:

wac plug my-app.wasm --plug seekrit-secrets.wasm -o composed.wasm

composed.wasm is what you deploy. It exports whatever your app exported; the seekrit import is satisfied internally and is no longer visible from outside.

Four builds, and picking the wrong one fails early

WASI hosts differ in what they provide, and an import a host cannot satisfy makes a component fail to load rather than fail at a call site — with an error that points at WASI rather than at seekrit. So there are four artifacts:

FileConfiguration arrives viaUse on
seekrit-secrets.wasmwasi:config/store@0.2.0-draft, falling back to the environmentwasmCloud 1.x
seekrit-secrets-spin.wasmwasi:config/store@0.2.0-draft-2024-09-27, falling back to the environmentSpin 3.0+
seekrit-secrets-rc1.wasmwasi:config/store@0.2.0-rc.1, falling back to the environmentwasmCloud wash-runtime
seekrit-secrets-env.wasmthe environment onlywasmtime, Wasmer, NGINX Unit, anything else

The first three are the same interface — same functions, same types — under three package versions, because hosts pinned different snapshots of the same WASI That is not a detail you can paper over: wasmtime gives pre-release versions no semver-compatible matching, so those three are unrelated names to the linker and none stands in for another.

If your app will not start with a message naming a wasi:config interface, read the version in that message and pick the matching build. If the message names wasi:config with no version at all, your host does not provide it: use -env.

Each artifact also declares a WASI version, recorded in the release notes. On a wasmtime-based host (Spin, wasmCloud, wasmtime itself) any 0.2.x release links it, since wasmtime matches 0.2 imports across the whole 0.2 track. A host that matches import names exactly needs that version or newer.

Configuration

Every key is accepted in three spellings — SEEKRIT_TOKEN, seekrit_token, and seekrit-token — because hosts disagree about what a configuration key may contain. Set whichever one your runtime allows.

KeyRequiredMeaning
SEEKRIT_TOKENyesA service token.
SEEKRIT_API_URLnoDefaults to https://api.seekrit.dev.
SEEKRIT_WITHnogroup:environment composition overrides, comma-separated.
SEEKRIT_BRANCHnoA branch of the token's environment.
SEEKRIT_INTERPOLATEno0 leaves secret references as literal text.
caution

Never compile the token into the .wasm. Components are content-addressed and pushed to registries, so a token baked into one is a published credential — the same mistake as shipping a token in a browser bundle. It comes from the host, at load time, or not at all.

Fermyon Spin

Spin has its own page: Fermyon Spin. It composes for you, so you declare the component as a dependency rather than running wac:

[component.app.dependencies]
"seekrit:secrets/store" = { version = "0.3.0", package = "seekritdev:secrets-spin", registry = "ghcr.io" }

Use the secrets-spin package there, not secrets — Spin pins a different snapshot of the wasi:config draft, and the two do not substitute for each other. Three other manifest settings are load-bearing; the Spin page covers them.

wasmCloud

wasmCloud can use this component, but it is the one host where you probably want the other integration instead. It publishes a pluggable secrets API, and seekrit ships a backend for it — so wash and wadm know about your secrets, and decryption still happens on your own lattice rather than in the seekrit API. See the wasmCloud guide.

To use the component anyway — when you want the value to exist only inside the guest's sandbox, and are content for the platform not to know about it:

Reference ghcr.io/seekritdev/secrets:0.3.0 in your application manifest, and link the component to a wasi:config provider and an outgoing-HTTP provider. seekrit_token goes in the link configuration, alongside whatever else that component needs.

On wash-runtime, which registers wasi:config/store@0.3.0 rather than the 0.3.0 that wasmCloud 1.x registers, use ghcr.io/seekritdev/secrets:0.3.0 instead.

wasmtime, Wasmer, NGINX Unit

Use seekrit-secrets-env.wasm and pass the token as an environment variable:

wasmtime serve -S cli -S http --env SEEKRIT_TOKEN="$SEEKRIT_TOKEN" composed.wasm

One resolve per instance

The decrypted set is cached for the lifetime of a component instance. On a host that instantiates per request — Spin does — that means one resolve per request, and nothing survives the request. On a long-lived instance it means one resolve total, however many times you call get.

Failures are not cached. An API that was briefly unreachable is retried on the next call rather than remembered.

What it does not do

It hands your guest a plaintext; where the guest sends it is the guest's business. If you need the value never to be in the program at all — a placeholder substituted at the network edge, against an allowlist — that is the credential broker, and it is a stronger boundary than any in-process shim.

There is no write path. Like every language SDK, this is read-only.