Our browser had a password vault that only worked on Windows. The check was blunt and honest:
available(): boolean { return process.platform === 'win32' && safeStorage.isEncryptionAvailable(); } When we decided to enable it on Linux and macOS, the obvious change looked like a one-liner: drop the platform check and trust Electron's safeStorage.isEncryptionAvailable(). That one-liner would have been wrong on Linux, and in a way that no error message would ever have told us. This post is about why, and what we shipped instead.
The setup
We build a desktop browser on Electron. Saved passwords are encrypted with Electron's safeStorage module before they are written to a per-profile JSON file on disk. On Windows, safeStorage uses DPAPI. On macOS, the key lives in the Keychain. Both are reasonable defaults for a local vault (with known limits we come back to below).
Linux is different. There is no single OS secret store. Electron picks a backend based on the desktop environment: gnome_libsecret for GNOME, Cinnamon, XFCE and a few others, kwallet / kwallet5 / kwallet6 for KDE sessions. And when it can't recognise the environment, or when the app is started with --password-store="basic", it falls back to basic_text.
The trap: "encryption available" is not "protected"
Here is the part that is easy to miss. The Electron docs say:
If no secret store is available, items stored in using the
safeStorageAPI will be unprotected as they are encrypted via hardcoded plaintext password. You can detect when this happens whensafeStorage.getSelectedStorageBackend()returnsbasic_text.
So on a Linux machine without a keyring (a minimal window manager, some headless or unusual setups, or someone who launched the app with the basic password store), encryptString() still works. It returns bytes that look encrypted. Nothing throws. But the key that protects those bytes is not a secret.
For most apps that is a tolerable degradation. For a password vault it is not: the whole point of the feature is that the file on disk is not readable by whoever gets hold of it. A vault that silently writes "encrypted" passwords with a known key gives users a false sense of safety, which we consider worse than no vault at all.
isEncryptionAvailable() is not the tool for this decision. On Linux it tells you whether the app is ready and a key is available. It does not tell you what kind of key.
The second trap: asking too early
There is a smaller timing issue on top. getSelectedStorageBackend() only means something after Electron's ready event. Called earlier, it returns unknown. If the vault's availability check runs during startup (for example while the UI asks "should I show the password import button?"), you can get an answer that is neither a yes nor a real no.
We decided that "unknown" should be treated the same as "not allowed". Better to show the vault as unavailable for a moment during startup than to make a security decision on missing information.
What we changed
The fix in our pull request does three things.
First, a small gate that decides whether the OS backend is acceptable at all:
export function osEncryptionBackendAllowed(platform: NodeJS.Platform = process.platform): boolean { if (platform === 'win32' || platform === 'darwin') return true; if (platform !== 'linux') return false; try { if (typeof safeStorage.getSelectedStorageBackend !== 'function') return false; const backend = safeStorage.getSelectedStorageBackend(); const allowed = typeof backend === 'string' && backend !== 'basic_text' && backend !== 'unknown'; if (!allowed) console.warn(`[password-vault] Linux secret storage backend "${String(backend)}" refused; install/unlock gnome-keyring (libsecret) or KWallet.`); return allowed; } catch (error) { console.warn('[password-vault] Could not query Linux secret storage backend (keyring locked or unavailable?):', error); return false; } } A few deliberate choices are hiding in there:
- Deny by default. Any platform we haven't thought about (FreeBSD, for example) gets
false. - A blocklist of two values, not an allowlist of four. We refuse
basic_textandunknownand accept whatever else Electron reports. The trade-off: if a future Electron version adds a new real keyring backend, it works without a code change. If it adds a new insecure fallback with a different name, we would accept it. We think the first case is more likely, but it is a judgement call, and a stricter team could reasonably go the other way. - Throwing means no. A locked keyring or a D-Bus error can surface as an exception. A missing API (older Electron) also means no. None of these should crash the caller, and none of them should be read as permission.
- The warning says what to do. If a Linux user wonders why the vault is greyed out, the log tells them to install or unlock GNOME Keyring or KWallet.
Second, the vault's own availability check now combines that gate with an app-ready check and Electron's own answer:
available(): boolean { if (!appReady()) return false; try { return osEncryptionBackendAllowed() && safeStorage.isEncryptionAvailable(); } catch { return false; } } Third, the user-facing error changed from "Windows password encryption is unavailable" to "Operating system password encryption is unavailable. Nothing was imported." The old message was accurate when the feature was Windows-only and would have been confusing everywhere else.
How we tested it, and what the tests don't prove
We added a test file that mocks Electron's app and safeStorage and covers the decision table:
- Windows and macOS are allowed, and the Linux backend API is never called there.
-
gnome_libsecret,kwallet,kwallet5andkwallet6are allowed on Linux. -
basic_textandunknownare refused, and listing the vault returns{ available: false, entries: [] }without touching the file. - A throwing or missing
getSelectedStorageBackendis refused. - Before app ready, the vault is unavailable.
To be clear about the limits: these are unit tests against a mocked safeStorage. They prove that our gating logic does what we intended. They do not prove that every Linux distribution and desktop environment reports its backend the way the docs describe, or that a locked keyring behaves the same on every setup. That needs testing on real machines, and we don't have a broad matrix for it yet.
There are also limits that no code on our side changes. Per Electron's own docs, DPAPI on Windows protects data from other users on the same machine, but not from other apps running as the same user. Linux security depends on which secret store is in use. A local password vault raises the bar; it does not make a compromised user session safe.
One more thing we noticed while reading the docs
The synchronous safeStorage API (isEncryptionAvailable, encryptString, decryptString) is marked deprecated as of Electron 45 and scheduled for removal in Electron 46, in favour of an async API with key rotation and "temporarily unavailable" handling. Our vault still uses the synchronous calls. The async API will need its own look at how Linux fallbacks are reported, so we can't simply port this gate one-to-one. That migration is still open on our side.
Lessons
- "Did the call succeed?" and "is the result safe?" are different questions.
safeStorageon Linux can succeed while giving you no real protection. Find the API that tells you how something was done, not just that it was done. - Treat "I don't know yet" as "no" in security decisions.
unknownbefore app ready, exceptions from D-Bus, a missing method: all of them should land on the safe side. - Error messages encode assumptions. "Windows password encryption is unavailable" was a small, visible symptom of the Windows-only assumption baked into the code.
- Say what your tests don't cover. Mocked platform tests are useful, but they test your logic, not the platform.
We ran into this while working on LastBrowser, a desktop browser with a built-in AI sidebar. The code is on GitHub if you want to look at the vault or tell us where we got it wrong.


