WebAssembly Component Supply Chain: Signing, Attestation, and Registry Security

WebAssembly Component Supply Chain: Signing, Attestation, and Registry Security

The Problem

The WebAssembly Component Model promoted .wasm components from flat binary blobs to typed, composable artifacts with declared imports and exports expressed in WIT (WebAssembly Interface Types). Alongside the component binary format came a dedicated registry protocol — warg (WebAssembly Registry) — and tooling to fetch, compose, and execute components. The ecosystem is young, and the supply chain controls that the container world has spent five years building (image signing, SLSA provenance, admission-time verification, namespace reservation) have not yet been systematically applied to the WASM component distribution path.

The gaps are structural. A cargo-component build produces a .wasm binary that is typically pushed to either an OCI registry (using WASM media types) or a warg registry. In neither case does the build toolchain automatically sign the artifact or attach provenance. Downstream consumers — Wasmtime-based runtimes, wasm-tools compose, Spin — pull and instantiate the component without verification unless the operator has built that verification step explicitly. Dependency confusion in warg namespaces is a real attack surface: warg’s package namespace model differs from OCI’s and has weaker reservation semantics at public registries. Composed components carry transitive dependencies that are invisible unless the composition pipeline audits them.

This article covers the full supply chain hardening path: component model concepts relevant to the supply chain, the warg protocol’s security model, cosign signing for WASM artifacts, SLSA provenance generation and attachment, runtime verification at instantiation time, dependency confusion mitigations, and Cargo-level controls for the Rust WASM build path.

Target systems: cargo-component 0.14+, wasm-tools 1.x, wasmtime 22+ (Rust API), cosign 2.4+, oras 1.2+, slsa-github-generator v2.0+, warg protocol v0.2, cargo-vet 0.9+, cargo-deny 0.14+.

Threat Model

  • Adversary 1 — Registry substitution: attacker with write access to a warg or OCI registry replaces a legitimately-published component with a malicious version under the same package name and version. Downstream builds pulling that component incorporate the malicious bytecode.
  • Adversary 2 — Namespace squatting: attacker registers a package name in a public warg registry that collides with an internal package name an organisation uses privately. If the fetch configuration allows fallback to the public registry, the squatted version is pulled instead.
  • Adversary 3 — Tampered CI artifact: attacker with CI access builds and publishes a component that differs from the source at the tagged commit. Without a SLSA provenance attestation tying the artifact to the source commit and build environment, consumers cannot detect the substitution.
  • Adversary 4 — Composed component with malicious transitive dependency: a composed application references component A, which imports interface implementation component B, which transitively depends on a compromised component C. The operator who composed A and B audited both; C was not directly visible without tracing the full composition graph.
  • Access level: Adversary 1 requires registry write. Adversary 2 requires only a user account on the public warg registry. Adversary 3 requires CI job write. Adversary 4 requires no privileged access.
  • Objective: Deliver malicious WASM bytecode into a production Wasmtime or Spin deployment.
  • Blast radius: Bounded by the WASM sandbox at minimum, but a compromised component that is granted WASI capabilities (filesystem, network, clocks) can exfiltrate data or execute further attacks within those grants.

Component Model Recap: What Is Being Signed

A WASM component (Component Model binary format) is distinct from a core WASM module. A core module is a flat byte sequence of type, function, memory, import, and export sections understood directly by the WASM spec. A component wraps one or more core modules and adds:

  • A component type section that declares the component’s imports and exports using WIT-defined interface types rather than bare numeric types.
  • Canonical ABI lifting/lowering code that translates between the WASM core ABI and the high-level WIT types (strings, records, variants, resources).
  • Sub-components — a component can embed other components inline, forming a composition bundle.

When you run cargo component build --release, the output is a single .wasm file in the component binary format. This file carries the full composition graph if components were linked at build time, or it carries only the core module with an unmet import list if composition happens at instantiation time. What you sign is this file.

WIT interfaces are the audit surface. A component’s type section declares exactly what imports it requires and what exports it provides. The declaration:

package myorg:payments@0.3.0;

world payment-processor {
  import wasi:http/outgoing-handler@0.2.0;
  import wasi:keyvalue/store@0.2.0;
  export process: func(amount: u64, currency: string) -> result<string, string>;
}

is a verifiable statement of capability requirements. When you sign the component, you are signing this declared surface. A component that has been tampered with to add an additional import (e.g., wasi:filesystem/preopens) will have a different binary and a different digest, and the signature will not verify.

The warg Protocol: Security Model

warg is the package registry protocol specified by the Bytecode Alliance for WASM component distribution. It differs from OCI in important ways that affect supply chain security.

Content-addressed storage. Every package version stored in a warg registry is addressed by the SHA-256 digest of its content. A fetch by version (myorg:payments@0.3.0) resolves through the registry log to a content digest; the actual bytes are served by the registry’s content store. This means the content is tamper-evident at the storage layer: a client that verifies the received bytes against the content digest from the log entry cannot be served a substituted artifact without the substitution being detectable.

Append-only log. warg’s trust model is built on an append-only Merkle log (similar to Certificate Transparency). Each package’s publish operations are entries in a per-package log. The log is signed by the package’s operator key. A client that tracks the log head can detect if entries are inserted, deleted, or reordered — any such manipulation changes the log root and breaks the operator key signature. This provides tamper evidence at the package-history level, not just at the individual artifact level.

Namespace and key model. A warg registry uses a namespace-scoped key model. The package name myorg:payments belongs to the myorg namespace. Publishing to a namespace requires a key that has been delegated authority for that namespace by the registry operator. On a self-hosted warg instance, the operator controls namespace delegation. On public warg instances, namespace registration is first-come-first-served with verification that varies by registry.

Where the model is weak. warg’s append-only log provides tamper evidence for the package history a client has seen, but it does not prevent a malicious registry from selectively serving different log views to different clients (log split-view attack). The warg specification addresses this with log consistency proofs; client implementations must verify these proofs. As of mid-2026, not all warg client implementations enforce consistency proof verification by default. The second weakness is namespace reservation on public instances: unlike npm’s org scopes or OCI’s registry ownership model, warg does not universally enforce that a namespace is controlled by the entity it names.

Signing WASM Components with cosign

WASM components are distributed over OCI registries (pushed with WASM media types) or warg registries. For OCI-distributed components, cosign’s standard artifact signing flow applies directly. For warg-distributed components, the signed artifact must be co-published to an OCI registry as a canonical trust anchor, or a warg-native signing mechanism used (currently experimental; the warg protocol v0.3 draft includes first-class signature envelopes).

For the practical 2026 deployment, use OCI as the signing surface even when warg is the distribution mechanism. Publish to both:

# Build the component
cargo component build --release --locked
# Output: target/wasm32-wasip2/release/payments.wasm

# Push to OCI registry with WASM component media type
oras push ghcr.io/myorg/wasm/payments:0.3.0 \
  --artifact-type application/vnd.wasm.config.v1+json \
  target/wasm32-wasip2/release/payments.wasm:application/vnd.wasm.content.layer.v1+wasm

# Capture the digest
DIGEST=$(oras manifest fetch ghcr.io/myorg/wasm/payments:0.3.0 --descriptor | jq -r .digest)

# Sign with cosign using keyless (Sigstore OIDC) — appropriate for CI
cosign sign --yes ghcr.io/myorg/wasm/payments@${DIGEST}

Key-based signing for production environments where you need to control the verification key explicitly:

# Generate a key pair (store private key in a secrets manager, not in the repo)
cosign generate-key-pair --output-key-file cosign.key

# Sign
cosign sign --key cosign.key ghcr.io/myorg/wasm/payments@${DIGEST}

Verification before use:

# Keyless: verify against the Sigstore transparency log and the expected OIDC identity
cosign verify \
  --certificate-identity-regexp="^https://github.com/myorg/payments/.github/workflows/release.yml" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/wasm/payments:0.3.0

# Key-based
cosign verify --key cosign.pub ghcr.io/myorg/wasm/payments:0.3.0

The cosign signature is stored as an OCI referrer attached to the component manifest. A downstream tool pulling the component can check for the referrer before fetching the layer bytes.

For the full OCI container signing pattern and Kyverno admission enforcement, see Sigstore and cosign for container signing. The same admission policy patterns apply to WASM artifacts when you configure Kyverno’s verifyImages rule with the WASM media type matcher.

SLSA Provenance for WASM Components

A cosign signature proves that a specific key (or CI identity) signed the artifact. It does not prove what source code and build environment produced it. For that, you need a SLSA provenance attestation: a signed statement of the form “artifact with digest X was produced from source Y at commit Z using build environment W.”

Use the slsa-github-generator to produce a SLSA Level 3 provenance attestation in GitHub Actions. For a WASM component build:

# .github/workflows/release.yml
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      artifact-digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - name: Install Rust WASM target
        run: rustup target add wasm32-wasip2
      - name: Install cargo-component
        run: cargo install cargo-component --version 0.14.0 --locked
      - name: Build component
        run: cargo component build --release --locked
      - name: Push to OCI
        id: push
        run: |
          oras push ghcr.io/myorg/wasm/payments:${{ github.ref_name }} \
            --artifact-type application/vnd.wasm.config.v1+json \
            target/wasm32-wasip2/release/payments.wasm:application/vnd.wasm.content.layer.v1+wasm
          DIGEST=$(oras manifest fetch ghcr.io/myorg/wasm/payments:${{ github.ref_name }} \
            --descriptor | jq -r .digest)
          echo "digest=${DIGEST}" >> $GITHUB_OUTPUT

  provenance:
    needs: build
    permissions:
      actions: read
      id-token: write
      packages: write
    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0
    with:
      base64-subjects: "${{ needs.build.outputs.artifact-digest }}"
      upload-assets: false

The generator produces an in-toto attestation in SLSA format. Attach it to the OCI artifact as a referrer using cosign:

# Download the generated provenance from the workflow artifact
# Then attach it as an attestation to the WASM component in the registry
cosign attest \
  --predicate payments.slsa.json \
  --type slsaprovenance \
  --key cosign.key \
  ghcr.io/myorg/wasm/payments@${DIGEST}

Verify the attestation downstream:

cosign verify-attestation \
  --type slsaprovenance \
  --key cosign.pub \
  ghcr.io/myorg/wasm/payments:0.3.0 \
  | jq '.payload | @base64d | fromjson | .predicate'

The predicate contains the source URI, commit SHA, build trigger, and build environment. A policy that requires SLSA Level 2 or higher can verify that the buildType field indicates a GitHub Actions workflow build and the materials field references the expected source repository. For a deeper treatment of SLSA provenance generation and verification pipelines, see SLSA build provenance.

Verifying Component Identity at Runtime

Signature and attestation verification at push time and at admission time is necessary but not sufficient. A runtime that pulls a component and instantiates it without re-verifying the digest has a gap: the artifact could have been replaced in storage between admission and instantiation, or the instantiation path could bypass the admission controller entirely.

Wasmtime’s component instantiation API exposes a resolver hook that runs before a component’s imports are satisfied. Use this hook to verify the component’s digest against the expected value before the module is compiled and linked:

use wasmtime::component::{Component, Linker};
use wasmtime::{Config, Engine, Store};
use sha2::{Sha256, Digest};

fn load_and_verify_component(
    engine: &Engine,
    wasm_bytes: &[u8],
    expected_sha256: &str,
) -> anyhow::Result<Component> {
    // Compute the digest of the raw bytes before compilation
    let mut hasher = Sha256::new();
    hasher.update(wasm_bytes);
    let digest = format!("{:x}", hasher.finalize());

    if digest != expected_sha256 {
        anyhow::bail!(
            "component digest mismatch: expected {}, got {}",
            expected_sha256,
            digest
        );
    }

    Component::from_binary(engine, wasm_bytes)
}

Feed the expected_sha256 from a verified, signed manifest — not from the same distribution channel as the bytes. In a Kubernetes deployment, the expected digest comes from the signed OCI manifest (verified at admission time by Kyverno or ratify) and is injected as an environment variable into the Wasmtime host process. The host then verifies the bytes it loaded match the admitted digest.

For a warg-fetched component, the warg client returns a content digest alongside the bytes. Verify the digest against the log entry’s recorded digest before passing the bytes to Wasmtime:

// Using the warg Rust client
let client = warg_client::Client::new(registry_url, auth_token).await?;
let (bytes, content_digest) = client.fetch_component("myorg:payments", "0.3.0").await?;

// The log entry digest is authoritative; the content digest must match
let log_digest = client.get_log_entry_digest("myorg:payments", "0.3.0").await?;
assert_eq!(content_digest, log_digest, "content digest does not match log entry");

// Now also verify against your expected known-good digest
load_and_verify_component(&engine, &bytes, &KNOWN_GOOD_DIGEST)?;

This two-level verification — warg log integrity plus known-good digest — closes the gap between the registry’s tamper-evidence guarantees and your deployment’s trust anchor.

Dependency Confusion in WASM: Namespace Squatting in warg

The warg package name format is <namespace>:<package>@<version>. When a build tool resolves myorg:payments, it looks up the configured registries in order. If the first registry (typically a private instance) does not have the package, some toolchain configurations fall through to the public warg registry. An attacker who registers myorg:payments on the public registry can serve malicious bytecode to any build that falls through.

The attack is identical in structure to dependency confusion in npm, PyPI, and crates.io. The warg-specific mitigations:

Namespace reservation on your private registry. Configure your warg server to own the myorg namespace exclusively. Do not allow public registry fallback for any package under a namespace you control.

Explicit registry mapping in warg.toml. The warg client configuration supports per-namespace registry bindings. This prevents fallback:

# warg.toml (committed to the repository)
[namespaces]
"myorg" = { registry = "warg.myorg.internal", fallback = false }
"wasi" = { registry = "warg.bytecodealliance.org", fallback = false }
"ba" = { registry = "warg.bytecodealliance.org", fallback = false }

With fallback = false, a missing package in the configured registry produces an error rather than falling through to a public instance. Every namespace must be mapped; an unmapped namespace defaults to the public registry.

Register your namespaces on public registries. Even if you never publish to the public warg registry, register the myorg namespace there to block squatting. The Bytecode Alliance public warg instance supports namespace reservation through its identity verification flow. Treat this the same way you treat reserving your org name on npm even for packages you only publish privately.

Verify namespace ownership on consumed packages. When pulling third-party components from public registries, confirm the publisher’s key signature matches the identity you expect. The warg log records the operator key for each namespace; compare it against a reference value from the publisher’s documentation or out-of-band communication.

Cargo Supply Chain Controls for WASM Builds

A WASM component built with cargo-component is a Rust binary. Every Rust supply chain control applies. Two tools are essential:

cargo-deny. Enforce a policy on the transitive Cargo dependency graph. Create deny.toml at the repository root:

[advisories]
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]
vulnerability = "deny"
unmaintained = "warn"
yanked = "deny"

[licenses]
allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "ISC", "BSD-2-Clause", "BSD-3-Clause"]
deny = ["GPL-3.0", "AGPL-3.0"]
copyleft = "warn"

[bans]
multiple-versions = "warn"
deny = [
  # Block known-bad crates
  { name = "openssl", reason = "use rustls; openssl brings C dep chain" },
]

Run in CI before the build step:

cargo deny check

cargo-vet. Maintain an audit log of which crates your team has manually reviewed for supply chain safety. New unreviewed crates in the dependency tree cause cargo vet to fail:

cargo vet init    # once, to set up .cargo/vet/
cargo vet         # in CI; fails on unaudited new deps
cargo vet certify # when adding/reviewing a new crate

For WASM-specific builds, also enforce Cargo.lock is committed and used:

# In CI, fail if Cargo.lock would change
cargo component build --release --locked

The --locked flag causes Cargo to abort if Cargo.lock does not satisfy the current Cargo.toml. This prevents a resolution drift attack where a CI run silently upgrades a dependency to a version not reviewed by cargo-vet.

For a full treatment of SBOM generation from the Cargo dependency graph and vulnerability scanning of WASM artifacts, see WASM Supply Chain SBOM and Provenance and SBOM generation and consumption.

Component Composition Security: Auditing Transitive Dependencies

When wasm-tools compose combines two components, the result is a new component binary that embeds both components’ logic (or their imports/exports are wired at instantiation time, depending on the composition strategy). The security audit surface expands: if component A imports interface myorg:auth/verify and that interface is satisfied by component B, and component B was built with a compromised version of a crypto crate, then the composed artifact carries the vulnerability even if component A’s direct Cargo dependencies are clean.

Extract the transitive composition graph from a composed component binary using wasm-tools:

# Inspect a composed component's type section to see all declared interfaces
wasm-tools component wit target/wasm32-wasip2/release/composed-app.wasm

# List all sub-components embedded in the binary
wasm-tools dump target/wasm32-wasip2/release/composed-app.wasm \
  | grep -E "(component|module) \["

# For each sub-component, extract and hash it
wasm-tools component extract-core \
  target/wasm32-wasip2/release/composed-app.wasm \
  --output extracted-core.wasm
sha256sum extracted-core.wasm

Match each extracted sub-component’s digest against the expected digest from the signed manifest of the source component. If a sub-component’s digest does not appear in your registry’s signed manifest list, it was either built locally without signing or pulled from an untrusted source.

Automate this in CI as a composition audit step:

#!/usr/bin/env bash
# audit-composition.sh
# Requires: wasm-tools, jq, a signed-digests.json mapping name->expected_digest

COMPOSED_WASM=$1
KNOWN_DIGESTS=$2

# Extract all embedded core modules and check their digests
wasm-tools dump "$COMPOSED_WASM" --json \
  | jq -r '.modules[] | .hash' \
  | while read -r actual_digest; do
      if ! jq -e --arg d "$actual_digest" '.[] | select(. == $d)' "$KNOWN_DIGESTS" > /dev/null; then
        echo "FAIL: unknown sub-component digest ${actual_digest}"
        exit 1
      fi
    done

echo "All sub-component digests verified"

Run this after wasm-tools compose and before pushing the composed artifact. The signed-digests.json file should be generated from verified OCI manifest digests, not from local builds, so that the audit reflects the registry-published trust anchors.

For a deeper look at WIT interface trust boundaries within composed applications, see WASM Component Model Security Boundaries.

Registry Deployment Hardening

If you operate a self-hosted warg registry, the deployment configuration determines whether the tamper-evidence properties of the warg protocol actually hold for your consumers.

TLS with certificate pinning. The warg client must connect over TLS. Use a certificate from a well-known CA and configure HSTS. For internal-only registries, use a private CA and distribute the CA certificate through your infrastructure tooling (not through the same registry). Pin the certificate hash in the warg client configuration:

[registry."warg.myorg.internal"]
tls-cert-hash = "sha256:a1b2c3d4..."  # pin the leaf cert or CA cert hash

Operator key management. The warg registry’s operator key signs namespace delegation. Store this key in a hardware security module or a secrets manager with audit logging. Rotate it on a regular schedule. A compromised operator key allows an attacker to sign malicious namespace delegation entries into the log; detecting this after the fact requires comparing log states from before and after the suspected compromise.

Log consistency proof enforcement. Configure your warg server to serve and your clients to verify log consistency proofs on every fetch. This closes the split-view attack surface where a compromised registry serves a different log to different clients:

# warg client config
[security]
require-consistency-proofs = true
checkpoint-url = "https://warg-checkpoint.myorg.internal"  # independent log monitor

Rate-limit namespace registration. On public or semi-public warg instances, prevent bulk namespace squatting by rate-limiting new namespace registrations per identity and requiring identity verification (e.g., linking to a GitHub org) before a namespace is activated.

Putting It Together: CI Pipeline

A complete CI pipeline for a WASM component that integrates all controls:

steps:
  - name: Enforce lockfile
    run: cargo component build --release --locked

  - name: Run cargo-deny
    run: cargo deny check

  - name: Run cargo-vet
    run: cargo vet

  - name: Push to OCI registry
    run: |
      oras push ghcr.io/myorg/wasm/payments:${TAG} \
        --artifact-type application/vnd.wasm.config.v1+json \
        target/wasm32-wasip2/release/payments.wasm:application/vnd.wasm.content.layer.v1+wasm

  - name: Sign with cosign
    run: cosign sign --yes ghcr.io/myorg/wasm/payments:${TAG}

  - name: Attach SLSA provenance
    run: |
      cosign attest \
        --predicate payments.slsa.json \
        --type slsaprovenance \
        --key cosign.key \
        ghcr.io/myorg/wasm/payments@${DIGEST}

  - name: Publish to warg (internal only)
    run: |
      warg publish myorg:payments ${TAG} \
        target/wasm32-wasip2/release/payments.wasm \
        --registry warg.myorg.internal

The --locked build, cargo-deny, and cargo-vet steps run before any artifact is produced. Signing and attestation happen immediately after the OCI push. The warg publish step uses the internal registry with no public fallback configured.

Summary

The WASM component supply chain has a distinct attack surface from the container supply chain, even though many of the mitigations borrow the same tools. The warg protocol’s append-only log provides stronger tamper evidence than a plain OCI registry for package history, but namespace squatting is a live threat on public instances and must be mitigated through explicit namespace mapping and registration. Cosign signing of WASM OCI artifacts follows the same mechanics as container signing. SLSA provenance attestations attached as OCI referrers close the gap between signature (who signed) and provenance (what built it). Runtime digest verification in Wasmtime ensures that signed artifacts are what is actually instantiated. Composed components require explicit audit of the full sub-component graph against known-good digests. Cargo-level controls (cargo-vet, cargo-deny, --locked) address the Rust build path that underlies most production WASM components.

Each layer is independently bypassable; the full chain is required for a defensible deployment.