NGINX WASM Filters with ngx_wasm_module: Request-Path Plugins, Resource Caps, and Distribution
Problem
NGINX has had ngx_http_lua_module and njs for years; both let operators inject custom logic into the request path. Each has limits — Lua adds a heavy runtime, njs is JavaScript with NGINX-specific extensions, and neither has the supply-chain story (signing, distribution, isolation) that container-based or WASM-based plugins have.
ngx_wasm_module, developed by Kong and increasingly upstream-tracked, brings the proxy-wasm protocol to NGINX. The same plugin model that has been in Envoy since 2019 — WASM modules implementing the proxy-wasm ABI to handle request and response phases — now runs inside NGINX worker processes.
For NGINX operators this changes the plugin story:
- A single plugin artifact (
.wasm) runs in NGINX, Envoy, Apache Traffic Server, and any other proxy with proxy-wasm support. The build pipeline is shared. - Plugins distribute through OCI registries, signed with cosign, verifiable at deploy time — same supply-chain controls as container images.
- The plugin runs in a sandboxed VM with linear-memory isolation, memory caps, and CPU caps. A misbehaving plugin does not corrupt the NGINX worker’s heap.
- The proxy-wasm ABI surface is well-defined and capability-restrictable, replacing the “trust any Lua module” model.
The hardening surface differs from Envoy in ways that matter:
- NGINX worker model. NGINX runs multiple workers per box. Each worker has its own WASM VM instance per plugin. Memory caps apply per-VM; the per-worker count multiplies the host memory footprint.
- Master-worker IPC. NGINX’s master process is privileged; workers are not. Plugin configuration changes via
nginx -s reloadcycle workers — long-lived plugin state persists for the worker’s lifetime, not across reloads. - Phase semantics. NGINX’s phase model (
rewrite,access,content,header_filter,body_filter,log) maps to proxy-wasm callbacks differently than Envoy’s HTTP filter model. Some plugins that work in Envoy need adjustment for NGINX phases. ngx_wasm_moduleruntime selection. Choice between Wasmtime, V8, and WAMR backends, each with different memory and CPU characteristics.
This article covers ngx_wasm_module configuration, per-plugin resource caps, ABI capability restriction, OCI-based plugin distribution, and operational telemetry.
Target systems: NGINX 1.25+ with ngx_wasm_module v0.4+ compiled in; Wasmtime 22+ or V8 11+ as the runtime backend. Compatible with Kong Gateway 3.6+ which embeds the module by default.
Threat Model
- Adversary 1 — Compromised plugin author or build pipeline: ships a malicious update to a previously-trusted plugin.
- Adversary 2 — OCI registry typosquat or compromise: plugin pulled with the wrong content, identity, or signature state.
- Adversary 3 — Plugin abusing the proxy-wasm ABI: uses
proxy_dispatch_http_callfor SSRF, reads sensitive request properties for exfiltration, or modifies response bodies for tampering. - Adversary 4 — Resource exhaustion: a plugin (malicious or buggy) consumes CPU or memory until NGINX workers OOM or stall.
- Access level: Plugin author / registry access for adversaries 1 and 2; running plugin in production for 3 and 4.
- Objective: Read or modify in-flight requests; exfiltrate sensitive headers; cause data-plane outages.
- Blast radius: A plugin sees every request and response on every route it is bound to. Without per-plugin isolation, a memory leak in one plugin can crash a worker handling other tenants’ traffic. With hardening, blast radius is bounded to the specific worker process and the routes the plugin is bound to.
Configuration
Step 1: Enable the WASM Subsystem
# /etc/nginx/nginx.conf
load_module modules/ngx_wasm_module.so;
wasmtime {
flag fuel_consumption on;
flag wasi true;
}
# Or, alternatively, V8 backend.
# v8 {
# flag wasm_max_module_size_bytes 16777216;
# }
events {
worker_connections 1024;
}
http {
# WASM VM defaults applied to every plugin unless overridden.
wasm {
# Per-VM memory cap.
max_memory 64m;
# Compilation cache to avoid recompile on reload.
compiler_cache /var/cache/nginx/wasm;
# Refuse precompiled artifacts.
allow_precompiled off;
}
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
# Apply two plugins in order to this location.
proxy_wasm my-auth-plugin;
proxy_wasm my-rate-limit-plugin;
proxy_pass http://upstream;
}
}
}
Step 2: Define a Plugin with Resource Caps
# Plugin definition block.
http {
# ... defaults ...
wasm {
module my-auth-plugin {
# Pin the artifact by digest. NGINX rejects mismatched content.
file /etc/nginx/wasm/my-auth.wasm;
sha256 1234567890abcdef...;
config '{"issuer":"https://auth.example.com","audience":"api"}';
# Per-plugin caps override the wasm{} defaults.
max_memory 32m;
fuel 50000000; # 50M ops budget per request
# ABI capability restriction.
# By omitting capabilities, they are denied.
allowed_capabilities {
proxy_log;
proxy_get_property;
proxy_get_buffer;
proxy_set_buffer;
proxy_define_metric;
proxy_increment_metric;
# Note: proxy_dispatch_http_call deliberately omitted.
# The plugin cannot make outbound HTTP calls.
}
# Failure policy.
on_panic deny; # any plugin trap rejects the request
}
}
}
on_panic deny makes the plugin fail-closed. For security-critical plugins (auth, authorization, request validation) this is correct. For observability or non-critical plugins, on_panic continue lets the request proceed without the plugin.
Step 3: Per-Worker VM Accounting
NGINX runs worker_processes workers, each with its own VM instance per plugin. Plan capacity:
total_wasm_memory = worker_processes × num_plugins × max_memory_per_plugin
With worker_processes auto on a 16-core box, 4 plugins, and 32 MiB cap each:
16 × 4 × 32 MiB = 2 GiB reserved for WASM VMs alone. Pin worker_processes to a known value rather than auto if WASM memory is significant.
# Pin worker count to make WASM memory predictable.
worker_processes 8;
worker_rlimit_nofile 65536;
Step 4: Capability Restriction Per Plugin
The proxy-wasm ABI capability set should be minimal per plugin role.
# Logging plugin — no body access, just metadata.
wasm {
module my-logger {
file /etc/nginx/wasm/logger.wasm;
sha256 ...;
allowed_capabilities {
proxy_log;
proxy_get_property;
proxy_define_metric;
proxy_increment_metric;
}
on_panic continue; # logging failures should not break requests
}
}
# Auth plugin — read headers, set status, no outbound calls.
wasm {
module my-auth {
file /etc/nginx/wasm/auth.wasm;
sha256 ...;
allowed_capabilities {
proxy_log;
proxy_get_property;
proxy_get_buffer; # read Authorization header
proxy_set_buffer; # set 401 response body
proxy_define_metric;
proxy_increment_metric;
}
on_panic deny;
}
}
# External-auth plugin — needs dispatch to a specific cluster only.
wasm {
module my-external-auth {
file /etc/nginx/wasm/external-auth.wasm;
sha256 ...;
allowed_capabilities {
proxy_log;
proxy_get_property;
proxy_get_buffer;
proxy_set_buffer;
proxy_dispatch_http_call;
proxy_define_metric;
}
config '{"auth_cluster":"auth-service"}';
on_panic deny;
}
}
For plugins that need proxy_dispatch_http_call, define the upstream cluster as an NGINX upstream block and have the plugin reference it by name. The plugin cannot dispatch to arbitrary URLs — only to NGINX-defined clusters.
Step 5: Load Plugins from OCI Registries
Pulling directly from OCI registries (rather than file paths) integrates with the existing supply-chain story:
wasm {
module my-auth {
# Pull from OCI registry at startup.
url oci://registry.example.com/wasm-plugins/my-auth:v1.2.3;
sha256 1234567890abcdef...;
registry_credentials_file /etc/nginx/registry-creds.json;
allowed_capabilities {
proxy_log;
proxy_get_property;
proxy_get_buffer;
proxy_set_buffer;
}
on_panic deny;
}
}
Combine with admission-pipeline cosign verification (covered in OCI WASM Module Signing and Verification). NGINX itself does not verify cosign signatures yet; do verification at the registry side and gate plugin pushes there.
For air-gapped or signature-required environments, run a periodic pull-and-verify job that downloads the artifact, verifies cosign + SLSA provenance, and stages it to the local filesystem path NGINX uses:
#!/bin/sh
# /usr/local/bin/refresh-nginx-wasm-plugins.sh
set -eu
REF="oci://registry.example.com/wasm-plugins/my-auth:v1.2.3"
DEST="/etc/nginx/wasm/my-auth.wasm"
cosign verify "$REF" \
--certificate-identity 'https://github.com/myorg/my-auth-wasm/.github/workflows/build.yml@refs/heads/main' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
oras pull "$REF" --output /tmp/wasm
mv /tmp/wasm/my-auth.wasm "$DEST"
nginx -s reload
Step 6: Telemetry
NGINX exposes WASM-specific stats via the existing stub_status and vts modules. Track per-plugin counts:
nginx_wasm_module_invocations_total{plugin}
nginx_wasm_module_panics_total{plugin}
nginx_wasm_module_fuel_consumed{plugin}
nginx_wasm_module_memory_pages_current{plugin, worker_pid}
nginx_wasm_module_dispatch_calls_total{plugin, cluster}
nginx_wasm_module_capability_denied_total{plugin, capability}
Alert on:
panics_totalrising — plugin instability.capability_denied_totalnon-zero — plugin trying to use a capability not granted (likely a misconfigured allowed_capabilities or an attempted ABI abuse).memory_pages_currentnearmax_memory— leak.
Step 7: Reload Hygiene
nginx -s reload cycles workers. WASM VMs reinitialize with the new configuration. Long-lived state in plugins (cached config, JWT keys) is reset. For plugins that fetch external config (e.g., JWKS for JWT verification), this means a re-fetch on every reload — coordinate the reload schedule with the upstream’s rate limits.
For plugins that need cross-reload state, use proxy_set_shared_data / proxy_get_shared_data — a per-plugin key-value store that persists across reloads but not across worker restarts.
Expected Behaviour
| Signal | Without ngx_wasm_module | With ngx_wasm_module (hardened) |
|---|---|---|
| Plugin runtime | Lua/njs with full host access | WASM with linear-memory sandbox + ABI capability allowlist |
| Plugin distribution | Files in NGINX config dir | OCI artifact, signed, content-pinned |
| Plugin abuse → worker compromise | Possible (Lua heap shared with NGINX) | Bounded by VM (separate linear memory, fuel, capability list) |
| Plugin failure | Configurable (Lua error handling) | `on_panic deny |
| Plugin observability | NGINX log lines | Per-plugin metrics with capability-denial counters |
| Plugin upgrade | Edit Lua, reload | Update OCI tag + SHA, reload |
Verify a plugin is enforced:
# Confirm capability denial works.
curl -X POST https://api.example.com/api/test
# Look for plugin's capability_denied entry in NGINX error log.
tail -f /var/log/nginx/error.log | grep wasm
# 2026/04/27 12:00:00 [error] 12345#12345: *1 wasm: capability denied:
# proxy_dispatch_http_call (plugin: my-auth, request: /api/test)
# Confirm SHA pinning rejects mismatch.
echo "tampered" >> /etc/nginx/wasm/my-auth.wasm
nginx -t
# nginx: [emerg] wasm module my-auth: SHA mismatch:
# expected 1234..., got 5678...
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| WASM over Lua/njs | Stronger isolation; supply-chain story | Cold-start of WASM VM per worker (~10-50ms) | Use compiler_cache to avoid recompile across reloads. |
| Per-plugin memory cap | Bounded RSS per plugin | Multiplied by worker count; total host RSS planning needed | Pin worker_processes; calculate total. |
on_panic deny |
Fail-closed for security plugins | Plugin bug crashes all traffic | Stage new plugins behind feature flags; test in staging. |
| ABI capability restriction | Smallest plugin surface | Plugin authors must know exact capability needs | Document per-role capability templates; provide examples. |
| OCI plugin distribution | Same pipeline as containers | Registry must be reachable from NGINX hosts | Mirror to a local registry; use the periodic pull-and-verify pattern for air-gap. |
| SHA pinning | Tamper detection | Update flow needs SHA refresh | Automate in deploy pipeline. |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Plugin SHA mismatch on reload | nginx -t fails |
Validation message in error log | Update SHA in nginx.conf to match the deployed artifact. Pipeline should compute SHA atomically with the file change. |
Plugin allocates beyond max_memory |
Plugin traps; on_panic policy applied |
nginx_wasm_module_panics_total rises; per-plugin memory metric near cap |
Increase cap if legitimate; identify leak otherwise. |
| Plugin tries denied capability | Specific request feature breaks | capability_denied log lines |
Audit whether the capability is actually needed. Add only after review. |
| Worker OOM under combined plugin load | Worker process killed by kernel | dmesg shows oom-kill | Lower per-plugin max_memory or reduce worker_processes. |
| Reload loses plugin state | Cached JWKS, rate-limit counters reset | First requests after reload are slower or rate-limit windows reset | Use proxy_set_shared_data for state that should survive reload. |
| Plugin loads precompiled artifact | NGINX rejects with security warning | Log entry on first load | Keep allow_precompiled off. Build .wasm sources, not .cwasm. |
| ngx_wasm_module version skew | Plugin built against a newer module ABI fails | Module load errors at startup | Pin ngx_wasm_module and plugin SDK versions; upgrade in coordinated waves. |
When to Consider a Managed Alternative
Self-hosting NGINX with WASM extensions requires module compilation, plugin distribution, capability config, signing pipeline, and observability for every plugin (4-8 hours/month for a multi-plugin gateway).
- Kong Gateway: ships
ngx_wasm_moduleintegrated, with plugin distribution via the Kong Hub. - NGINX Plus with WAF Module: managed package with Kubernetes-native config and supply-chain controls.
- Cloudflare API Gateway: if your gateway is at the edge, switch from self-hosted NGINX to Cloudflare’s managed pipeline.