Connecting Go + WASM for a Personal Page
This site is a static Zine project. There is no app server, no session cookie, and no private API. Cloudflare Workers only serve files from public/.
That is a good setup for a personal page. It is a bad setup if you want a password in front of one post, because anything Zine writes into HTML is public forever.
The Quotes page is the experiment: keep plaintext off the published site, encrypt at build time in Go, and unlock in the browser with the same Go code compiled to WebAssembly. JavaScript only loads the WASM module, takes the password, and renders text. It never compares the password itself.
Official Zine starting points used throughout this write-up:
Why not just hide the quotes in HTML?
The first Quotes layout looped assets/quotes.json in SuperHTML. The page looked fine. View-source showed every quote.
A display:none form, a JS if (password === "..."), or a SHA-256 of the password in the script are the same class of mistake: the verifier and the secret sit in a file anybody can download.
For a static host the honest design is:
- Publish ciphertext, not quotes.
- Derive a key from the password with a memory-hard function (Argon2id).
- Decrypt with AES-256-GCM. Success is the auth tag. Failure is a generic “incorrect password”.
- Run that crypto in Go, not in ad-hoc JavaScript.
This is still client-side protection. Anyone can download quotes.sealed and try passwords offline. Use a strong password. Do not treat this as a vault for real secrets.
The pieces
| Path | Role |
|---|---|
private/quotes.json | Plaintext quotes. Not copied into public/. |
internal/quotevault | Shared Seal / Open (Argon2id + AES-GCM). |
cmd/seal-quotes | Build-time CLI. Writes assets/quotes.sealed. |
cmd/quotes-wasm | Same Open() compiled to GOOS=js GOARCH=wasm. |
assets/wasm_exec.js | Go’s JS runtime, copied from GOROOT. |
assets/quotes-gate.js | Fetch WASM + sealed blob, call unlockQuotes. |
layouts/quotes.shtml | Lock form. No quote loop. |
Makefile quotes-protect | Build WASM, copy wasm_exec.js, seal. |
.github/workflows/deploy.yml | Build and deploy the Worker. |
Sealed blob layout
quotevault writes a versioned binary, not JSON. The password is not in the file. Salt and nonce are random per seal, so resealing always changes the bytes.
| Offset | Field | Size | Why |
|---|---|---|---|
| 0 | QTV1 magic | 4 | Reject junk blobs before crypto |
| 4 | salt | 16 | Argon2id salt, from crypto/rand |
| 20 | nonce | 12 | AES-GCM nonce, never reused |
| 32 | ciphertext + tag | rest | Quotes JSON, authenticated |
Key derivation (browser-friendly, still memory-hard):
- Argon2id, time
2, memory32 MiB, threads1 - 32-byte key → AES-256-GCM
Wrong password, bit flip, or truncated file all return ErrInvalidPassword from Open. The WASM glue maps that to { ok: false } with an empty quotes string, so JS cannot tell “bad password” from “tampered blob”.
1. Shared Go: internal/quotevault
Seal and Open live in one package so the CLI and the WASM module cannot drift.
func Seal(password, plaintext []byte) ([]byte, error) {
if err := ValidateQuotesJSON(plaintext); err != nil {
return nil, err
}
salt := make([]byte, saltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, fmt.Errorf("salt: %w", err)
}
key := deriveKey(password, salt)
defer zero(key)
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("gcm: %w", err)
}
nonce := make([]byte, nonceSize)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("nonce: %w", err)
}
ciphertext := aead.Seal(nil, nonce, plaintext, []byte(magic))
blob := append([]byte(magic), salt...)
blob = append(blob, nonce...)
return append(blob, ciphertext...), nil
}
Open slices magic / salt / nonce / ciphertext, derives the same key, and aead.Opens. After a successful decrypt it validates JSON again (text + author required) so a corrupt-but-authenticated payload cannot reach innerHTML.
deriveKey is one call:
func deriveKey(password, salt []byte) []byte {
return argon2.IDKey(
password,
salt,
argonTime,
argonMemKiB,
argonThreads,
keySize,
)
}
Tests in internal/quotevault/quotevault_test.go cover round-trip, wrong password, tamper, short blob, and invalid JSON. Run them with:
$ make test-go
2. Build-time CLI: cmd/seal-quotes
The CLI is intentionally small: read JSON, seal, write bytes. The Makefile passes -password from QUOTES_PASSWORD (loaded from .env, which is gitignored).
plaintext, err := os.ReadFile(*inPath)
if err != nil {
fatal(fmt.Errorf("read quotes: %w", err))
}
blob, err := quotevault.Seal([]byte(*password), plaintext)
if err != nil {
fatal(fmt.Errorf("seal: %w", err))
}
if err := os.WriteFile(*outPath, blob, 0o644); err != nil {
fatal(fmt.Errorf("write sealed: %w", err))
}
Plaintext source looks like this (private/quotes.json). This file must not sit under assets/, or Zine will publish it.
[
{
"text": "Talk is cheap. Show me the code.",
"author": "Linus Torvalds"
}
]
3. Same Open, compiled to WASM
cmd/quotes-wasm is a js && wasm main. It parks on select {} so exported funcs stay alive, then bridges JS Uint8Array bytes into Go.
//go:build js && wasm
func main() {
js.Global().Set("unlockQuotes", js.FuncOf(unlockQuotes))
js.Global().Set("quotesWasmReady", js.ValueOf(true))
select {}
}
func unlockQuotes(_ js.Value, args []js.Value) (result any) {
defer func() {
if recover() != nil {
result = failResult()
}
}()
password := args[0].String()
n := args[1].Get("byteLength").Int()
sealed := make([]byte, n)
js.CopyBytesToGo(sealed, args[1])
plaintext, err := quotevault.Open([]byte(password), sealed)
if err != nil {
return failResult()
}
return map[string]any{"ok": true, "quotes": string(plaintext)}
}
Build (stripped, ~4.6 MiB — Cloudflare will Brotli it):
$ GOOS=js GOARCH=wasm go build -trimpath -ldflags='-s -w' \
-o assets/quotes.wasm ./cmd/quotes-wasm
$ cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/wasm_exec.js
wasm_exec.js must match the Go version that built the module.
4. JavaScript is only the loader
assets/quotes-gate.js does four jobs:
new Go()fromwasm_exec.js, instantiatequotes.wasm.fetchquotes.sealedasUint8Array.- On submit, call
unlockQuotes(password, sealedBytes). - Render with
textContent(noinnerHTML).
const go = new Go();
const response = await fetch(wasmUrl);
const streamed = await WebAssembly.instantiateStreaming(
response.clone(),
go.importObject
);
go.run(streamed.instance);
const result = globalThis.unlockQuotes(password, sealedBytes);
if (!result || !result.ok) {
setStatus("Incorrect password.", true);
return;
}
renderQuotes(JSON.parse(result.quotes));
instantiateStreaming needs Content-Type: application/wasm. The gate falls back to arrayBuffer() + WebAssembly.instantiate when the MIME type is wrong.
After a successful unlock, the plaintext JSON is kept in sessionStorage so a refresh in the same tab does not ask again. Closing the tab clears it.
5. The Quotes layout does not loop quotes
SuperHTML used to do $site.asset('quotes.json').ziggy(). That is gone. The layout only points at the two binaries:
<div id="quotes-app"
data-wasm="$site.asset('quotes.wasm').link()"
data-sealed="$site.asset('quotes.sealed').link()">
<form id="quotes-lock-form">
<input id="quotes-password" type="password" required>
<button type="submit">Unlock</button>
</form>
<div id="quotes-list" class="quotes" hidden></div>
</div>
zine.ziggy lists those files under static_assets so Zine copies them as-is (no Ziggy parse on a WASM binary):
.static_assets = [
"wasm_exec.js",
"quotes-gate.js",
"quotes.wasm",
"quotes.sealed",
]
6. Makefile: always reseal, rebuild WASM when Go changes
A file-stamp rule for quotes.sealed is a trap. Changing the password does not touch private/quotes.json, so Make reports Nothing to be done. quotes-protect is .PHONY and always runs seal-quotes. WASM still rebuilds only when sources change.
QUOTES_PASSWORD ?= $(shell cat .env | grep QUOTES_PASSWORD | cut -d '=' -f 2)
quotes-protect: $(QUOTES_WASM) $(WASM_EXEC)
$(GO) run ./cmd/seal-quotes -in $(QUOTES_IN) -out $(QUOTES_SEALED) -password $(QUOTES_PASSWORD)
$(WASM_EXEC):
cp "$$($(GO) env GOROOT)/lib/wasm/wasm_exec.js" $(WASM_EXEC)
$(QUOTES_WASM): cmd/quotes-wasm/main.go internal/quotevault/quotevault.go go.mod
GOOS=js GOARCH=wasm $(GO) build -trimpath -ldflags='-s -w' -o $(QUOTES_WASM) ./cmd/quotes-wasm
Local loop, from the Zine CLI:
$ make serve
# http://localhost:1990/blog/quotes/
serve and build both depend on quotes-protect, so a production public/ cannot ship without a fresh sealed blob.
7. Deploy the page to a Cloudflare Worker
The site is a Workers static assets project (wrangler.jsonc). Wrangler uploads ./public after make build. There is no custom Worker script; not_found_handling is a 404 page.
{
"name": "sandilya-karavadi-website",
"compatibility_date": "2026-06-19",
"build": {
"command": "echo 'Wrangler Build....' && make build"
},
"assets": {
"directory": "./public",
"not_found_handling": "404-page"
}
}
CI is .github/workflows/deploy.yml. On push or PR to master it checks out the repo, installs Node and Zine, injects secrets/vars, builds, and deploys.
name: Deploy Worker
on:
push:
branches:
- master
pull_request:
types:
- opened
- synchronize
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
name: Build & Deploy to Cloudflare
steps:
- name: Checkout Code
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
package-manager-cache: false
- name: Install Zine CLI
uses: kristoff-it/setup-zine@v2
- name: Install Dependencies
run: go mod download
- name: Set Environment Variables
run: |
echo "QUOTES_PASSWORD=${{ vars.QUOTES_PASSWORD }}" >> .env
echo "CLOUDFLARE_API_TOKEN=${{ secrets.CLOUDFLARE_API_TOKEN }}" >> .env
echo "CLOUDFLARE_ACCOUNT_ID=${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" >> .env
- name: Build the project
run: make build
- name: Login to Cloudflare
run: npx wrangler login
- name: Deploy to Cloudflare
run: npx wrangler deploy
- name: Clean up
run: make clean
What you must configure in GitHub before this job can seal quotes:
| Name | Where | Used for |
|---|---|---|
QUOTES_PASSWORD | Repository variable (vars.) | seal-quotes -password |
CLOUDFLARE_API_TOKEN | Repository secret | Wrangler deploy |
CLOUDFLARE_ACCOUNT_ID | Repository secret | Wrangler account |
Local deploy without Actions:
$ make build
$ npx wrangler deploy
npx wrangler login is interactive. On a headless runner, prefer CLOUDFLARE_API_TOKEN in the environment and skip the login step. make build also needs a Go toolchain on the runner (actions/setup-go) so go mod download and GOOS=js GOARCH=wasm go build succeed.
After deploy, /quotes.json must 404. /blog/quotes/ should show the lock, not the quotes. /quotes.wasm should be application/wasm.
What this does not do
- It does not hide
private/quotes.jsonfrom anyone who can clone the git repo. Only the published site is locked. Keep that file out of a public repo if the quotes themselves must stay private. - It does not rate-limit attackers. Argon2id just makes each guess slower.
- Go WASM is large. That is the cost of sharing one
quotevaultbetween CLI and browser.
The useful invariant is smaller than “secure website”: the published HTML never contains the quotes, and the published JavaScript never contains the password.