Password-Protected Drafts on a Static Site: StatiCrypt, Jekyll, and Honest Security Testing

9 minute read

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:

  1. Put the content in the page but hide it with JavaScript (not real security — content is in the source)
  2. 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:

  1. Write the post in _previews/my-post.md with preview_password: strong-password-here
  2. Push to master
  3. Share rorads.github.io/previews/my-post/ + the password

When it’s approved and ready to publish:

  1. Move to _posts/YYYY-MM-DD-my-post.md
  2. Remove the preview_password field
  3. 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.