Sandilya Phani Kumar Karavadi ⚡️⚡️

Connecting Go + WASM for a Personal Page

October 25, 2026 • 16 min read • by Phani Kumar

How a static Zine site locks a blog page with Go, WebAssembly, JavaScript, and Argon2id + AES-GCM.

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:

  1. Publish ciphertext, not quotes.
  2. Derive a key from the password with a memory-hard function (Argon2id).
  3. Decrypt with AES-256-GCM. Success is the auth tag. Failure is a generic “incorrect password”.
  4. 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

PathRole
private/quotes.jsonPlaintext quotes. Not copied into public/.
internal/quotevaultShared Seal / Open (Argon2id + AES-GCM).
cmd/seal-quotesBuild-time CLI. Writes assets/quotes.sealed.
cmd/quotes-wasmSame Open() compiled to GOOS=js GOARCH=wasm.
assets/wasm_exec.jsGo’s JS runtime, copied from GOROOT.
assets/quotes-gate.jsFetch WASM + sealed blob, call unlockQuotes.
layouts/quotes.shtmlLock form. No quote loop.
Makefile quotes-protectBuild WASM, copy wasm_exec.js, seal.
.github/workflows/deploy.ymlBuild and deploy the Worker.
build machine private/quotes.json + QUOTES_PASSWORD seal-quotes (Go) quotevault.Seal quotes.sealed quotes.wasm wasm_exec.js zine release static public/ Cloudflare Worker serves files only browser JS + Go WASM Open()
Go seals quotes at build time. The Worker never sees the password. The browser unlocks with the same Go Open().

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.

OffsetFieldSizeWhy
0QTV1 magic4Reject junk blobs before crypto
4salt16Argon2id salt, from crypto/rand
20nonce12AES-GCM nonce, never reused
32ciphertext + tagrestQuotes JSON, authenticated
Associated data for GCM is the magic string, so a truncated or swapped header fails closed.

Key derivation (browser-friendly, still memory-hard):

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:

  1. new Go() from wasm_exec.js, instantiate quotes.wasm.
  2. fetch quotes.sealed as Uint8Array.
  3. On submit, call unlockQuotes(password, sealedBytes).
  4. Render with textContent (no innerHTML).
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.

page / JS quotes.wasm unlockQuotes quotevault Open() DOM password + blob Argon2id JSON
The password crosses into Go WASM once. JavaScript only receives ok + quotes JSON, then writes text nodes.

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
git push Actions Zine + Go make build seal + zine wrangler deploy public/ → Worker
The password used to seal quotes is a GitHub Actions variable, not a file in git.

What you must configure in GitHub before this job can seal quotes:

NameWhereUsed for
QUOTES_PASSWORDRepository variable (vars.)seal-quotes -password
CLOUDFLARE_API_TOKENRepository secretWrangler deploy
CLOUDFLARE_ACCOUNT_IDRepository secretWrangler 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

The useful invariant is smaller than “secure website”: the published HTML never contains the quotes, and the published JavaScript never contains the password.


References


← Quotes   or   Back to the Homepage