Completing the Puzzle for Encrypted Blogs: End-to-End Image Encryption
The Problem
My blog employs a two-layer encryption system: git-crypt encrypts source files at the Git level, and the frontend decrypts articles using AES-256-GCM in the browser. After this solution ran for some time, an obvious problem surfaced — all images in encrypted articles appeared as broken icons.
The reason is simple. Encrypted articles use relative paths in their Markdown:

However, after decryption, the article is rendered on the /blog/post.html?p=posts/2026/... page. The browser resolves the relative path to /blog/images/cross_competition_dynamics.png — a path that simply doesn't exist.
Solution Selection
The most straightforward idea was to Base64 encode images and embed them directly into the Markdown, packaging them together during encryption. However, after doing some calculations, this approach was abandoned:
- Single image 60KB-300KB
- Base64 encoding inflates size by approximately 33%
- It's common for an article to reference 14 images
- Embedded, the encrypted JSON could swell to several MB
The final solution is to encrypt images separately as independent files:
- Each image is encrypted with the article's
contentKey, generating a separate.encfile - Image paths in Markdown are rewritten to absolute paths pointing to the encrypted files
- After decrypting the article on the browser side, all images are asynchronously decrypted and displayed via Blob URL
This way, images and articles share the same key, and the user only needs to enter the password once.
Data Flow
Build Time (Node.js)
═══════════════════════════════════════════════════════════
Markdown Source File Image Source File
./cross-experiment.md ./images/foo.png
│ │
│ Scan  │ Read binary
▼ ▼
Rewrite path to absolute AES-256-GCM encrypt
/blog/encrypted/.../foo.png.enc (same contentKey)
│ │
▼ ▼
Encrypt Markdown text Write foo.png.enc
→ slug.json → {iv, ciphertext}
Browser Side
═══════════════════════════════════════════════════════════
User enters password
│
▼
PBKDF2 → Decrypt slot → Get articleKey
│
▼
SHA-256(articleKey) → contentKey
│
├──→ Decrypt article text → marked.js → HTML
│ │
│ Find img[src$=".enc"]
│ │
└──→ Parallel fetch all .enc ──→ AES-GCM decrypt
│
▼
Blob URL replaces img.src
│
▼
Images display correctly
Build-Side Implementation
Add a new processMarkdownImages() function in build-encrypted-posts.js, called before encrypting the article text:
function processMarkdownImages(body, mdFilePath, articleKeyString, outputSlugDir) {
const imageRegex = /!\[([^\]]*)\]\(\.\/images\/([^)]+)\)/g;
const mdDir = path.dirname(mdFilePath);
const contentKey = crypto.createHash('sha256').update(articleKeyString).digest();
const processedImages = new Set();
// First pass: encrypt all referenced images
let match;
while ((match = imageRegex.exec(body)) !== null) {
const imageName = match[2];
if (processedImages.has(imageName)) continue;
processedImages.add(imageName);
const imageData = fs.readFileSync(path.join(mdDir, 'images', imageName));
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', contentKey, iv);
const encrypted = Buffer.concat([cipher.update(imageData), cipher.final()]);
const tag = cipher.getAuthTag();
// Output as JSON formatted .enc file
const encImageData = {
iv: iv.toString('base64'),
ciphertext: Buffer.concat([encrypted, tag]).toString('base64')
};
const encImagePath = path.join(OUTPUT_DIR, outputSlugDir, 'images', imageName + '.enc');
fs.mkdirSync(path.dirname(encImagePath), { recursive: true });
fs.writeFileSync(encImagePath, JSON.stringify(encImageData));
}
// Second pass: rewrite image paths in Markdown
return body.replace(imageRegex, (_, alt, imageName) => {
return ``;
});
}
Build output example:
blog/encrypted/gol/
├── 2026-02-25-cross-experiment-en.json # Encrypted article
├── 2026-02-25-cross-experiment-en/
│ └── images/
│ ├── cross_competition_dynamics.png.enc # Encrypted image
│ ├── cross_competition_metrics.png.enc
│ └── cross_survival_dynamics.png.enc
Browser-Side Implementation
Modify tryDecrypt() to Return Content Key
Originally, tryDecrypt() only returned the decrypted plaintext string. Now it also returns the Base64 encoding of contentKey for subsequent image decryption:
async function tryDecrypt(password, encData) {
// ... PBKDF2 decrypts slot, gets articleKey ...
const contentKeyHash = await crypto.subtle.digest('SHA-256', enc.encode(articleKey));
const contentKey = await crypto.subtle.importKey(
'raw', contentKeyHash, 'AES-GCM', false, ['decrypt']
);
// Decrypt article
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: contentIv }, contentKey, ciphertext
);
// Return text and key; key is used for image decryption
const contentKeyB64 = btoa(String.fromCharCode(...new Uint8Array(contentKeyHash)));
return { plaintext: new TextDecoder().decode(plaintext), contentKeyB64 };
}
Add Image Decryption Function
After the article is rendered, scan all image tags ending with .enc and decrypt them in parallel:
async function decryptArticleImages(article, contentKeyB64) {
if (!contentKeyB64) return;
const images = article.querySelectorAll('img[src$=".enc"]');
if (images.length === 0) return;
const keyBytes = base64ToBuffer(contentKeyB64);
const contentKey = await crypto.subtle.importKey(
'raw', keyBytes, 'AES-GCM', false, ['decrypt']
);
await Promise.all(Array.from(images).map(async (img) => {
try {
img.style.opacity = '0.3'; // Visual feedback for loading
const resp = await fetch(img.src);
const encData = await resp.json();
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBuffer(encData.iv) },
contentKey,
base64ToBuffer(encData.ciphertext)
);
// Infer MIME type from filename
const ext = img.src.replace('.enc', '').split('.').pop().toLowerCase();
const mimeTypes = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml'
};
const blob = new Blob([decrypted], { type: mimeTypes[ext] || 'image/png' });
img.src = URL.createObjectURL(blob);
img.style.opacity = '';
} catch (e) {
console.error('Image decryption failed:', img.src, e);
img.style.opacity = '';
}
}));
}
Session Cache Must Also Store the Key
Store contentKeyB64 in the cache simultaneously, so that images can also be decrypted when loading articles from the cache:
function setCachedDecrypt(slug, content, contentKeyB64) {
sessionStorage.setItem(CACHE_PREFIX + slug, JSON.stringify({
content, contentKeyB64, ts: Date.now()
}));
}
An Unexpected Bonus
During development, a bug was discovered: .png files in the _content/crypt-posts/gol/images/ directory were being scanned by the build system as blog posts, causing 23 images to appear in the homepage article list.
It turned out that this directory contained a .no_posts marker file, meaning "this directory does not contain posts," but the build system wasn't checking for it. A one-line fix:
function findPostFiles(dir, files = []) {
// Skip directories containing a .no_posts marker
if (fs.existsSync(path.join(dir, '.no_posts'))) return files;
// ...
}
Summary
This change resolved the last pain point for encrypted articles. The design principles are key reuse and file separation: images and articles use the same contentKey for encryption but are stored and transmitted independently. The user only needs to enter the password once, and the article text and all images will be decrypted and displayed sequentially.
In terms of security, the encryption strength for images and articles is identical (AES-256-GCM), and their key lifecycle is also the same — cached in sessionStorage for 15 minutes before being automatically cleared.