Serving Wasm over HTTPS on localhost

This page answers one question: when does a WebAssembly project need HTTPS in local development, and what is the least painful way to provide it with a certificate your browsers actually trust?

Prerequisites

  • [ ] mkcert (brew install mkcert, winget install FiloSottile.mkcert, or a release binary on Linux).
  • [ ] A dev server that accepts a certificate and key: Vite, Node’s https module, Caddy, or nginx.
  • [ ] Administrator rights once, to install a local certificate authority.

Why localhost is usually enough — and when it is not

Browsers treat http://localhost and http://127.0.0.1 as secure contexts, the same as HTTPS. Features gated on a secure context — SharedArrayBuffer with cross-origin isolation, service workers, WebGPU, the Origin Private File System, crypto.subtle — all work on plain HTTP as long as the hostname is localhost. For a single developer testing on one machine, HTTPS is genuinely unnecessary.

Three situations change that. Testing from another device on the network means using an IP address or a hostname like laptop.local, which is not a secure context over HTTP. Using a custom hostname such as app.test to mirror production cookies or multi-domain setups loses the localhost exemption. And reproducing production behaviour around mixed content, HSTS or secure cookies requires the real scheme. Each of these is common in WebAssembly projects, because threaded builds, storage-heavy apps and GPU work all depend on secure-context APIs.

Which URLs count as a secure context Whether common development URLs are treated as secure contexts by browsers, which decides whether APIs such as SharedArrayBuffer, OPFS, service workers and WebGPU are available. URL secure context Wasm APIs that depend on it http://localhost:5173 yes all available http://127.0.0.1:5173 yes all available http://192.168.1.20:5173 no SAB, OPFS, WebGPU missing http://app.test:5173 no same as above https://192.168.1.20:5173 (trusted cert) yes all available

Step 1 — create a local certificate authority

mkcert creates a certificate authority on your machine and installs it into the system and browser trust stores. Certificates it issues are then trusted like any public certificate — no warning pages, no “proceed anyway”.

mkcert -install
Created a new local CA 💥
The local CA is now installed in the system trust store! ⚡️
The local CA is now installed in the Firefox trust store (requires browser restart)! 🦊

The CA’s private key stays on your machine, in the directory printed by mkcert -CAROOT. Never share it or commit it: anyone holding it can issue certificates your machine trusts for any domain.

Step 2 — issue a certificate for every name you use

List every hostname and address the dev server will be reached by. Include your LAN address if you test on phones:

mkdir -p .certs
mkcert -cert-file .certs/dev.pem -key-file .certs/dev-key.pem \
  localhost 127.0.0.1 ::1 app.test 192.168.1.20
echo ".certs/" >> .gitignore

A certificate only covers the names it was issued for. If your laptop’s IP address changes on another network, reissue it — the browser’s error will say the certificate is not valid for the address you used.

Step 3 — serve with Vite, Node or Caddy

Vite takes the files directly:

// vite.config.js
import fs from "node:fs";

export default {
  server: {
    host: true,                       // listen on the LAN, not just localhost
    https: {
      cert: fs.readFileSync(".certs/dev.pem"),
      key: fs.readFileSync(".certs/dev-key.pem"),
    },
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
};

A plain Node server needs only the https module:

import https from "node:https";
import fs from "node:fs";
import { handler } from "./static-handler.js";   // your existing request handler

https.createServer(
  { cert: fs.readFileSync(".certs/dev.pem"), key: fs.readFileSync(".certs/dev-key.pem") },
  handler
).listen(8443, "0.0.0.0");

Caddy is the shortest of all: for a name like app.test it issues its own locally trusted certificate automatically on first run, with no mkcert step:

# Caddyfile
app.test {
    root * ./dist
    file_server
    header {
        Cross-Origin-Opener-Policy same-origin
        Cross-Origin-Embedder-Policy require-corp
    }
}

Map app.test to 127.0.0.1 in your hosts file, run caddy run, and accept the one-time prompt to install Caddy’s CA.

Keeping certificates out of the repository and in the workflow

Certificates for local development sit in an awkward place: every developer needs one, but each must be generated on that developer’s machine, because each machine has its own CA. Committing certificates to the repository does not work — they would be signed by one person’s CA and trusted by nobody else’s browsers — and it trains people to commit private keys, which is a habit to avoid on principle.

The pattern that works is a script that generates the certificate on demand, checked by the dev command:

#!/usr/bin/env bash
# scripts/dev-cert.sh — create a dev certificate for this machine if missing
set -euo pipefail
mkdir -p .certs
if [[ ! -f .certs/dev.pem ]]; then
  command -v mkcert >/dev/null || { echo "install mkcert first"; exit 1; }
  ip=$(ipconfig getifaddr en0 2>/dev/null || hostname -I | awk '{print $1}')
  mkcert -cert-file .certs/dev.pem -key-file .certs/dev-key.pem localhost 127.0.0.1 ::1 "$ip"
fi

The dev server configuration should then fall back to plain HTTP when the files are absent, so a contributor who only needs localhost is not forced through the certificate setup. In Vite that is a conditional around the https option; for other servers, the same check before choosing https.createServer or http.createServer.

Treat the CA itself with the same care as any credential. It lives outside the project, in your user profile, and it is trusted by your whole machine — not just for this project. On a shared or managed machine, check with whoever manages it before installing one.

Step 4 — trust the CA on test devices

Your phone does not trust your laptop’s CA until you install it there. Export the root certificate and install it on the device:

cp "$(mkcert -CAROOT)/rootCA.pem" ~/Desktop/dev-root-ca.pem

On iOS, AirDrop or email the file, install the profile in Settings, then enable full trust for it under General → About → Certificate Trust Settings. On Android, install it under Security → Encryption & credentials → Install a certificate → CA certificate. Some Android apps ignore user-installed CAs, but Chrome honours them for browsing. The rest of the device workflow is in testing a Wasm app on a phone during development.

How a device comes to trust the dev server The developer creates a local CA, issues a certificate for the laptop's names and installs the CA on the phone. When the phone connects, the server presents the certificate, the phone verifies it against the installed CA, and the page loads as a secure context. developer laptop dev server phone browser mkcert -install; issue cert for 192.168.1.20 install rootCA.pem and enable trust GET https://192.168.1.20:5173 certificate signed by local CA chain verifies → secure context

Step 5 — verify the secure context, not just the padlock

A padlock means the connection is encrypted with a trusted certificate. What the Wasm app needs is a secure context and, for threads, cross-origin isolation. Check both from the console on the device you are testing:

console.table({
  secure: isSecureContext,
  isolated: crossOriginIsolated,
  sab: typeof SharedArrayBuffer === "function",
  opfs: typeof navigator.storage?.getDirectory === "function",
  gpu: "gpu" in navigator,
});

If secure is true but isolated is false, the certificate is fine and the problem is headers or a cross-origin subresource — see configuring COOP/COEP headers for SharedArrayBuffer.

Expected output

curl -sI --cacert "$(mkcert -CAROOT)/rootCA.pem" https://192.168.1.20:5173/ | head -3
HTTP/1.1 200 OK
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

On the device, the console table shows true in every row, and the threaded build starts its workers.

Gotchas

  • NET::ERR_CERT_AUTHORITY_INVALID on the phone. The CA is installed but not trusted. On iOS, the extra trust switch under Certificate Trust Settings is easy to miss.
  • NET::ERR_CERT_COMMON_NAME_INVALID. The address you used is not in the certificate. Reissue with the current IP address.
  • Firefox still warns after mkcert -install. Firefox uses its own trust store and needs a restart; on Linux, certutil from NSS must be installed for mkcert to reach it.
  • Self-signed certificates “accepted” with a click-through. Some APIs and service workers refuse to work on pages loaded with a bypassed certificate error. Use a trusted local CA instead of clicking through.

Performance note

TLS adds a handshake to the first connection — about 15 ms on a LAN — and nothing measurable to subsequent requests on the same connection. HTTPS also enables HTTP/2 in most dev servers, which multiplexes requests; on a page that loaded a module plus eighty small ES modules in Vite’s unbundled dev mode, cold load over HTTPS with HTTP/2 was faster than plain HTTP/1.1.

Cold dev-mode page load over HTTP/1.1 versus HTTPS with HTTP/2 A Vite dev page loading a 1.1 MB Wasm module and about eighty unbundled ES modules from a laptop, measured on a phone over Wi-Fi. HTTP/2 multiplexing outweighs the TLS handshake. ms to interactive, phone over Wi-Fi HTTP/1.1, plain 2,310 ms HTTPS + HTTP/2 1,740 ms

Frequently Asked Questions

Is mkcert safe to use? For development, yes — it is widely used. The risk is the CA key: anyone who obtains it can intercept your traffic. Keep it on your machine and uninstall the CA (mkcert -uninstall) when you no longer need it.

Can I use a real certificate from a public CA instead? For a real domain pointing at your LAN, yes — a DNS-validated certificate works on every device without installing anything. It is more setup but helpful for teams with many test devices.

Do I need HTTPS for Wasm itself? No. WebAssembly runs on plain HTTP. HTTPS matters only for the secure-context APIs many Wasm apps use alongside it.

Will HTTPS change how my module is cached in development? Browsers cache compiled WebAssembly per origin, and https://localhost:5173 is a different origin from http://localhost:5173. Switching schemes therefore starts with a cold compile cache, which can make the first load after the switch look slower than it really is.

What about tunnelling services? Tunnels give you a public HTTPS URL that forwards to your machine, which avoids certificates entirely for device testing. They route your traffic through a third party, so avoid them for anything sensitive.

← Back to Local Development Server Configurations