Open-source project
D0n9X1n/hexo-blog-encrypt avatar
D0n9X1n/hexo-blog-encrypt

hexo-blog-encrypt encrypts posts at build time and unlocks them in the browser

Yet, just another hexo plugin for security.

1,052 stars108 forksJavaScriptMIT

At a glance

What is it?
hexo-blog-encrypt is a Hexo plugin that replaces a post body with a password form and ships only ciphertext in the generated HTML, decrypting in the reader's browser with the Web Crypto API. There is no server in the loop, and the security of the whole thing rests on a password you distribute out of band.
Who is it for?
Adopt hexo-blog-encrypt when you run a static Hexo blog and want selected posts readable only with a password, and you accept that the passphrase reaches the reader through a channel outside the site. Do not adopt it to hide anything from the person who can read the repository or the build logs, because the password lives in front matter or in _config.yml.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 9 days ago.
What is it written in?
Mainly JavaScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 21, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Ciphertext in the HTML, decryption in the reader's browser

The plugin's job is described in three sentences at the top of the README: password protect individual Hexo posts, encrypt the content at build time with AES-256-GCM, and decrypt it in the reader's browser with the Web Crypto API. No server, and no plaintext in the generated HTML.

That last phrase is the architectural commitment, and it is the one that makes the plugin interesting. In a conventional password protected post, the server holds the content and checks the password, which means the deployment needs a backend, a session, and a way to keep the content out of the page source. Here the site stays a static site. The build step encrypts each protected post, writes the ciphertext into the generated file, and the browser does the decryption.

The consequence for the reader is a password form where the body used to be, and for the reader of the page source is a blob of ciphertext rather than the text. The consequence for the operator is that there is nothing to authenticate against on a server, so the passphrase itself has to be delivered to the reader out of band, by message or by whatever channel the post is advertising.

A live demo exists for this reason, and every password on it is hello. The demo covers a theme gallery, the decrypt callback and the script loading order with cached keys, so you can try the behaviour before installing anything.

Per-post salts, fresh nonces, and one option that changes both

The cryptographic choices are specific, and the highlights table names them. Key derivation is PBKDF2 with SHA256, and the cipher is AES-256-GCM. The GCM authentication tag is what rejects a wrong password and what rejects tampered ciphertext, so a wrong password produces a decryption failure rather than garbage text.

Each post gets its own 32-byte salt and its own 12-byte nonce, and the reason given is worth repeating: one cracked post does not speed up attacks on the others. That is the standard reason to use a per-item salt, and it is applied here at the level of a single blog post rather than a single user.

Two configuration options change that behaviour, and they pull in opposite directions. The autoSave option caches the derived AES key in localStorage so that a reload does not ask for the password again, with entries stored under hbe.v4.<page path>. The stableSalt option keeps the PBKDF2 salt stable across clean builds for the same post permalink, which matters on platforms such as Cloudflare Pages, Vercel, Netlify and GitHub Actions that rebuild the generated HTML from a clean environment on every deploy. Without it, every rebuild would produce a new salt and invalidate every cached key.

The documentation is explicit about the limit of that stability. Only the salt is stable, and the AES-GCM nonce stays random for every encryption to avoid nonce reuse. If the post permalink changes, the stable salt changes too and the page's existing localStorage cache is invalidated.

The iteration count has a floor of 100_000, a default of 250_000, and a recommendation of 600_000 or more, with values below the recommendation producing a build warning.

Configuration resolves in a fixed precedence order

Settings are resolved in one order, and it is stated in the configuration section: post front matter takes precedence over the _config.yml encrypt block, which takes precedence over the built-in defaults.

The front matter is where a per-post password goes, and the same block also carries the text shown around the form.

markdown
---
title: Hello World
tags:
- encryptAsDiary
date: 2016-03-30 21:12:21
password: mikemessi
abstract: Here's something encrypted, password is required to continue reading.
message: Hey, password is required here.
wrong_pass_message: Oh, this is an invalid password. Check and try again, please.
theme: xray
---

The site-wide block in _config.yml holds the defaults and the tag mapping.

yaml
encrypt: # hexo-blog-encrypt
  abstract: Here's something encrypted, password is required to continue reading.
  message: Hey, password is required here.
  wrong_pass_message: Oh, this is an invalid password. Check and try again, please.
  theme: default
  silent: false

Below that sit the tags block, which maps tag names to passwords, and the decryptButton block, which controls the visible Decrypt button next to the password field.

One precedence detail catches people out: a front-matter password takes precedence over a tag password, so a post tagged for encryption still uses its own password if it has one.

Eight themes and the scripts that run after decryption

The theme list is explicit, with eight names, default, blink, flip, shrink, surge, up, wave and xray, and the instruction for adding your own is to drop in one HTML file. The theme is selected per post through the front matter theme key or site-wide in the encrypt block, so different posts can present different password forms.

That flexibility creates a second problem, which is the table of contents. A blog theme renders a table of contents from the post headings, and a heading is not encrypted, so a table of contents can reveal the structure of a post whose body is locked. The project ships a Claude Code skill, hexo-encrypt-toc, that fixes any theme's table of contents for encrypted posts without leaking headings. That is a small artifact and a telling one, because it shows the failure mode is structural rather than a bug in one theme.

Scripts get their own rule. Scripts inside a decrypted post run in document order, and a hexo-blog-decrypt event fires when the post is revealed. The event is the hook for anything that has to happen once the content is visible, such as a syntax highlighter that scans the code blocks that were just injected.

Privacy is handled by default rather than by configuration. Passwords and keys are not stored unless you opt in with autoSave, and the runtime dependency count is zero, with the build using the Node.js crypto module and the browser using the Web Crypto API, so there is no third party code in the decryption path of your readers.

What this plugin does not protect you from

It is worth being blunt about the boundaries, because the mechanism is strong in one direction and absent in another.

First, the content is protected in the published HTML, not in your repository. The password lives in the front matter of the post or in the encrypt block of _config.yml, and anyone with access to the repository or to the build environment can read it. If the threat is someone reading your published site, this works. If the threat is someone with commit access or CI log access, it does not.

Second, the plaintext is visible to the reader after they enter the password, and the derived key can be cached in localStorage when autoSave is on. That is a deliberate convenience, and it is opt-in, but it does mean that a browser extension or an XSS on the site can read a key that was meant to be temporary.

Third, the abstract is not encrypted. Index pages show the abstract text instead of the content, which is the point, but it does mean the summary of a post is always public and the headings are public unless the table of contents skill is applied.

Fourth, the passphrase has to travel to the reader. In a static site there is no server to check it against, so the distribution of the password is a communication problem, not a technical one.

The test suite gates on 100 percent coverage and three browsers

The scripts in the package manifest show a project with an unusually strict gate.

The server test command runs node --test over the server test files and the docs and skills tests, under c8, with coverage thresholds of 100 for lines, functions, branches and statements, and the check-coverage flag enabled. The published badge reflects 100 percent server line, branch and function coverage, and the test script runs lint first, then the server tests, then the end to end tests.

The end to end suite is a Playwright run that installs chromium, firefox and webkit and then executes the tests against a real Hexo site fixture, in tests/fixtures/hexo-site. The fixtures directory is prepared by a helper script that installs the site dependencies on demand, and the Playwright config lives at tests/e2e/playwright.config.js.

A password form that renders differently in each browser, and whose decryption depends on the Web Crypto API, is exactly the case where a unit test is not enough, so the multi browser run is a requirement rather than a nicety.

The build itself is not Vite or a bundler, it is a node build/build.js run by the build script and by prepack, and there is a separate prepare script that runs on install. The package publishes index.js, src/server/ and lib/ as its files, and it is mirrored to GitHub Packages as @d0n9x1n/hexo-blog-encrypt, with npm provenance enabled.

Version 4, MIT, and the shape of the release train

The repository is not archived, and the last push was on 2026-10-13. The recent releases are v4.1.0 on 2026-10-13, v4.0.3 on 2026-09-05 and v4.0.2 on 2026-06-07, so the project is shipping actively on a 4.x line.

The major version is meaningful here, because the cached key entry format is versioned in the localStorage key itself, hbe.v4.<page path>. A change in how keys are stored or looked up would invalidate every cache, so the v4 prefix is a compatibility marker rather than decoration.

The requirements are Node.js 18 or later and Hexo 5 or later, with hexo declared as a peer dependency rather than a direct dependency, so the plugin uses whatever Hexo the site already runs. There are no runtime dependencies at all, which is why the badge in the README reads runtime dependencies 0.

The licence is MIT, and the repository carries both an English README and a Chinese one, a wiki directory, a demo directory with its own Hexo site, and a skills directory holding the table of contents skill. The wiki is where the development notes live, including the coverage badge link, and it is also where the longer configuration reference belongs, since the README already runs long.

Editorial conclusion

Adopt hexo-blog-encrypt when you run a static Hexo blog and want selected posts readable only with a password, and you accept that the passphrase reaches the reader through a channel outside the site. Do not adopt it to hide anything from the person who can read the repository or the build logs, because the password lives in front matter or in _config.yml. Verify first that the passphrase is never shipped in the built HTML, that autoSave is left off unless you accept a derived key sitting in the reader's localStorage, and that kdf iterations are at or above the recommended 600_000 rather than the 250_000 default, which logs a build warning.

Frequently asked questions

How does hexo-blog-encrypt protect a Hexo post?

The post body is encrypted at build time with AES-256-GCM using a PBKDF2-SHA256 derived key, and the generated HTML contains only ciphertext. The reader's browser decrypts it with the Web Crypto API after entering the password, so no server is involved.

How do I install and set up hexo-blog-encrypt?

Run npm install --save hexo-blog-encrypt, which requires Node.js 18 or later and Hexo 5 or later. Then add a password to the post front matter and rebuild with hexo clean && hexo generate && hexo server.

Can I encrypt posts by tag instead of setting a password on each one?

Yes. The encrypt block in _config.yml has a tags list that maps tag names to passwords, so every post carrying one of those tags is encrypted. A password set in the post front matter takes precedence over a tag password.

What does the autoSave option do?

It caches the derived AES key in localStorage so a reload does not ask for the password again, with entries stored under hbe.v4.<page path>. It is off by default, and passwords and keys are not stored unless you opt in.

Why does my encrypted post still show a table of contents?

Headings are not encrypted, so a theme that renders a table of contents from them can leak the structure of a locked post. The project ships a skill, hexo-encrypt-toc, that fixes the table of contents for encrypted posts in any theme without leaking headings.

How many themes does hexo-blog-encrypt support?

Eight are bundled, named default, blink, flip, shrink, surge, up, wave and xray, and you can add your own by dropping in one HTML file. The theme is selected per post in front matter or site-wide in the encrypt block.

Official sources

  1. D0n9X1n/hexo-blog-encrypt on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/d0n9x1n-hexo-blog-encrypt.svg)](https://hysenlabs.com/projects/d0n9x1n-hexo-blog-encrypt)

Was this useful?