Search

Prototyping Electron encrypted SQLite libraries — running better-sqlite3-multiple-ciphers and @journeyapps/sqlcipher on macOS and Windows

Tadashi Shigeoka · Fri, August 28, 2026

The previous post, Comparing encrypted SQLite libraries for Electron, built a desk-based ranking: better-sqlite3-multiple-ciphers as the primary pick, @signalapp/sqlcipher and the SQLite Encryption Extension (SEE) as conditional second and third options, and @journeyapps/sqlcipher as a poor fit for cross-platform Electron apps because v6.0.0 dropped Windows support.

A paper comparison leaves questions that only real builds can answer: whether N-API prebuilds actually work on the target ABIs without a rebuild, whether electron-builder unpacks the native modules from asar as expected, and whether Windows packaging drops the .node binary in the right place ahead of signing. To answer those, I added three Electron Todo recipes to codenote-net/app-distribution-recipes and drove each through CRUD and encrypted-header verification on both macOS and Windows. The short version is that the ranking from the previous post held up in the field.

Prototyping frame

Each recipe drives the same minimal Todo CRUD app through a different library and build configuration. The UI, IPC boundary, and key-handling patterns are shared so that the delta between recipes is the library itself.

The corresponding pull requests are:

The shared specification looks like this:

  • Runtime: Electron 44.0.0, electron-builder 26.15.3, Node.js 22 or later (an LTS line at the time of writing).
  • The renderer runs with contextIsolation and sandbox enabled, and reaches the database only through a narrow IPC surface owned by the main process.
  • The encryption key is a hard-coded demonstration string. A production build would combine safeStorage with a key-derivation function that follows the OWASP Password Storage Cheat Sheet, as described in the previous post.
  • The smoke test runs CRUD, closes the database, then reads the first 16 bytes of the file and asserts that the header is not SQLite format 3\0. That single check catches missing PRAGMA key calls and ordering mistakes at packaging time.
const header = fs.readFileSync(databasePath).subarray(0, 16).toString("utf8");
assert.notEqual(header, "SQLite format 3 ");

Running better-sqlite3-multiple-ciphers on macOS and Windows

The first recipe uses better-sqlite3-multiple-ciphers 13.0.3 as-is. The database wrapper is a straightforward synchronous API, and electron-builder’s install-app-deps swaps in the N-API prebuild that matches Electron 44’s ABI.

The core of the wrapper hands a raw buffer key to Database#key, turns on WAL mode, and calls prepare the usual way:

const Database = require("better-sqlite3-multiple-ciphers");
 
class TodoDatabase {
  constructor(filePath, encryptionKey) {
    this.database = new Database(filePath);
    this.database.key(Buffer.from(encryptionKey, "utf8"));
    this.database.pragma("journal_mode = WAL");
    this.database.exec(`
      CREATE TABLE IF NOT EXISTS todos (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
        completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1)),
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
      )
    `);
    // ... prepared statements
  }
}

The electron-builder side stays small. All it does is unpack the native module out of asar and list the DMG/ZIP/NSIS/portable targets:

{
  "build": {
    "asarUnpack": [
      "node_modules/better-sqlite3-multiple-ciphers/**/*"
    ],
    "mac": { "target": ["dmg", "zip"] },
    "win": { "target": ["nsis", "portable"] }
  }
}

What actually happened during the runs:

  • On macOS arm64, npm run build:mac produced a DMG and a ZIP in normal build times.
  • Launching the resulting .app from Finder walked through create, update, complete-toggle, and delete manually without issue.
  • npm test (CRUD plus the encrypted-header assertion) passed on both operating systems.
  • On Windows x64, npm run build:win produced the NSIS installer and the portable executable, and the installed application showed the same CRUD behavior.
  • No additional rebuild configuration, extra packages, or afterPack hooks were needed on either OS. electron-builder install-app-deps alone dropped the right N-API prebuild in place.

The previous post kept a caveat that the upstream README’s phrasing (that prebuilts should “theoretically” work across Electron versions) needed to be verified empirically. Within the scope of this recipe on macOS arm64 and Windows x64, that caveat lifts: install-app-deps picks the N-API prebuild that fits Electron 44 and packaging proceeds without special handling.

Running @journeyapps/sqlcipher on macOS

The second recipe uses @journeyapps/sqlcipher 6.0.0. As the previous post noted, this version drops node-pre-gyp in favor of source-only installs and officially removes Windows support. On macOS the local build compiles SQLCipher and OpenSSL from source, so Xcode Command Line Tools is the only extra prerequisite.

Because the package exposes the traditional node-sqlite3 callback API, the wrapper adds a small promise layer. This is where the sync/async API difference called out in the previous post shows up in concrete code:

const sqlite3 = require("@journeyapps/sqlcipher").verbose();
 
class TodoDatabase {
  async initialize() {
    const escapedKey = this.encryptionKey.replaceAll("'", "''");
    await this.run("PRAGMA cipher_compatibility = 4");
    await this.run(`PRAGMA key = '${escapedKey}'`);
    await this.run("PRAGMA journal_mode = WAL");
    // ... CREATE TABLE
  }
}

Measurement results on macOS:

  • npm install built SQLCipher and OpenSSL from source, then postinstall ran electron-builder install-app-deps to rebuild the addon for Electron 44.
  • npm test (CRUD plus encrypted-header) passed.
  • npm run build:mac produced a DMG and a ZIP, and the packaged application launched.
  • The packaged .node was confirmed as Mach-O arm64 with the file command.

The Windows side is intentionally left in a reproducibly broken state. npm run build:win fails during the native addon rebuild for SQLCipher. Forcing packaging past that (for example with npmRebuild=false) would produce a Windows installer that ships the host operating system’s Mach-O .node inside a Windows package (an installer that looks fine until you run it and it crashes on first import). The recipe keeps CI honest by leaving the failure at the rebuild stage rather than papering over it.

Running @journeyapps/sqlcipher on Windows anyway (workaround)

The third recipe is a minimum viable workaround for the officially unsupported Windows path of @journeyapps/sqlcipher 6.0.0. It targets the case where an existing project already depends on the library and needs Windows distribution as a stopgap before migrating fully to better-sqlite3-multiple-ciphers.

Four extra pieces make the Windows build possible:

  • A pinned vcpkg revision provides openssl:x64-windows. A PowerShell helper, stage-windows-openssl.ps1, copies the headers and import libraries into the paths expected by the package’s public binding.gyp.
  • Since the npm metadata’s os field rejects Windows, npm ci --ignore-scripts --force installs the JavaScript tree with lifecycle scripts disabled. Electron is restored with npm rebuild electron, then the project-specific build takes over.
  • electron-builder --config.npmRebuild=false avoids a second rebuild. An afterPack hook copies libcrypto-3-x64.dll and libssl-3-x64.dll next to the packaged executable so the addon can find them at runtime.
  • A GitHub Actions job on the windows-2022 runner exercises the whole path: it verifies that the native addon is present, checks that the DLLs made it into the package, and launches the packaged .exe to confirm it does not immediately exit.

The added Windows build script is small in absolute terms:

{
  "scripts": {
    "stage:win": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/stage-windows-openssl.ps1",
    "build:win": "npm run stage:win && electron-builder install-app-deps && electron-builder --win --x64 --config.npmRebuild=false"
  }
}

Once the workaround runs in CI on every commit, the first-time setup pitfalls are largely defused. But needing this much scaffolding just to bring Windows back is exactly the empirical evidence that reinforces the earlier recommendation: a greenfield Electron app that must ship on Windows should not choose @journeyapps/sqlcipher 6.0.0 as its encrypted SQLite backend.

Dependency flow at a glance

Placing the three recipes side by side shows how much the required build inputs differ, even though the Electron app itself is the same shape:

flowchart LR
    A["npm install"] --> B{"Library"}
    B -- "better-sqlite3-multiple-ciphers" --> C["Fetch N-API prebuild"]
    B -- "@journeyapps/sqlcipher (macOS)" --> D["Build from source (Xcode CLT)"]
    B -- "@journeyapps/sqlcipher (Windows)" --> E["vcpkg OpenSSL + install --force"]
    C --> F["electron-builder install-app-deps"]
    D --> F
    E --> G["Custom stage script"]
    G --> H["electron-builder --config.npmRebuild=false"]
    F --> I["Package output"]
    H --> I

better-sqlite3-multiple-ciphers takes the shortest path: install, let install-app-deps match the Electron ABI, and package. @journeyapps/sqlcipher needs a source build on macOS and, on Windows, a hand-supplied OpenSSL plus binding-gyp path staging that the package itself does not offer.

What the measurements confirmed

Across the three recipes, three pieces of the previous post’s ranking became measured rather than argued:

  • N-API prebuild portability: better-sqlite3-multiple-ciphers 13.0.3 on Electron 44 works on both macOS arm64 and Windows x64 with install-app-deps alone. The “verify empirically” caveat from the previous post is discharged for these targets.
  • @journeyapps/sqlcipher 6.0.0’s Windows story: the dropped Windows support is not just a metadata flag. Bringing Windows back requires an OpenSSL supply chain, a workaround for the npm os gate, and afterPack packaging of runtime DLLs. Together those add up to a dedicated recipe, and that gap versus better-sqlite3-multiple-ciphers is what the previous post’s ranking was already assuming.
  • Machine-checkable encryption evidence: independent of library choice, asserting that the on-disk file header is not SQLite format 3\0 in a smoke test catches missing PRAGMA key calls and ordering errors at CI time, and it survives library migrations without change.

The ordering from the previous post (better-sqlite3-multiple-ciphers first, @signalapp/sqlcipher and SEE conditional, @journeyapps/sqlcipher off the list for Windows-inclusive targets) holds under empirical measurement on macOS arm64 and Windows x64. The natural next prototypes are pulling @signalapp/sqlcipher into the same frame when AGPL is acceptable, and extending these recipes end-to-end through Notarizing macOS software before distribution.

That’s all from prototyping Electron encrypted SQLite libraries on macOS and Windows and validating the previous post’s ranking with real builds, from the Gemba.

References