Password-Protected Drafts on a Static Site: StatiCrypt, Jekyll, and Honest Security Testing
The Problem
I’m writing this from Japan, where I’ve been working mostly from my phone via Claude Code. That context matters for why this was worth building: when you don’t have a laptop open, the natural way to preview a draft post is to push it and look at it on the live site. Which is fine until the post isn’t ready for the world to see.
A few drafts have been sitting with published: false — work that needs a colleague’s review, or comms sign-off, or just another pair of eyes before it’s indexed forever. The pieces themselves aren’t sensitive, but the workflow of sharing them was awkward.
The immediate thought — “just email them the draft” — falls over for a few reasons. A rendered, styled post is much easier to read than a raw markdown file. I want the person reviewing it to see it as it will actually look. And if that post contains images, diagrams, and formatted tables, “pasting it into an email” is not a great experience.
The second thought — “just set published: false in Jekyll” — is what I was already doing. That keeps it off the site entirely. But it also means nobody can see it, which rather defeats the purpose of sharing.
What I actually wanted was a way to publish to a URL, but keep the content behind a password. Simple in principle. Less simple on a static site with no server.
Why Static Sites Make This Interesting
GitHub Pages serves flat HTML files. There is no server executing your request, checking credentials, and deciding whether to send the page. The file is just there. This means:
- No basic auth — that’s the web server’s job, and there is no web server you control
- No session tokens — same problem
- No server-side rendering gated behind a login — same problem again
The only realistic options on a pure static site are client-side. Which means either:
- Put the content in the page but hide it with JavaScript (not real security — content is in the source)
- AES-encrypt the content before it goes in the page, and decrypt it client-side after the visitor provides the correct password (actually secure, if the KDF is decent)
Option 2 is what StatiCrypt does. It takes an HTML file, encrypts the whole thing with AES-256-CBC using a key derived from your password, and replaces the file with a self-contained password form that decrypts and renders the original page in the browser. No server. No special infrastructure. Just a well-encrypted blob sitting on a CDN.
What I Built
The implementation has three moving parts: a Jekyll collection for preview posts, a Python script that runs StatiCrypt after the build, and a GitHub Actions workflow that strings it together.
flowchart TD
subgraph Author["Writing"]
direction TB
draft["_previews/my-post.md\npreview_password: my-password"]
end
subgraph Build["GitHub Actions Build"]
direction TB
jekyll["bundle exec jekyll build"]
encrypt["python3 scripts/encrypt_previews.py"]
deploy["actions/deploy-pages"]
jekyll --> encrypt --> deploy
end
subgraph Live["Live Site"]
direction TB
form["rorads.github.io/previews/my-post/\n(AES-256 encrypted)"]
content["Full themed post\n(only visible with password)"]
form -->|"correct password"| content
end
draft --> jekyll
deploy --> form
style Author fill:#1e293b,stroke:#fff,stroke-width:1px,color:#fff
style Build fill:#1e293b,stroke:#fff,stroke-width:1px,color:#fff
style Live fill:#1e293b,stroke:#fff,stroke-width:1px,color:#fff
style draft fill:#6366f1,stroke:#fff,stroke-width:2px,color:#fff
style jekyll fill:#38bdf8,stroke:#fff,stroke-width:2px,color:#fff
style encrypt fill:#2d8cff,stroke:#fff,stroke-width:2px,color:#fff
style deploy fill:#10b981,stroke:#fff,stroke-width:2px,color:#fff
style form fill:#fbbf24,stroke:#1e293b,stroke-width:2px,color:#1e293b
style content fill:#10b981,stroke:#fff,stroke-width:2px,color:#fff
The Jekyll side
Preview posts live in a _previews/ collection. This keeps them completely separate from site.posts, which means they’re invisible to the RSS feed, the paginated homepage, the category and tag archives, and the Lunr search index — all without any additional configuration.
The collection is declared in _config.yml with sane defaults applied:
collections:
previews:
output: true
permalink: /previews/:name/
defaults:
- scope:
path: ""
type: previews
values:
layout: single
noindex: true
sitemap: false
search: false
search_exclude: true
share: false
related: false
The noindex: true flag triggers a <meta name="robots" content="noindex, nofollow"> tag injected via the custom head include. This is belt-and-suspenders given the content is already AES-encrypted — even if a crawler somehow got there, it wouldn’t index an encrypted blob. But it is also present in the StatiCrypt password form wrapper, so it covers the uninitiated version too.
A preview post looks like this:
---
title: "My Draft Post"
preview_password: "draft-review-2026-Kx7nP"
excerpt: "What this is about."
categories:
- technical
tags:
- python
---
Draft content here...
The encryption script
scripts/encrypt_previews.py runs after jekyll build. It reads each _previews/*.md file, pulls the preview_password from the YAML front matter, finds the rendered HTML at _site/previews/<name>/index.html, and passes it to StatiCrypt.
def encrypt_file(html_path: str, password: str) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
result = subprocess.run(
[
"staticrypt",
html_path,
"--password", str(password),
"--directory", tmpdir,
"--config", "false",
"--short",
"--template", _TEMPLATE,
],
capture_output=True, text=True,
)
shutil.move(os.path.join(tmpdir, "index.html"), html_path)
The --config false flag is important — it prevents StatiCrypt from reading or writing a .staticrypt.json salt file. Without it, the first run generates a salt, saves it to a config file, and uses it on subsequent runs. That’s fine locally but introduces state in CI that you’d need to manage. Stateless is cleaner here: each build generates a fresh random salt, which means the ciphertext changes on every deploy. Anyone who had the URL+password before the deploy still has the URL+password — they just need to re-enter it (no “Remember me” persistence across deploys, which is an acceptable trade-off for a draft review mechanism).
The scripts/ directory is excluded from the Jekyll build output via _config.yml, so neither the Python script nor the StatiCrypt template appear in _site/ or get served publicly.
GitHub Actions
The classic GitHub Pages build (push to branch, GitHub builds) doesn’t support arbitrary post-build steps. Moving to an Actions-based workflow was necessary to insert the encryption step. It’s a fairly standard Jekyll Actions setup, with StatiCrypt dropped in the middle:
- name: Build Jekyll
run: bundle exec jekyll build
env:
JEKYLL_ENV: production
PAGES_REPO_NWO: rorads/rorads.github.io
JEKYLL_GITHUB_TOKEN: $
- name: Install staticrypt
run: npm install -g staticrypt
- name: Encrypt preview pages
run: python3 scripts/encrypt_previews.py
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: _site
One small thing worth noting: PAGES_REPO_NWO and JEKYLL_GITHUB_TOKEN are needed because the jekyll-github-metadata gem — bundled in the github-pages gem — tries to call the GitHub API to resolve the repo’s base URL. Without authentication and the repo name, it fails with a hard error and aborts the build. This was the first thing that broke when the workflow went live.
Security Testing
Once it was up, I decided to actually try to break it rather than just assume it worked. I went through thirteen attack vectors with Claude Code.
flowchart LR
subgraph Passed["Passed ✓"]
direction TB
p1["View source — no plaintext"]
p2["robots.txt disallows /previews/"]
p3["noindex on password form"]
p4["Not in RSS feed"]
p5["Not in sitemap"]
p6["Not in lunr search"]
p7["No directory listing"]
p8["Alt URLs (.html, .md) → 404"]
p9["Source markdown inaccessible"]
p10["KDF: 600k iterations effective"]
end
subgraph Fixed["Fixed during test ✗→✓"]
direction TB
f1["scripts/ dir was publicly served"]
f2["staticrypt-template.html in sitemap"]
end
subgraph Advisory["Advisory"]
direction TB
a1["Weak passwords crackable\noffline via hashcat"]
end
style Passed fill:#0f2818,stroke:#10b981,stroke-width:1px,color:#fff
style Fixed fill:#2d1b00,stroke:#fbbf24,stroke-width:1px,color:#fff
style Advisory fill:#1e1b2e,stroke:#6366f1,stroke-width:1px,color:#fff
style p1 fill:#1e293b,stroke:#10b981,color:#fff
style p2 fill:#1e293b,stroke:#10b981,color:#fff
style p3 fill:#1e293b,stroke:#10b981,color:#fff
style p4 fill:#1e293b,stroke:#10b981,color:#fff
style p5 fill:#1e293b,stroke:#10b981,color:#fff
style p6 fill:#1e293b,stroke:#10b981,color:#fff
style p7 fill:#1e293b,stroke:#10b981,color:#fff
style p8 fill:#1e293b,stroke:#10b981,color:#fff
style p9 fill:#1e293b,stroke:#10b981,color:#fff
style p10 fill:#1e293b,stroke:#10b981,color:#fff
style f1 fill:#1e293b,stroke:#fbbf24,color:#fff
style f2 fill:#1e293b,stroke:#fbbf24,color:#fff
style a1 fill:#1e293b,stroke:#6366f1,color:#fff
The two issues found and fixed during testing:
scripts/ was publicly served. Jekyll copies all non-underscored, non-excluded files to _site/. The scripts/ directory wasn’t in the exclude: list, so encrypt_previews.py and the custom StatiCrypt template were live at /scripts/. Not a security hole — the script doesn’t contain passwords — but it does reveal the implementation stack unnecessarily. Added scripts/ to the exclude: list in _config.yml.
The custom template appeared in sitemap.xml. This was a consequence of the above. Fixed by the same exclusion.
The KDF: not as weak as it first appeared
The most interesting part of the test was analysing the key derivation. Running pbkdf2(password, salt, 1000, "SHA-1") is visibly in the page source, which initially looked alarming — 1,000 iterations of SHA-1 is very weak by modern standards.
But StatiCrypt 3.5.x uses three chained rounds for backwards compatibility:
flowchart LR
pw["Password"]
salt["Salt\n(public, 128-bit random)"]
r1["PBKDF2-SHA1\n1,000 iterations"]
r2["PBKDF2-SHA256\n14,000 iterations"]
r3["PBKDF2-SHA256\n585,000 iterations"]
key["AES-256 key"]
pw --> r1
salt --> r1
r1 -->|"hex string"| r2
salt --> r2
r2 -->|"hex string"| r3
salt --> r3
r3 --> key
style pw fill:#6366f1,stroke:#fff,color:#fff
style salt fill:#6366f1,stroke:#fff,color:#fff
style r1 fill:#1e293b,stroke:#38bdf8,color:#fff
style r2 fill:#1e293b,stroke:#2d8cff,color:#fff
style r3 fill:#1e293b,stroke:#2d8cff,color:#fff
style key fill:#10b981,stroke:#fff,color:#fff
The third round dominates: 585,000 PBKDF2-SHA256 iterations is close to the current OWASP recommendation (600,000). Benchmarking this properly in Python gave about 2.7 guesses/sec on a single CPU core. A GPU running hashcat would be significantly faster — but even at 2,000 guesses/sec, working through rockyou.txt (14 million passwords) takes roughly two hours. More importantly, the AES-256 encryption is not the bottleneck here; the only viable attack is on the password itself via the public salt and ciphertext.
The practical implication: the system is secure for any password that isn’t in a common wordlist. draft-review-2026-Kx7nP is safe. testpass123 is not — it would fall in seconds to anyone who extracted the ciphertext and salt from the page source (which is trivial — they’re in the page’s JS config object) and pointed hashcat at it.
Try It
There’s a test post at rorads.github.io/previews/test-preview/ with password testpass123. It demonstrates the password form and the full StatiCrypt → Minimal Mistakes rendering pipeline. This post exists specifically to be public — the password is intentionally weak and shared here; don’t use anything like it for real drafts.
Using It
For my own workflow:
To share a draft for review:
- Write the post in
_previews/my-post.mdwithpreview_password: strong-password-here - Push to
master - Share
rorads.github.io/previews/my-post/+ the password
When it’s approved and ready to publish:
- Move to
_posts/YYYY-MM-DD-my-post.md - Remove the
preview_passwordfield - Push — it goes live on the next deploy
The same post, just moved between directories and with one field removed. Nothing needs to be rewritten or reformatted.
What’s Next
The main limitation is that the password changes the ciphertext on every deploy (because the salt is regenerated). This means anyone relying on a “Remember me” cookie will need to re-enter the password after a push. For a review workflow this is fine — reviews don’t last months and you’re not checking the draft fifty times a day. But if I ever wanted persistent “subscribers” to a preview, I’d need to store the salt outside the CI run.
The other thing I’d like is per-file salts stored in the repo, so that the ciphertext (and therefore the shareable hash link) stays stable between deploys unless the content actually changes. That’s doable by generating salts on first run, committing them back, and using them on subsequent runs — but it adds state to the workflow I’d rather avoid for now.
For the moment, the simple version works well enough.