Secrets in WASM Edge Functions: WASI Keyvalue, Vault Agent, and Capability-Based Secret Access

Secrets in WASM Edge Functions: WASI Keyvalue, Vault Agent, and Capability-Based Secret Access

The Problem

Edge functions present a secrets management problem that does not exist in the same form for traditional server deployments. A WASM binary is a portable artifact — it gets distributed to CDN edge nodes, potentially served to browsers, cached in OCI registries, and executed across dozens of geographic locations simultaneously. Traditional secrets management assumes you control the execution environment: you deploy code to a server you own, and you inject secrets into that server’s environment. At the edge, you do not fully control the environment, and the boundary between “my code” and “the runtime’s infrastructure” is deliberately thin.

Three naive approaches all fail in different ways:

Bundle secrets in the WASM binary. This is the most obvious failure mode. A developer hard-codes let api_key = "sk_live_abc123" in their Rust source, compiles to WASM, and ships the binary. Because WASM is a bytecode format that preserves string literals in its data section, extraction is trivial:

strings handler.wasm | grep -iE "(sk_live|api_key|secret|token|password)"

The binary is often public — distributed to edge nodes, uploaded to an OCI registry, or literally served as a download from a CDN. The string is visible to anyone who runs wasm-tools dump on the binary. In 2025, Semgrep Security scanned public WASM artifacts on npm and found Stripe test keys, Twilio auth tokens, and GitHub PATs in production WASM modules uploaded by well-meaning developers who assumed bytecode was opaque.

Pass secrets as environment variables at WASI startup. WASM modules targeting WASI Preview 1 can call std::env::var("STRIPE_API_KEY") just like a native process. The problem is that the runtime must supply those environment variables at module instantiation, which means they appear in:

  • The process environment of the runtime itself (/proc/<pid>/environ readable by co-located processes)
  • Runtime startup logs (wasmtime run --env STRIPE_API_KEY=sk_live_xxx logs the full command)
  • Container orchestration logs that capture the pod spec or the runtime invocation command
  • Long-lived runtime processes where the variable persists indefinitely across thousands of requests

In a multi-tenant edge runtime — where one runtime process handles requests from multiple WASM modules — environment variables from one tenant are potentially visible to another through shared runtime state. Spin’s early documentation actually recommended this pattern before the variables API existed. Teams who followed that guidance have environment-injected secrets in deployed modules today.

Fetch secrets from Vault or AWS Secrets Manager at request time. This sounds architecturally correct but creates two problems. First, it adds 50–200ms of latency per cold path — catastrophic for an edge function whose entire value proposition is sub-10ms response time. Second, it requires credentials to authenticate to Vault or AWS SM, and those credentials have to come from somewhere — you are back to the same problem, one level up. The IRSA/Workload Identity pattern that resolves this for Kubernetes pods does not exist for edge WASM runtimes in CDN infrastructure. Fastly Compute functions have no equivalent of EC2 instance metadata.

The correct architecture is to invert the dependency: the runtime holds the secret, and the WASM module requests the secret value through a host interface at request scope. The module has no knowledge of where the secret is stored, no credentials to any secret backend, and no ability to read secrets it was not explicitly granted access to. This is capability-based secret access, and it is the model that WASI keyvalue, Fermyon Spin’s variables API, and Cloudflare Workers bindings all implement — with different levels of standardisation and production readiness.

Threat Model

Secret bundled in WASM binary. A developer hard-codes a secret as a string literal, a build-time constant (const API_KEY: &str = "sk_live_xxx"), or via env!("STRIPE_KEY") at compile time. The WASM binary is pushed to an OCI registry or deployed to a CDN edge node. Any party with access to the binary — which may be public — extracts the secret with strings or wasm-tools dump. No authentication required; no intrusion necessary.

Environment variable secret in WASM. The runtime injects secrets as environment variables at module startup. The runtime process logs its invocation arguments. A sysadmin running ps aux on the edge node sees wasmtime run --env STRIPE_API_KEY=sk_live_xxx handler.wasm. The secret persists in process memory for the lifetime of the runtime, not the lifetime of the request. In shared runtimes, a compromised or malicious WASM module that calls wasi:cli/environment.get-environment() receives all environment variables, not just its own.

WASM module caching secret in static/global variable. A developer fetches a secret once and caches it in a static OnceLock<String> for performance. In a single-tenant runtime this is an operational concern. In a multi-tenant edge runtime that instantiates WASM modules from a shared module pool, static state from one request may be visible to a subsequent request from a different tenant if the runtime reuses module instances without re-instantiation. Fastly Compute guarantees fresh instantiation per request; Spin running in server mode on self-hosted infrastructure may not.

Capability overprovision. The WASM module is granted access to the entire secrets store rather than the specific secrets it needs. A vulnerability in the module — or a supply chain compromise in one of its Rust dependencies — can enumerate and exfiltrate all secrets in the store, not just the ones the module legitimately uses. This is the WASM equivalent of a Lambda function with an IAM role that allows secretsmanager:GetSecretValue on *.

Vault Agent secret in non-tmpfs mount. A self-hosted Spin deployment uses Vault Agent to inject secrets into files. The files are written to a regular filesystem volume rather than a tmpfs. The secret persists on disk, survives container restarts, and is readable by any process with access to the volume mount.

Hardening Configuration

1. Fermyon Spin Variables API (Rust)

Spin’s variables API is the most production-ready secrets interface for self-hosted WASM edge functions. The runtime fetches the secret value from its configured backend (Azure Key Vault, AWS Secrets Manager, or Spin’s own encrypted store) and makes it available to the component via a host call. The WASM module’s source code contains only the variable name, never the value.

# spin.toml
spin_manifest_version = 2

[application]
name = "payment-handler"
version = "0.1.0"

[variables]
stripe_api_key = { required = true, secret = true }
webhook_secret  = { required = true, secret = true }
# 'secret = true' tells Spin this variable is sensitive:
# - Spin CLI redacts the value in `spin up` output
# - Spin Cloud audit logs omit the value
# - The variable is excluded from `spin watch` hot-reload diffs

[[trigger.http]]
route = "/payment/charge"
component = "payment-handler"

[component.payment-handler]
source = "target/wasm32-wasip1/release/payment_handler.wasm"
[component.payment-handler.variables]
# Declare which variables this component is allowed to read.
# A component that does not declare a variable here cannot read it,
# even if the variable is defined at the application level.
stripe_api_key = "{{ stripe_api_key }}"
webhook_secret = "{{ webhook_secret }}"

The component-level [component.X.variables] block is the capability constraint. A second component in the same Spin application that does not declare stripe_api_key cannot read it — the host denies the request. This is coarse-grained but meaningful: a logging component or a health-check component cannot accidentally access payment credentials.

// src/lib.rs
use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;
use spin_sdk::variables;

#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
    // variables::get() makes a host call to the Spin runtime.
    // The value is fetched from the configured variable provider
    // (Azure Key Vault, AWS SM, or Spin Cloud's store).
    // It is NOT in the WASM binary. It is NOT in an environment variable.
    let api_key = variables::get("stripe_api_key")
        .map_err(|e| anyhow::anyhow!("Failed to get stripe_api_key: {}", e))?;

    let webhook_secret = variables::get("webhook_secret")
        .map_err(|e| anyhow::anyhow!("Failed to get webhook_secret: {}", e))?;

    // Validate the Stripe webhook signature before processing
    let signature = req
        .header("stripe-signature")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("Missing stripe-signature header"))?;

    verify_stripe_webhook(req.body(), signature, &webhook_secret)?;

    // api_key and webhook_secret are local variables — they live on the
    // WASM stack for the duration of this function call.
    // When the function returns, they are gone from linear memory.
    // Rust's ownership model guarantees no dangling reference survives.
    let charge_result = execute_charge(&api_key, req.body())?;

    Ok(Response::builder()
        .status(200)
        .header("content-type", "application/json")
        .body(serde_json::to_vec(&charge_result)?)
        .build())
    // api_key drops here. webhook_secret drops here.
    // No global state was written.
}

fn verify_stripe_webhook(
    body: &[u8],
    signature: &str,
    secret: &str,
) -> anyhow::Result<()> {
    use hmac::{Hmac, Mac};
    use sha2::Sha256;

    // Parse the timestamp from the signature header
    let ts = signature
        .split(',')
        .find_map(|part| part.strip_prefix("t="))
        .ok_or_else(|| anyhow::anyhow!("No timestamp in stripe-signature"))?;

    let signed_payload = format!("{}.{}", ts, String::from_utf8_lossy(body));

    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
        .map_err(|_| anyhow::anyhow!("Invalid HMAC key length"))?;
    mac.update(signed_payload.as_bytes());

    let expected = hex::encode(mac.finalize().into_bytes());

    let received = signature
        .split(',')
        .find_map(|part| part.strip_prefix("v1="))
        .ok_or_else(|| anyhow::anyhow!("No v1 signature in stripe-signature"))?;

    if expected != received {
        return Err(anyhow::anyhow!("Webhook signature verification failed"));
    }
    Ok(())
}

fn execute_charge(_api_key: &str, _body: &[u8]) -> anyhow::Result<serde_json::Value> {
    // Actual Stripe charge logic — elided for brevity
    Ok(serde_json::json!({"status": "ok"}))
}
# Set variables — NEVER put values in spin.toml, which goes to git

# Local development (values read from .env file or runtime config):
# Create a runtime-config.toml that Spin reads at startup:
cat > runtime-config.toml << 'EOF'
[variable_provider]
type = "static"

[variable_provider.values]
stripe_api_key = "sk_test_51abc..."
webhook_secret = "whsec_test_xxx"
EOF
# spin up --runtime-config-file runtime-config.toml

# Fermyon Cloud production deployment:
spin cloud variables set stripe_api_key="sk_live_51xyz..."
spin cloud variables set webhook_secret="whsec_live_yyy..."
# Output:
# Updated variable "stripe_api_key" for app "payment-handler"
# Updated variable "webhook_secret" for app "payment-handler"
# (The value is never echoed back — Cloud stores it encrypted)

# Self-hosted Spin with Azure Key Vault as the variable provider:
cat > runtime-config.toml << 'EOF'
[variable_provider]
type = "azure-key-vault"
vault_url = "https://my-spin-vault.vault.azure.net/"
client_id = "..."
client_secret = "..."
tenant_id = "..."
authority_host = "AzurePublicCloud"
EOF
# The Spin runtime fetches secrets from Key Vault on each variables::get() call
# (or from a short-lived in-process cache if configured)

When Spin starts with the Azure Key Vault provider, it authenticates once using the service principal credentials in runtime-config.toml. Subsequent variables::get() calls made by WASM components are resolved by the Spin host, which contacts Key Vault using the already-established session. The WASM module never sees the Key Vault credentials.

2. WASI Keyvalue Interface (WASI Preview 2 Component Model)

WASI keyvalue is the standardised interface for this pattern. Instead of a runtime-specific SDK like spin_sdk::variables, a component model component targets the wasi:keyvalue WIT interface. Any runtime that implements this interface — Wasmtime with the wasi-keyvalue host implementation, WAMR, or future Spin versions — can run the same WASM binary. This is the portability argument for WASI P2 over runtime-specific SDKs.

// Cargo.toml
// [dependencies]
// wasi = { version = "0.2", features = ["keyvalue"] }
// anyhow = "1"

// Component using WASI keyvalue for portable secret access
// The WIT interface the runtime must implement:
//
// package wasi:keyvalue@0.2.0;
//
// interface store {
//   resource bucket {
//     get: func(key: string) -> result<option<list<u8>>, error>;
//     set: func(key: string, value: list<u8>) -> result<_, error>;
//     delete: func(key: string) -> result<_, error>;
//   }
//   open: func(identifier: string) -> result<bucket, error>;
// }

use wasi::keyvalue::store::{open};

/// Retrieve a secret from the runtime-managed keyvalue store.
/// The "secrets" identifier is a label the runtime maps to its
/// actual secret backend — Vault, AWS SM, or a local encrypted store.
/// The WASM component has no knowledge of which backend is in use.
fn get_secret(secret_name: &str) -> anyhow::Result<String> {
    // open() makes a host call — the runtime resolves "secrets" to
    // its configured backend. The component receives a handle (bucket)
    // that it can use to fetch values. If the component was not granted
    // the "secrets" capability at instantiation, open() returns an error.
    let bucket = open("secrets")
        .map_err(|e| anyhow::anyhow!("Failed to open secrets store: {:?}", e))?;

    let raw = bucket
        .get(secret_name)
        .map_err(|e| anyhow::anyhow!("keyvalue get failed for {}: {:?}", secret_name, e))?
        .ok_or_else(|| anyhow::anyhow!("Secret not found: {}", secret_name))?;

    let value = String::from_utf8(raw)
        .map_err(|e| anyhow::anyhow!("Secret is not valid UTF-8: {}", e))?;

    // bucket drops here — the runtime-side handle is released.
    // The runtime may evict the secret from any request-scoped cache.
    Ok(value)
    // value is returned to the caller — caller is responsible for
    // dropping it after use. Do not store in a global.
}

The key semantic difference from environment variables is the handle model. open("secrets") returns a Bucket resource — a handle managed by the runtime. The runtime can enforce per-request access accounting: it knows which component opened which bucket and can audit or rate-limit access. When the Bucket drops, the runtime can invalidate any caching it did for that request’s lifetime.

The Wasmtime wasi-keyvalue host crate (as of mid-2025, experimental) can be configured to back the "secrets" store identifier against a Vault HTTP API:

# wasmtime runtime configuration for wasi-keyvalue backed by Vault
# (wasmtime-wasi-keyvalue crate, experimental)

[keyvalue]
[keyvalue.stores.secrets]
type = "vault"
address = "https://vault.internal:8200"
token_path = "/var/run/vault/token"   # Vault token injected by Vault Agent
mount = "secret"
path_prefix = "edge-functions/"
# A component requesting bucket "secrets" maps to:
# vault kv get secret/edge-functions/<key>

Production readiness note: as of early 2026, wasi:keyvalue has experimental support in Wasmtime (behind a feature flag), partial support in WAMR, and is on Spin’s roadmap. It is the correct architecture for portability, but for production deployments today, the Spin variables API or Cloudflare bindings are more stable.

3. Cloudflare Workers Secrets (JavaScript/WASM)

Cloudflare’s model is the furthest from traditional secrets management but the easiest to operate correctly. Secrets are stored in Cloudflare’s infrastructure and injected into the Worker module’s execution environment at instantiation. They are not in the wrangler.toml or the source code — they exist only in Cloudflare’s encrypted store.

# wrangler.toml — no secrets here, only the binding name
name = "payment-handler"
main = "src/worker.js"
compatibility_date = "2025-01-01"

# Declare the binding — this tells Cloudflare that env.STRIPE_API_KEY
# should be injected, but wrangler.toml does NOT contain the value.
# The actual value lives in Cloudflare's secret store.
[vars]
# Public, non-sensitive config goes here as vars:
STRIPE_API_VERSION = "2024-06-20"
# NEVER put STRIPE_API_KEY here — it would appear in wrangler.toml → git history
// src/worker.js
export default {
  async fetch(request, env, ctx) {
    // env.STRIPE_API_KEY is injected by Cloudflare at module instantiation.
    // It is NOT readable from the wrangler.toml.
    // It is NOT in the compiled WASM binary.
    // It is NOT an environment variable visible to other processes.
    // It is scoped to this Worker invocation — a fresh binding per request.

    const stripeKey = env.STRIPE_API_KEY;
    const webhookSecret = env.STRIPE_WEBHOOK_SECRET;

    if (!stripeKey || !webhookSecret) {
      return new Response("Service configuration error", { status: 503 });
    }

    // Validate the webhook signature
    const signature = request.headers.get("stripe-signature");
    if (!signature) {
      return new Response("Missing signature", { status: 400 });
    }

    const body = await request.arrayBuffer();
    const isValid = await verifyStripeWebhook(body, signature, webhookSecret);
    if (!isValid) {
      return new Response("Invalid signature", { status: 401 });
    }

    // Process the charge — stripeKey is used within this invocation scope
    const result = await processCharge(stripeKey, body);

    // stripeKey and webhookSecret go out of scope when this function returns.
    // V8 isolates provide strong memory isolation between Workers.
    return new Response(JSON.stringify(result), {
      headers: { "content-type": "application/json" },
    });
  },
};

async function verifyStripeWebhook(body, signature, secret) {
  // Parse timestamp from signature header
  const parts = signature.split(",").reduce((acc, part) => {
    const [k, v] = part.split("=");
    acc[k] = v;
    return acc;
  }, {});

  const signedPayload = `${parts.t}.${new TextDecoder().decode(body)}`;
  const keyData = new TextEncoder().encode(secret);
  const msgData = new TextEncoder().encode(signedPayload);

  const cryptoKey = await crypto.subtle.importKey(
    "raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", cryptoKey, msgData);
  const computed = Array.from(new Uint8Array(sig))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  return computed === parts.v1;
}
# Deploy the secret — never stored in wrangler.toml or git:
wrangler secret put STRIPE_API_KEY
# Prompts: Enter a secret value: (input hidden)
# Output: ✔ Successfully created the secret for script "payment-handler"

wrangler secret put STRIPE_WEBHOOK_SECRET
# Prompts: Enter a secret value: (input hidden)
# Output: ✔ Successfully created the secret for script "payment-handler"

# Verify which secrets are configured (names only — values never shown):
wrangler secret list
# Output:
# [
#   { "name": "STRIPE_API_KEY", "type": "secret_text" },
#   { "name": "STRIPE_WEBHOOK_SECRET", "type": "secret_text" }
# ]

# For CI pipelines — set via API without interactive prompt:
echo "sk_live_xxx" | wrangler secret put STRIPE_API_KEY --env production

4. Vault Agent Sidecar for Self-Hosted Spin on Kubernetes

For teams running Spin on self-managed Kubernetes rather than Fermyon Cloud, the Vault Agent injector is the standard pattern for getting secrets into the runtime without embedding them in the container image or pod spec.

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spin-payment-handler
  namespace: payments
spec:
  replicas: 3
  selector:
    matchLabels:
      app: spin-payment-handler
  template:
    metadata:
      labels:
        app: spin-payment-handler
      annotations:
        # Vault Agent injector annotations — Vault Agent sidecar is injected
        # into the pod before the Spin container starts
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "spin-payment-handler"
        # Secret path in Vault:
        vault.hashicorp.com/agent-inject-secret-stripe: "secret/data/payments/stripe"
        # Template: render the secret value into Spin's runtime-config format
        vault.hashicorp.com/agent-inject-template-stripe: |
          {{- with secret "secret/data/payments/stripe" -}}
          [variable_provider]
          type = "static"
          [variable_provider.values]
          stripe_api_key = "{{ .Data.data.api_key }}"
          webhook_secret = "{{ .Data.data.webhook_secret }}"
          {{- end }}
        # File mode — the rendered file must not be world-readable
        vault.hashicorp.com/agent-inject-file-stripe: "runtime-config.toml"
        vault.hashicorp.com/secret-volume-path-stripe: "/vault/secrets"
        # Vault Agent renews the token and re-renders the file when the
        # secret rotates (if using Vault dynamic secrets or a short TTL)
        vault.hashicorp.com/agent-revoke-on-shutdown: "true"
    spec:
      serviceAccountName: spin-payment-handler
      # Vault Agent writes to /vault/secrets — mount as tmpfs so
      # the secret never touches disk
      volumes:
      - name: vault-secrets
        emptyDir:
          medium: Memory   # tmpfs — survives only in RAM, not on disk
          sizeLimit: 1Mi
      containers:
      - name: spin
        image: ghcr.io/fermyon/spin:3.2.0
        command:
          - spin
          - up
          - --from
          - /app/payment-handler.wasm
          - --runtime-config-file
          - /vault/secrets/runtime-config.toml
        volumeMounts:
        - name: vault-secrets
          mountPath: /vault/secrets
          readOnly: true
        securityContext:
          runAsNonRoot: true
          runAsUser: 1000
          readOnlyRootFilesystem: true
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
        resources:
          limits:
            memory: 128Mi
            cpu: 500m

The Vault policy for the spin-payment-handler Kubernetes service account should be scoped to exactly the secrets this component needs — not the entire secret/data/payments/ path:

# vault-policy-spin-payment-handler.hcl
path "secret/data/payments/stripe" {
  capabilities = ["read"]
}

# Explicitly deny enumeration — the component cannot list what secrets exist
path "secret/metadata/payments/*" {
  capabilities = ["deny"]
}

path "secret/data/payments/*" {
  capabilities = ["deny"]
}
# Apply the policy and create the Kubernetes auth role:
vault policy write spin-payment-handler vault-policy-spin-payment-handler.hcl

vault write auth/kubernetes/role/spin-payment-handler \
  bound_service_account_names=spin-payment-handler \
  bound_service_account_namespaces=payments \
  policies=spin-payment-handler \
  ttl=1h

5. Preventing Static Variable Secret Caching

This is the most common mistake in Rust WASM code after moving from environment variables to a variables API. The developer understands that variables::get() is a host call with latency and adds a cache — inadvertently creating cross-request persistence.

// WRONG: static variable survives across requests in long-running runtimes.
// In Spin server mode, the WASM module instance may be reused across
// requests. The OnceLock is initialized on the first request and the
// cached value is used for all subsequent requests.
// In a multi-tenant runtime, a second tenant's request uses the first
// tenant's cached secret if the runtime reuses the instance.

use std::sync::OnceLock;
use spin_sdk::variables;

static STRIPE_KEY: OnceLock<String> = OnceLock::new();

// DON'T DO THIS:
fn get_stripe_key_cached() -> &'static str {
    STRIPE_KEY.get_or_init(|| {
        variables::get("stripe_api_key").expect("stripe_api_key not set")
    })
}
// CORRECT: fetch per request scope.
// variables::get() makes a host call, but the Spin runtime short-circuits
// this with an in-process cache on the HOST side — the WASM module does
// not need to cache it. The host cache is request-scoped, not module-scoped.

use spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;
use spin_sdk::variables;

#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
    // Each invocation fetches fresh — host-side cache makes this fast.
    // The value lives in this stack frame only.
    let api_key = variables::get("stripe_api_key")?;

    let result = use_api_key(&api_key, &req)?;

    Ok(Response::builder()
        .status(200)
        .body(result)
        .build())
    // api_key dropped here. No global state mutated.
}

// If you genuinely need to share state across requests (e.g., a connection pool),
// use types that do not hold raw secret material — hold a client handle that
// the runtime provided, not the credential string that created it.

The host-side caching detail matters: Spin’s variable provider implementations cache resolved values with a short TTL (configurable, defaults to a few seconds). The WASM component calling variables::get("stripe_api_key") on every request does not result in a Key Vault API call on every request — the host cache absorbs the load. The WASM module does not need to implement its own cache, and doing so is actively harmful.

6. Scanning WASM Binaries for Bundled Secrets Before Deployment

Add secret scanning of the compiled WASM binary to your CI pipeline. This catches build-time constant injection (env!("STRIPE_KEY"), hard-coded string literals, and secrets that end up in the binary’s data section via transitive dependencies).

#!/usr/bin/env bash
# .github/workflows/wasm-secret-scan.sh
# Run after cargo build --target wasm32-wasip1 --release

set -euo pipefail

WASM_DIR="target/wasm32-wasip1/release"
FOUND_SECRETS=0

for wasm_file in "${WASM_DIR}"/*.wasm; do
  echo "Scanning: ${wasm_file}"

  # Extract all printable strings from the WASM binary
  # wasm-tools dump shows data sections and global initializers
  STRINGS=$(wasm-tools dump "${wasm_file}" 2>/dev/null || strings "${wasm_file}")

  # Check for high-confidence secret patterns
  # These patterns match real credential formats with low false-positive rates
  if echo "${STRINGS}" | grep -qP \
    'sk_live_[a-zA-Z0-9]{24,}|' \
    'sk_test_[a-zA-Z0-9]{24,}|' \
    'whsec_[a-zA-Z0-9+/]{32,}|' \
    'hf_[a-zA-Z0-9]{37,}|' \
    'AKID[A-Z0-9]{16,}|' \
    'ghp_[a-zA-Z0-9]{36,}|' \
    'xoxb-[0-9]{11,}-[a-zA-Z0-9]{24,}'; then
    echo "ERROR: Possible secret detected in ${wasm_file}"
    FOUND_SECRETS=1
  fi
done

if [ "${FOUND_SECRETS}" -eq 1 ]; then
  echo ""
  echo "WASM binary secret scan failed. Review the detected patterns."
  echo "If these are false positives, add them to the allowlist."
  exit 1
fi

echo "WASM secret scan passed — no high-confidence secrets detected."
# .github/workflows/build.yml (relevant step)
- name: Build WASM component
  run: cargo build --target wasm32-wasip1 --release

- name: Scan WASM binary for bundled secrets
  run: |
    # Install wasm-tools if not cached
    cargo install wasm-tools --locked --quiet 2>/dev/null || true
    bash .github/scripts/wasm-secret-scan.sh

- name: Deeper scan with TruffleHog
  run: |
    # TruffleHog understands binary formats and has a large detector library
    trufflehog filesystem target/wasm32-wasip1/release/ \
      --only-verified \
      --fail \
      --no-update
  # --only-verified: only fail on secrets that TruffleHog can verify
  # are active credentials (reduces false positives significantly)

TruffleHog’s filesystem scanner reads binary files and attempts to detect credentials using its detector library — it will find Stripe keys, GitHub tokens, AWS access keys, and dozens of other formats regardless of whether they appear in a text file or a WASM data section.

Expected Behaviour

Spin variables with Fermyon Cloud: Running spin cloud variables set stripe_api_key="sk_live_xxx" produces:

Updated variable "stripe_api_key" for app "payment-handler"

The value is never echoed. Running spin cloud variables list shows:

  Name             Required  Secret
  stripe_api_key   true      true
  webhook_secret   true      true

Values are not displayed, only names. The underlying storage is encrypted at rest in Fermyon Cloud’s infrastructure.

WASI keyvalue in Wasmtime trace: Running with WASMTIME_LOG=wasmtime_wasi_keyvalue=debug shows:

DEBUG wasmtime_wasi_keyvalue: bucket open: identifier="secrets" → handle=BucketHandle(1)
DEBUG wasmtime_wasi_keyvalue: bucket get: handle=1 key="stripe_api_key" → found (32 bytes)
DEBUG wasmtime_wasi_keyvalue: bucket drop: handle=1

The value length is logged (32 bytes) but the value itself is not — the host implementation deliberately suppresses secret values from debug output.

Vault Agent injected file on tmpfs:

kubectl exec -n payments <spin-pod> -c spin -- ls -la /vault/secrets/
# -r-------- 1 vault-agent spin 127 May  9 10:23 runtime-config.toml
# Mode 0400: readable only by vault-agent user (uid 100)
# Spin container runs as uid 1000 with supplementary group spin (gid 2000)
# The runtime-config.toml has group read permission for group spin

kubectl exec -n payments <spin-pod> -- stat -f /vault/secrets/
# File: /vault/secrets/
# ID: 0 Namelen: 255 Type: tmpfs
# Type: tmpfs confirms the mount is memory-backed — nothing on disk

Trade-offs

WASI keyvalue standardisation vs. production readiness. WASI keyvalue is the right long-term architecture — a WASM component that depends only on wasi:keyvalue WIT can run on Wasmtime, WAMR, and any future conformant runtime. But as of early 2026, the production story is immature. Wasmtime’s wasi-keyvalue crate is experimental and gated behind the --wasi keyvalue flag. Spin’s variables API is stable and production-ready today. Use Spin variables for production; design your abstractions so that migrating to WASI keyvalue later requires changing only the host-side wiring.

Vault Agent tmpfs vs. runtime-managed secrets. The Vault Agent pattern is operationally heavier than Spin’s native variable provider. It requires running a Vault Agent sidecar (resource overhead), managing Kubernetes service account bindings, maintaining Vault auth roles and policies, and handling the Vault Agent’s own failure modes (token renewal failures, Vault unavailability). The payoff is that Vault is a single authoritative secrets store across your entire infrastructure — the same secret in Vault is available to Kubernetes pods, Spin runtimes, Lambda functions, and anything else you choose. If Vault is already in your stack, use it. If you are adopting it solely for WASM edge secrets, the operational cost exceeds the benefit compared to Fermyon Cloud’s native variable store or Cloudflare’s bindings.

Cloudflare Workers secret isolation. Cloudflare Workers V8 isolates provide strong memory isolation — a secret injected into one Worker invocation is not accessible from another invocation. But Cloudflare controls the injection mechanism, the storage, and the runtime. You have no self-hosted option. If your organisation requires that secrets never leave your infrastructure — for compliance or sovereignty reasons — Cloudflare Workers is not compatible with that requirement. Self-hosted Spin on your own Kubernetes cluster with Vault is the alternative.

Secret rotation latency. When you rotate a secret in Vault, Spin’s Azure Key Vault provider, or Cloudflare’s store, in-flight requests that have already fetched the old value will use it until they complete. Long-running Spin requests (streaming responses, WebSocket upgrades) may hold an old secret value for seconds or minutes. The host-side cache in Spin’s variable provider introduces additional delay — the new value may not appear in the cache until the TTL expires. Design rotation procedures to accept a brief overlap window, and ensure the old secret remains valid at the backend (Stripe, etc.) for the duration of the cache TTL plus the maximum expected request duration.

Failure Modes

Reading secrets into a global variable. A developer benchmarks their WASM component and finds that variables::get() adds 2ms per request due to host call overhead. They cache the value in a static OnceLock<String>. In single-tenant local development this is harmless. Deployed to a shared Spin cluster where the runtime reuses WASM module instances across tenants, the cached value from the first tenant’s initialisation is served to subsequent tenants. This is a cross-tenant secret leakage vulnerability. The host-side caching in Spin’s variable provider exists specifically to eliminate the performance motivation for module-side caching — trust the host cache.

Environment variables that the WASM reads at startup. A team migrating from environment-variable injection to Spin variables partially completes the migration. The spin.toml defines variables, but the runtime-config.toml still reads from environment variables for some secrets. Those environment variables appear in ps aux output, in container orchestration audit logs, and in the Spin startup log at debug level. The WASM module correctly uses variables::get() but the Spin host is reading the secret from an insecure source. Audit both the WASM source code and the Spin runtime configuration.

Not scanning WASM binaries before deployment. A developer adds a feature flag to the Rust source, and during testing sets it via a build-time environment variable (const FEATURE_API_KEY: &str = env!("FEATURE_API_KEY")). The CI pipeline builds with FEATURE_API_KEY=sk_live_xxx in the build environment. The secret is baked into the WASM binary’s data section. Without a binary scan step in CI, this ships to production. The secret is now in every edge node’s filesystem, in the OCI registry, and in the git history of the build artifacts. The wasm-secret-scan.sh step in CI catches this before deployment.

Sharing the same Spin variable store across environments. A developer uses the same Fermyon Cloud application for development and production, differentiating only by HTTP route. Both the dev and prod components share the same [variables] block. Running spin cloud variables set stripe_api_key=sk_live_xxx updates the value for both. The next development test request uses the live Stripe key and creates real charges. Maintain separate Spin applications (or separate Fermyon Cloud deployments) for each environment. Environment parity is a CI problem to solve with configuration, not by sharing a secrets store.

Vault Agent token renewal failure. The Spin runtime starts successfully and begins serving requests using the secrets from the Vault Agent-rendered runtime-config.toml. The Vault Agent’s Kubernetes auth token expires and renewal fails (Vault is unreachable, or the service account was rotated). The rendered runtime-config.toml on the tmpfs still contains the old secret values — Spin continues to function, using stale values. When the secret rotates in Vault (on a 90-day cycle), the rendered file is now stale but Spin does not know to re-read it. Add a liveness probe or alerting on Vault Agent token renewal failures. Configure Spin to re-read the runtime-config.toml periodically, or restart the Spin container when Vault Agent signals a renewal failure via a shared file or signal.