Build-time encryption for static HTML.

Veil turns selected HTML into authenticated ciphertext and a self-contained unlock page; deploy it to any HTTPS static host, with no application server.

Deterrence for previews and private-ish documents; not identity-based access control, and not for regulated or high-impact data.

Try it

This site is one deployment with two protected zones. One passphrase is disclosed; the other is not.

RouteKey scopePassphraseExpected
/demo/ y4le-veil-demo open-sesame decrypts
/demo/second.html y4le-veil-demo same as /demo/ no prompt after unlocking /demo/ in this tab
/vault/ y4le-veil-vault withheld stays locked

Walk it in one tab, leaving “Remember this device” unchecked:

  1. Open /demo/ and enter the passphrase; the page decrypts in your browser, not on the server.
  2. Follow its link to /demo/second.html; it opens with no prompt, because both pages came from one build and share one key scope.
  3. Follow its link to /vault/; it prompts again, and the demo passphrase is rejected. Separate build, separate passphrase, separate scope.
  4. Go back to /demo/; it opens straight away from the key cached for this session. Close the tab and it locks again.

What it does

  • The deployed artifact holds no protected plaintext. Each protected page ships as AES-256-GCM ciphertext plus a small unlock shell; local CSS and JS that Veil can inline go inside the ciphertext with it.
  • Pages decrypt in the browser. Web Crypto stretches the passphrase and unwraps the site key; the host serves static files and runs nothing.
  • Edited ciphertext fails to decrypt. AES-GCM authenticates every page, so tampering breaks it rather than rendering something else; swapping in a whole page, or an older build, is a different attack and is out of scope.
  • Subtrees can be protected separately. Each zone gets its own passphrase and its own browser-storage scope; this site is one such deployment.
  • The passphrase is never stored. Veil caches the site's master key for the tab, or for the device if the visitor asks; every rebuild mints a new key and invalidates the old one.
  • Builds are auditable before they ship. veil verify checks wrapper integrity, that the pages you meant to protect are protected, that the output corresponds to its input, and that everything decrypts.
  • One file, no runtime dependencies. veil.js is a single Node script; vendor it, pin the commit, record the digest.

Boundary

Veil is deterrence, not absolute security. It keeps selected HTML out of a plaintext deploy artifact and out of search indexes; it does not do the following.

  • The ciphertext is public, so guessing is offline and unlimited. PBKDF2 at 600,000 iterations raises the cost per guess; a weak passphrase still loses.
  • Only HTML is encrypted. Images, fonts, and data files stay public, and so do page count, file sizes, and directory structure.
  • A compromised host wins. Whoever serves the page can replace the unlock shell and capture the passphrase as it is typed.
  • Any JavaScript on the same origin wins. It can read every cached key out of browser storage, so every zone the visitor has unlocked is readable by it; separate zones limit that blast radius, they do not stop it. Keep analytics, chat widgets, and tag managers off the origin entirely.
  • There are no accounts and no revocation. Anyone with the passphrase can read the content and keep it; rotating the passphrase cannot retract what was already downloaded.
  • Whatever builds the site sees everything. The machine or CI runner that runs Veil holds the plaintext and the passphrase, and can replace the artifact before it is published; veil verify running in that same job cannot prove otherwise.
  • Veil protects the artifact, not the source. Both pages behind the doors on this site are plaintext in the public repository; what is demonstrated here is the wall, not the secret.

The full threat model states the boundary in detail.

How a build works

  1. Inline each page's local CSS and JS, so a protected page still styles and scripts itself under a strict content security policy.
  2. Mint one random 256-bit master key per build, and wrap it under a key derived from the passphrase with PBKDF2-SHA256.
  3. Encrypt every page with the master key under AES-256-GCM, each with its own IV and its own authenticated path binding.
  4. Replace the page with a wrapper: the payload, an unlock form, and a noindex robots meta. The real title exists only inside the ciphertext.

How it works covers inlining, asset omission, and unlock state.

Quick start

# typed, not left in shell history
read -rs VEIL_PASSPHRASE && export VEIL_PASSPHRASE

# encrypt a site into a fresh output directory
node veil.js ./my-site ./encrypted \
  --passphrase-env VEIL_PASSPHRASE --id my-project

# audit what you are about to publish
node veil.js verify ./encrypted --input ./my-site \
  --id my-project --passphrase-env VEIL_PASSPHRASE

Node.js 18+ and a host that serves over HTTPS; Web Crypto exists only in secure contexts, so a protected page needs HTTPS, localhost, or file:.

This site

Built by two chained Veil runs over one source tree, then verified once per zone before publishing.

public
/, /styles.css, /favicon.svg, /fonts/
zone 1
/demo/ · scope y4le-veil-demo · passphrase disclosed
zone 2
/vault/ · scope y4le-veil-vault · passphrase withheld
build
build-site.sh, run by pages.yml

This page is not encrypted; read its source and you will find it. Read the source of either protected route and you will find a payload.

It is also deployed on github.io, one origin shared with every other project site on the account; that is the situation the boundary above warns about, and it is acceptable here only because nothing on this site is secret. A deployment that matters gets its own hostname.