hometools

Password Protecting Static Site Content with WASM

April 2026

Heads Up

Security and encrypted are super complicated. I'm comfortable with this process given my understanding of the topics and the fact that I'm using a library for the encryption. But, I'm not an expert. You should consult with one if you're hosting critical content.

Introduction

I use static site generators for my sites. I call out to external APIs to do anything dynamic. The exception is password protection. I'd always thought that needed to be done dynamically on a site's host server. I realized with WASM that's not the case. Files can be encrypted and served statically with a WASM process providing decryption directly on the front end.

An Example

I've encrypted a file and put copy on my server. It's encrypt-decrypt-example.txt.bin. The file is static. You can download it directly. It won't do you much good, though. Being encrypted and all.

Enter the password "wasm" here and hit the "Decrypt File" button to view the content.

waiting for output

If you got the password right (it's all lowercase), you'll see the text "This is the content of the encrypted file." If you got it wrong, you'll get an incorrect password message.

How It Works

HTML

The HTML for the page boils down to a form with an input for the password, a submit button, and a hidden field to hold the URL of the file to decrypt.

<div class="example-box">
  <form class="decryptTextFile" action=".">
    <label>
      <div>Password:</div>
      <input type="password" name="password" />
    </label>
    <input type="hidden" name="url" value="samples/encrypt-decrypt-example.txt.bin" />
    <input type="submit" value="Decrypt File">
    <pre id="results">waiting for output</pre>
  </form>
</div>

JavaScript

The JavaScript is a module that does four things:

  1. Imports the WASM module responsible for the decryption.
  2. Creates a function to handle form submissions that trigger the decryption.
  3. Creates a main() function that initializes the WASM module and adds form event listeners.
  4. Calls the main() function to kick things off.
<script type="module">

import init, { decrypt_file } from "./pkg/static_site_file_decryption.js";

async function handleDecryptTextFileForm(event) {
  event.preventDefault();
  const formData = new FormData(event.target);
  const url = formData.get("url");
  const password = formData.get("password");
  const resultsEl = event.target.querySelector("#results");
  if (!password) {
    resultsEl.innerHTML = "A password is required";
    return;
  }
  const response = await fetch(url);
  try {
    if (!response.ok) {
      resultsEl.innerHTML =
        `ERROR: ${response.status} - ${response.statusText} - ${url}`;
    } else {
      const blob = await response.blob();
      const buffer = await blob.arrayBuffer();
      const bytes = new Uint8Array(buffer);
      const responseBytes = await decrypt_file(bytes, password);
      const text = new TextDecoder().decode(responseBytes);
      resultsEl.innerHTML = text;
    }
  } catch (error) {
    resultsEl.innerHTML = `ERROR: ${error}`;
  }
}

async function main() {
  await init();
  document.querySelectorAll(".decryptTextFile").forEach((form) => {
    form.addEventListener("submit", (event) => {
      handleDecryptTextFileForm(event);
    });
  });
}

main();

</script>

When a form submission fires, the script parses the incoming FormData (lines 7-14), does a fetch to pull in the encrypted file (starting on line 15), sends it to the wasm module (lines 21-24), then outputs the response (lines 25 and 26).

The JavaScript/WASM Connection

Communication between the JavaScript's handleDecryptTextFileForm() function and the WASM module is done by passing an arrays of bytes back and forth. This is done by loading the fetch response as a .blob() (line 21), grabbing it's .arrayBuffer() (line 22), and turning it into a Uint8Array (line 23) to send to the WASM decrypt_file function (line 24).

The bytes that come back from the WASM module are turned back in to text with TextDecoder() (line 25) and output to the page via an update to the result element's .innerHTML (line 26).

The WASM Code

I wrote the WASM module in Rust. It's basically just a wrapper around a library called orion that handles all the encryption work. The code looks like this:

src/lib.rs

use orion::{aead, kdf};
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn decrypt_file(
  bytes: Vec<u8>,
  password: String,
) -> Vec<u8> {
  let kdf_password =
    kdf::Password::from_slice(password.trim().as_bytes()).unwrap();
  let salt =
    kdf::Salt::from_slice(b"ca0beb1d-5a8a-4da9-b278-058087d00125")
      .unwrap();
  let kdf_key =
    kdf::derive_key(&kdf_password, &salt, 3, 1 << 16, 32).unwrap();
  match aead::open(&kdf_key, &bytes) {
    Ok(response) => response,
    Err(_) => "The password you entered\nwas incorrect."
      .as_bytes()
      .to_vec(),
  }
}

The only other file in the Rust project is the Cargo.toml file:

Cargo.toml

[package]
name = "static-site-file-decryption"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
getrandom = { version = "0.4.2", features = ["wasm_js"] }
orion = "0.17.13"
wasm-bindgen = "0.2.117"

The project is built with wasm-pack (which I installed with cargo install wasm-pack). I use this script to clean the pkg directory it outputs to before each run:

Build Script

#!/bin/bash

[ -e pkg ] && rm -rf pkg 
wasm-pack build --target web
[ -e "pkg/.gitignore" ] && rm "pkg/.gitignore"

Usage

This version of the tool is a prototype I built to figure out how everything works. The longer term goal is to build a version that works with multiple files instead of one at a time.

That said, this version works if you only need to deal with one file at a time. Because it's WASM, you don't need to compile the Rust code yourself. You can download these two files and line up the paths with the JavaScript above to set it up yourself.

You'll also need a way to encrypt files. I've set up a tool to do that at here: Static Site File Encryption Tool.

Wrap-up

As evident by the fact that I've been running a static site for like decade without a password feature, it's not something I have a lot of need for. There's been a few things over the years that I haven't done because of it. It's not a lot, but now I've got a solution.

-a

end of line

Endnotes

References