WebAssembly Module Registry Security: warg, OCI, and Supply Chain Controls for WASM

WebAssembly Module Registry Security: warg, OCI, and Supply Chain Controls for WASM

Distribution Models and Their Security Implications

WASM modules reach production through three different distribution paths, each with a distinct threat model.

File-based distribution means .wasm binaries checked into git or fetched from an arbitrary URL at build or deploy time. The supply chain controls available are whatever your CI pipeline enforces — there is no registry-level identity, no content-addressable verification, no revocation mechanism. A compromised CDN or a MITM on an unverified HTTPS fetch delivers a different binary than was built.

OCI artifact distribution places .wasm files in container registries (GHCR, ECR, Artifact Registry) using the OCI artifact specification. The registry gives you content-addressable storage by digest, and the same signing infrastructure — cosign, Notary v2 — used for container images applies directly. If your organization already enforces image signing before Kubernetes admission, extending that enforcement to OCI-stored WASM is a natural fit.

warg protocol distribution is purpose-built for the WebAssembly ecosystem. It introduces a cryptographic append-only log that makes package metadata tamper-evident, operators who sign the log state at regular intervals, and namespace reservation that structurally prevents most dependency confusion attacks. warg is the emerging standard for the Component Model ecosystem; modules distributed via wit-deps or wasm-pkg-tools consume it by default.

The practical choice for most teams in 2026: OCI for everything that fits your existing container registry infrastructure, warg for Component Model components where ecosystem tooling assumes it. This article covers both.

warg Protocol Security Model

The warg specification (https://warg.io) defines a content-addressable, append-only, operator-signed log for WebAssembly package metadata. The security properties come from three mechanisms working together.

Append-only log. Every package publish operation appends a signed record to the package’s log. Records reference their predecessor by hash, forming a hash chain. A registry operator cannot silently replace a previous version — the change would break the hash chain and be detectable by any client that has cached a prior log state. This is similar in structure to Certificate Transparency logs.

Operator signing. At configurable intervals, the registry operator signs a checkpoint that commits to the full log state. Clients verify that a checkpoint’s operator signature is valid and that it covers a log state consistent with what the client has seen before. An operator cannot retroactively alter published records without producing a checkpoint that contradicts a prior signed checkpoint — which clients would detect as a fork.

Content-addressable storage. Package content references are SHA-256 digests. Fetching content at a given digest and verifying it locally closes the gap between what the registry claims to serve and what it actually delivers.

To interact with a warg registry from the CLI:

wkg config set --registry wa.dev

wkg publish --package my-org:http-util ./output/http_util.wasm

wkg get my-org:http-util@1.2.0 --output ./deps/http_util.wasm

wkg verify my-org:http-util@1.2.0 ./deps/http_util.wasm

The verify subcommand checks both the log-chain integrity and the content digest. Make this a mandatory step in your CI pipeline before any .wasm file is used as a dependency.

For a private warg deployment, the key material for the operator signing key must be managed with the same rigour as a CA private key. Store it in a hardware-backed KMS, require M-of-N signing for key ceremonies, and rotate it on the schedule dictated by your security policy. The warg server reference implementation supports delegated signing where the operator key lives in a KMS and signs checkpoints via an API call rather than a key file on disk.

For more on WASM component supply chain controls built on top of warg, see WebAssembly Component Supply Chain.

OCI Registries for WASM Modules

The OCI Image Specification was extended with the artifact spec to support arbitrary content types. A .wasm file is just bytes with a declared media type — pushing it to any OCI-compliant registry gives you content-addressable storage and hooks into existing signing and policy infrastructure.

Media Types

Use the following media types when pushing WASM artifacts:

Artifact type Layer media type
Core module application/vnd.wasm.content.layer.v1+wasm
Component application/vnd.wasm.content.layer.v1+component
WAT (text format) application/vnd.wasm.content.layer.v1+wat

Pushing and Pulling with oras

oras is the reference CLI for OCI artifact operations. Install it via the GitHub release or your package manager, then:

oras push \
  ghcr.io/my-org/http-handler:1.2.0 \
  --artifact-type application/vnd.wasm.content.layer.v1+wasm \
  ./http_handler.wasm:application/vnd.wasm.content.layer.v1+wasm

oras pull \
  ghcr.io/my-org/http-handler@sha256:abc123... \
  --output ./modules/

Always pull by digest in production, not by tag. Tags are mutable; a compromised registry account can silently retag latest to point to a different digest. Pin the digest and verify it against a known-good value stored out-of-band.

Attach build metadata as an OCI referrer:

oras attach \
  ghcr.io/my-org/http-handler@sha256:abc123... \
  --artifact-type application/vnd.build-metadata.v1+json \
  ./build-info.json:application/json

This creates a referrer relationship in the registry index that tools like cosign and Syft can query when generating or verifying attestations.

Signing WASM Modules with cosign

cosign treats an OCI-stored WASM module identically to a container image. The signing workflow is the same; the enforcement mechanisms are the same. If you already sign container images, the operational delta for WASM is small.

For the full cosign setup and key management reference, see Sigstore cosign Container Signing.

Keyless Signing in CI

COSIGN_EXPERIMENTAL=1 cosign sign \
  --identity-token=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) \
  ghcr.io/my-org/http-handler@sha256:abc123...

In GitHub Actions the OIDC token is available automatically:

- name: Sign WASM module
  env:
    COSIGN_EXPERIMENTAL: "1"
  run: |
    cosign sign \
      --oidc-issuer=https://token.actions.githubusercontent.com \
      ghcr.io/${{ github.repository }}/http-handler@${{ steps.build.outputs.digest }}

Attaching SLSA Provenance

Generate provenance with slsa-github-generator and attach it as a cosign attestation:

cosign attest \
  --predicate ./provenance.json \
  --type slsaprovenance \
  ghcr.io/my-org/http-handler@sha256:abc123...

Verifying Before Use

cosign verify \
  --certificate-identity-regexp="^https://github.com/my-org/.*" \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  ghcr.io/my-org/http-handler@sha256:abc123...

cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity-regexp="^https://github.com/my-org/.*" \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  ghcr.io/my-org/http-handler@sha256:abc123...

Fail the deployment pipeline if either command exits non-zero. Do not fall back to skipping verification on transient errors — treat a verification failure as a hard stop.

Vulnerability Scanning WASM Binaries

Container image scanners — Trivy, Grype, Snyk — scan the filesystem layers of an OCI image by identifying shared libraries and package manager databases. A .wasm binary is an opaque bytecode blob from the scanner’s perspective. The scanner sees the OCI artifact but cannot recurse into the WASM binary to identify the Rust crates, Go packages, or C libraries compiled into it. You get zero CVE hits for a WASM file scanned with a naïve container scanner — not because it’s clean, but because the scanner has no visibility into its composition.

The correct approach is to scan at the source before compilation, and to use WASM-specific tooling at the binary level.

cargo audit for Rust-Compiled WASM

Most production WASM is compiled from Rust. cargo audit queries the RustSec Advisory Database against your Cargo.lock:

cargo audit --deny warnings

Run this in CI before the cargo build --target wasm32-wasip2 step. A vulnerable dependency in Cargo.lock gets caught here, before it becomes baked into the binary.

For Cargo workspaces with multiple crates compiling to WASM:

cargo audit --file ./Cargo.lock --ignore RUSTSEC-2024-0001

Pin specific advisory ignores in .cargo/audit.toml with a mandatory expiry date so they don’t silently accumulate:

[advisories]
ignore = [
  { id = "RUSTSEC-2024-0001", reason = "not reachable via WASM ABI", expires = "2026-08-01" }
]

cargo-vet: Supply Chain Vetting for Rust Dependencies

cargo audit checks known CVEs. cargo-vet covers the broader supply chain question: has anyone you trust actually reviewed the source code of every crate that ends up compiled into your WASM binary?

cargo install cargo-vet
cargo vet init

After init, cargo-vet generates a supply-chain/ directory containing an audits.toml where you record crate reviews, and imports.lock which pins the set of trusted third-party audit sources (Mozilla, ISRG, Bytecode Alliance).

cargo vet

Any crate not covered by an existing audit from a trusted source is flagged as unvetted. You must either add your own audit entry or explicitly certify that you trust a third-party audit:

[[audits.serde]]
who = "security@my-org.com"
criteria = "safe-to-deploy"
version = "1.0.197"
notes = "Reviewed 2026-03-15, no unsafe blocks in serde itself, proc-macro reviewed separately"

[[audits.serde_derive]]
who = "security@my-org.com"
criteria = "safe-to-deploy"
version = "1.0.197"
notes = "Proc-macro generates no unexpected code, reviewed output for representative inputs"

For WASM-specific builds, scope cargo-vet to the dependency tree that actually reaches the wasm32-* targets:

cargo vet --filter-platform wasm32-wasip2

This avoids requiring full audits of build-time-only dependencies that never compile into the binary.

The cargo-vet audit database is version-controlled alongside your source. Treat it as security-critical infrastructure — require code review for changes to audits.toml, and never allow --locked bypasses in CI.

wasm-pack audit

For WASM modules distributed via npm (browser targets), wasm-pack integrates with the npm ecosystem. After wasm-pack build, audit the generated package:

wasm-pack build --target web
cd pkg/
npm audit --audit-level=moderate

This catches vulnerabilities in any JavaScript glue code generated by wasm-bindgen, which is a real attack surface — the JS wrappers interact with the host environment and handle memory management at the boundary.

Binary-Level Analysis

For third-party .wasm binaries you cannot audit at source, wasm-objdump and wasm-dis from the WABT toolkit expose the import/export section and function bodies:

wasm-objdump -x third-party.wasm | grep -E "(Import|Export)"
wasm-objdump -d third-party.wasm > third-party.wat

Review the import section for unexpected host function calls. A module claiming to be a pure computation library that imports fd_write, path_open, or network syscalls warrants investigation. Automate this in CI with a policy-as-code check:

IMPORTS=$(wasm-objdump -x third-party.wasm | grep "Import\[" | grep -v "wasi_snapshot")
if [ -n "$IMPORTS" ]; then
  echo "UNEXPECTED IMPORTS: $IMPORTS"
  exit 1
fi

For artifact registry security controls at the infrastructure level, see Artifact Registry Security.

Enforcing Signed WASM in Wasmtime

Wasmtime does not provide built-in signature verification before instantiation — it trusts whatever bytes you hand it. Enforcement must happen in the embedding application, in the layer that calls wasmtime::Engine and wasmtime::Module.

A production-grade module loader wraps the load path with a verification step. The following is a Rust example using the cosign verification library:

use sigstore::cosign::{CosignCapabilities, ClientBuilder};
use sigstore::crypto::SigningScheme;
use wasmtime::{Engine, Module, Store};

async fn load_verified_module(
    engine: &Engine,
    oci_reference: &str,
    expected_issuer: &str,
    expected_identity_pattern: &str,
) -> anyhow::Result<Module> {
    let client = ClientBuilder::default()
        .with_oci_client_config(Default::default())
        .build()?;

    let auth = &sigstore::registry::Auth::Anonymous;
    let (manifest, _, config) = client
        .triangulate(oci_reference, auth)
        .await?;

    let mut signature_layers = client
        .verify(
            oci_reference,
            auth,
            &sigstore::cosign::verification_constraint::CertSubjectEmailVerifier {
                issuer: Some(expected_issuer.to_string()),
                ..Default::default()
            },
            &[],
        )
        .await?;

    if signature_layers.is_empty() {
        anyhow::bail!("no valid signatures found for {}", oci_reference);
    }

    let wasm_bytes = pull_wasm_bytes(oci_reference).await?;
    let module = Module::from_binary(engine, &wasm_bytes)?;
    Ok(module)
}

The loader fails closed: if verify returns an error or an empty signature list, load_verified_module returns an error and the module is never instantiated. Wire this into whatever component lifecycle your application uses — plugin load, request handler registration, dynamic module upgrade.

For environments where you control the WASM bytes but not the OCI layer, embed a detached signature file alongside the .wasm and verify it against a local trust root before calling Module::from_binary:

fn verify_detached_signature(
    wasm_bytes: &[u8],
    sig_bytes: &[u8],
    trusted_public_key: &[u8],
) -> anyhow::Result<()> {
    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
    let key = VerifyingKey::from_bytes(trusted_public_key.try_into()?)?;
    let signature = Signature::from_bytes(sig_bytes.try_into()?);
    let digest = sha2::Sha256::digest(wasm_bytes);
    key.verify(&digest, &signature)?;
    Ok(())
}

Sign at build time with the corresponding private key stored in your secrets manager, never on developer workstations.

Fermyon Spin: Verifying Component Signatures Before Deployment

Spin loads components declared in spin.toml — it does not verify their signatures before execution out of the box. Enforcement is a pre-deployment concern.

Pre-deployment Verification Hook

Add a spin deploy wrapper script that verifies each component declared in spin.toml before executing spin deploy:

#!/usr/bin/env bash
set -euo pipefail

SPIN_TOML="${1:-spin.toml}"
OIDC_ISSUER="https://token.actions.githubusercontent.com"
IDENTITY_PATTERN="^https://github.com/my-org/"

python3 - <<'EOF'
import tomllib, sys
with open("spin.toml", "rb") as f:
    config = tomllib.load(f)
for component in config.get("component", []):
    source = component.get("source", "")
    if source.startswith("ghcr.io/") or source.startswith("oci://"):
        print(source)
EOF
) | while read -r ref; do
  cosign verify \
    --certificate-identity-regexp="${IDENTITY_PATTERN}" \
    --certificate-oidc-issuer="${OIDC_ISSUER}" \
    "${ref}" || {
      echo "Signature verification failed for ${ref}" >&2
      exit 1
    }
done

spin deploy "$@"

Signed spin.toml Bundles

When distributing a Spin application as an OCI artifact (via spin registry push), the pushed artifact is an OCI manifest that includes the spin.toml configuration and all component .wasm files as layers. Sign the entire manifest, not just the individual .wasm layers:

DIGEST=$(spin registry push ghcr.io/my-org/my-app:1.0.0 --format oci)
cosign sign \
  --oidc-issuer=https://token.actions.githubusercontent.com \
  "ghcr.io/my-org/my-app@${DIGEST}"

On the consuming side, verify before spin up:

cosign verify \
  --certificate-identity-regexp="^https://github.com/my-org/" \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  "ghcr.io/my-org/my-app@${DIGEST}"

spin up --from "ghcr.io/my-org/my-app@${DIGEST}"

Pinning the digest in spin up --from prevents a mutable tag from pointing to a different, potentially unsigned, manifest after verification completes.

Dependency Confusion in WASM Ecosystems

Dependency confusion attacks exploit the gap between private and public package namespaces. The attacker publishes a package on the public registry with the same name as an internal package, betting that build tooling resolves the public version first.

warg Namespace Reservation

The warg protocol supports namespace ownership. Registries implementing the full spec allow an organization to claim a namespace — my-org — such that only authenticated principals in that organization can publish under it. On the public wa.dev registry, reserve your organization namespace before any external party does:

wkg namespace create my-org --registry wa.dev

After reservation, my-org:* packages on wa.dev can only be published by your key material. An attacker cannot publish my-org:http-util to wa.dev and have it served to clients fetching your internal package.

Private Registry Precedence

For wasm-pkg-tools configuration, declare your private registry as the authoritative source for your namespace and only fall back to the public registry for other namespaces:

[registry]
default = "wa.dev"

[namespace_registries]
"my-org" = { registry = "wasm.my-org.internal", protocol = "warg" }
"my-other-team" = { registry = "wasm.my-org.internal", protocol = "warg" }

With this configuration, my-org:* packages are always resolved against your internal registry. The public registry is never consulted for your namespace, eliminating the confusion attack surface for those packages.

For OCI-stored WASM, enforce registry precedence at the build system level. If your internal modules are on registry.my-org.internal, configure your build scripts to pull from there and deny any reference that routes to a public registry for first-party packages:

ALLOWED_REGISTRIES=("registry.my-org.internal" "ghcr.io/my-org")

check_registry() {
  local ref="$1"
  for allowed in "${ALLOWED_REGISTRIES[@]}"; do
    if [[ "$ref" == "${allowed}/"* ]]; then
      return 0
    fi
  done
  echo "Disallowed registry reference: $ref" >&2
  return 1
}

Scoped npm Packages for Browser WASM

For WASM modules distributed via npm (browser targets built with wasm-pack), use scoped package names (@my-org/http-util) and configure .npmrc to resolve your scope from your private registry:

@my-org:registry=https://npm.my-org.internal
//npm.my-org.internal/:_authToken=${NPM_INTERNAL_TOKEN}

The npm client sends @my-org/ prefixed resolution requests to your private registry and only falls back to the public registry for unscoped or differently-scoped packages. Publish your packages on the public registry as well with an ownership claim — this prevents a squatting attack where an attacker registers your scoped package name on the public registry and serves it to clients with misconfigured .npmrc files.

Putting It Together

A complete WASM supply chain policy for a production platform looks like:

  1. All first-party WASM is built in CI from audited source (cargo-vet, cargo audit), signed with keyless cosign immediately after build, and stored in an OCI registry pinned by digest.
  2. Third-party WASM dependencies are fetched by digest, verified for signatures from their declared maintainer keys, and their import sections are checked against an allowlist before being admitted to the dependency set.
  3. Deployment pipelines verify signatures before instantiation — whether that’s a Wasmtime embedding checking via the sigstore SDK, a Spin deploy script, or a Kubernetes admission webhook that validates OCI artifact signatures using Policy Controller.
  4. Namespace reservation on both the warg and npm registries prevents dependency confusion for your organization’s package names.
  5. The warg operator key and any long-lived cosign keys live in KMS with audit logging enabled. Checkpoint verification failures are alerts, not warnings.

The container ecosystem spent years learning these lessons the hard way. The WASM ecosystem is young enough that you can apply them from the start.