Client-side encrypted content for Hugo static sites with multi-user access control. Pages marked for encryption are processed at build time with AES-256-GCM and per-user key wrapping. Visitors enter a username and password in the browser to decrypt and view content. Zero server-side components.
Motivation
You want to host private or protected content on a static site without any server-side backend. Maybe you have medical records, internal documentation, family-only content, or team notes that should be password-protected.
Existing solutions either require a backend server, use simple password-per-page approaches with no user management, or aren't well-integrated with Hugo. Cryptoid gives you real multi-user access control with groups, cascade encryption for entire directories, per-user credentials, and all while keeping the simplicity of a static site. Content is encrypted at build time and decrypted in the browser — your host (GitHub Pages, Netlify, etc.) never sees plaintext.
Installation
Quick Start
1. Create configuration file
Use the interactive setup:
Or create .cryptoid.yaml manually in your Hugo site root:
users: alice: "strong-passphrase-for-alice" bob: "strong-passphrase-for-bob" carol: "strong-passphrase-for-carol" groups: admin: [alice] # admin group gets access to ALL encrypted content team: [alice, bob, carol] members: [bob, carol] salt: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" # 32 hex chars, generated by init
Add .cryptoid.yaml to your .gitignore.
2. Mark content for encryption
Protect a directory (creates _index.md cascade):
cryptoid protect content/private/ --groups team
Or protect individual files:
cryptoid protect content/secret-post.md --groups admin --hint "Ask the team lead"Or add encrypted: true directly to front matter:
--- title: "Secret Notes" encrypted: true groups: ["team"] password_hint: "The usual" remember: "ask" --- Your secret content here...
3. Set up Hugo integration
Or manually copy the files:
cryptoid hugo status # check what's installed4. Build workflow
# Validate configuration cryptoid validate --content-dir content/ # Encrypt marked pages cryptoid encrypt --content-dir content/ # Build your site hugo # Restore plaintext for git cryptoid decrypt --content-dir content/
How It Works
Multi-User Key Wrapping
Each encrypted page uses a random Content Encryption Key (CEK). The CEK is then wrapped (encrypted) individually for each authorized user using a key derived from "username:password" via PBKDF2. This means:
- Adding/removing users only requires re-wrapping the CEK, not re-encrypting content
- Each user has their own credentials — no shared passwords
- The browser only needs one PBKDF2 derivation per login attempt
Cascade Encryption
Encryption settings propagate from _index.md files to all content in that directory and subdirectories:
content/
_index.md # encrypted: true, groups: ["all"]
public-post.md # encrypted: false (explicit opt-out)
private/
_index.md # encrypted: true, groups: ["team"] (overrides parent)
notes.md # inherits: encrypted for team + admin
internal/
_index.md # encrypted: true, groups: ["admin"] (overrides)
strategy.md # inherits: encrypted for admin only
Rules:
- Nearest
_index.mdwithencryptedfield wins encrypted: falseon a file overrides any cascade_index.mdbodies are encrypted like any other file; only the front matter stays readable for cascade resolution- A file's
groupsreplaces inherited groups (no merging)
Admin Group
If a group named admin exists in your config, its members automatically get access to all encrypted content regardless of which groups a file specifies.
CLI Reference
Content Configuration
# Interactive mode: browse and select files/directories to protect/unprotect cryptoid protect -i [--config PATH] # Mark a directory for encryption (creates/updates _index.md) cryptoid protect content/private/ --groups team # Mark a file for encryption cryptoid protect content/secret.md --groups admin --hint "Ask the lead" --remember session # Remove encryption from a directory cryptoid unprotect content/private/ # Remove encryption from a file (sets encrypted: false as cascade override) cryptoid unprotect content/secret.md
Crypto Operations
# Encrypt all marked content cryptoid encrypt --content-dir content/ --config .cryptoid.yaml # Preview what would be encrypted cryptoid encrypt --content-dir content/ --dry-run # Decrypt all encrypted content cryptoid decrypt --content-dir content/ --config .cryptoid.yaml
Configuration Management
# Initialize a new config file (interactive setup) cryptoid init [--config .cryptoid.yaml] # Show config file location and status cryptoid config status # Display full config file with source annotations cryptoid config show [--config PATH] [--show-passwords] # Validate config and content consistency cryptoid config validate --content-dir content/ [--config PATH] # Manage users cryptoid config list-users [--config PATH] cryptoid config add-user [--config PATH] cryptoid config remove-user USERNAME [--config PATH] # Manage groups cryptoid config list-groups [--config PATH] cryptoid config add-group GROUP_NAME [--config PATH] cryptoid config remove-group GROUP_NAME [--config PATH] cryptoid config add-to-group GROUP_NAME USERNAME [--config PATH] cryptoid config remove-from-group GROUP_NAME USERNAME [--config PATH] # Generate or manage salt cryptoid config generate-salt [--apply] [--global]
Maintenance
# Show encryption status of all files cryptoid status --content-dir content/ --config .cryptoid.yaml cryptoid status --verbose # include group/user statistics # Re-wrap keys after changing users/groups (no decrypt needed) cryptoid rewrap --content-dir content/ --config .cryptoid.yaml # Re-wrap with new CEK for forward secrecy (after removing a user) cryptoid rewrap --content-dir content/ --rekey
Hugo Integration
cryptoid hugo status # check installation cryptoid hugo install # install shortcode + JS cryptoid hugo uninstall # remove cryptoid files
Configuration
.cryptoid.yaml
users: alice: "alice-password" bob: "bob-password" groups: admin: [alice] # special: universal access team: [alice, bob] # Optional: shared PBKDF2 salt (32 hex chars = 16 bytes) # salt: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
Front Matter Options
| Field | Type | Default | Description |
|---|---|---|---|
encrypted |
bool | false |
Enable encryption for this page |
groups |
list | all users | Group names that get access |
password_hint |
string | null |
Hint displayed on the login form |
remember |
string | "ask" |
Credential storage behavior |
Remember Options
"none"-- Never store credentials, always prompt"session"-- Store in sessionStorage (cleared when tab closes)"local"-- Store in localStorage (persists across sessions)"ask"-- Show checkbox, let visitor decide
Known Limitations
Despite strong encryption and multi-user management, cryptoid has a few important limitations:
External assets aren't encrypted: Images, PDFs, and other media referenced in an encrypted markdown file are NOT encrypted. If your markdown has , the image is still publicly accessible at that URL. Only the markdown body text is encrypted.
Front matter / YAML header is visible: The front matter (title, date, groups, etc.) remains in plaintext because Hugo needs it for site building and cryptoid needs it for cascade resolution. So an attacker can see page titles and which groups have access.
Section list pages may reveal structure: Hugo's list pages and taxonomy pages may reveal the existence and titles of encrypted content even though the body is encrypted.
No protection for Hugo-generated metadata: RSS feeds, sitemaps, search indexes, and other Hugo outputs may contain titles or excerpts from encrypted pages.
Despite these limitations, cryptoid is still excellent for hosting private content on a static site. It provides strong encryption for content bodies, proper multi-user management, group-based access control, and all without any server infrastructure.
Security
Cryptographic Parameters
| Parameter | Value |
|---|---|
| Cipher | AES-256-GCM |
| KDF | PBKDF2-SHA256 |
| Iterations | 310,000 |
| Salt | 16 bytes (random per page) |
| IV | 12 bytes (random per encryption) |
| Key wrapping | AES-256-GCM per user |
| Auth Tag | 128 bits |
| Content hash | Truncated SHA-256 (128 bits) |
Ciphertext Format
Base64-encoded JSON (v2):
{
"v": 2,
"alg": "aes-256-gcm",
"kdf": "pbkdf2-sha256",
"iter": 310000,
"salt": "<base64 16 bytes>",
"iv": "<base64 12-byte content IV>",
"ct": "<base64 AES-GCM encrypted content>",
"keys": [
{"iv": "<base64 12-byte wrap IV>", "ct": "<base64 wrapped CEK>"},
...
]
}Threat Model
What cryptoid protects against:
- Casual browsing of protected content
- Search engine indexing of encrypted content
- Static analysis of your HTML files
What cryptoid does NOT protect against:
- Weak passwords (use strong, unique passphrases)
- Malicious JavaScript injection on your site
- Someone with access to your
.cryptoid.yaml - Shoulder surfing / screen capture
Development
pip install -e ".[dev]"
pytest tests/ -v
pytest tests/ --cov=cryptoid --cov-report=term-missingLicense
MIT