How your diary stays unreadable to us
Safe Diary encrypts every entry in your browser before it ever reaches a server. This page documents exactly how that works, what our database can still see, and how to check the claims yourself.
The short version
Your password never leaves your device, and neither does the key it makes.
When you unlock Safe Diary, your browser turns your password into an encryption key and holds it in memory for that session only. Entries are encrypted with that key before upload and decrypted after download. The server receives ciphertext and stores ciphertext.
There is no key escrow, no recovery key, no support tool that can open your journal. That is a deliberate trade: it also means that if you forget your password, your entries are gone permanently. We cannot reset it, and neither can anyone who compels us to try.
What the database holds
Stored, and not stored
Being honest about metadata matters more than a blanket claim. Here is every field attached to an entry.
Visible to the server
- encrypted_dataAES-GCM ciphertext. Unreadable without your key.
- ivThe random 96-bit nonce for that entry.
- created_at / updated_atWhen an entry was written and last edited.
- idA random identifier, bound into the ciphertext.
- Approximate lengthCiphertext size roughly tracks entry length.
Never sent, never stored
- Your passwordIt is never transmitted in any form.
- Your encryption keyNon-extractable, memory only, discarded on lock.
- Titles and body textEncrypted inside the blob, not separate columns.
- Tags and moodsAlso inside the blob — tag names are not searchable server-side.
- Analytics of any kindNo trackers, no third-party scripts, no telemetry.
So a full database breach reveals how many entries you keep and when you wrote them — not a single word of what you wrote.
Key schedule v2
From password to ciphertext, in four steps
The order matters here, so the steps are numbered. Every one of them runs inside your browser, using the platform’s own Web Crypto implementation — no hand-rolled cryptography, no third-party crypto library.
Stretch the password
Your password is run through PBKDF2-HMAC-SHA256 with a per-account random salt and 600,000 iterations — deliberately slow, so guessing is expensive.
PBKDF2(pw, salt, 600000) → 256 bits
Split into two keys
Those bits are expanded by HKDF into two separate keys with different labels: one that encrypts your entries, one that proves to the server you know the password.
HKDF → enc key ∥ auth key
Encrypt each entry
AES-GCM with a 256-bit key and a fresh random nonce per save. The entry’s own identifier is authenticated alongside it, so ciphertext cannot be swapped between records.
AES-GCM-256, new IV, AAD = entry id
Upload the ciphertext
Only the encrypted blob and its nonce are sent. The encryption key is marked non-extractable by the browser, so no script — ours included — can read it back out.
POST { id, encrypted_data, iv }
- Key derivation
- PBKDF2-HMAC-SHA256, 600,000 iterations — meets current OWASP guidance
- Domain separation
- HKDF-SHA256, distinct labels for encryption and authentication
- Content cipher
- AES-GCM, 256-bit key, 96-bit random nonce per save
- Integrity
- GCM authentication tag, with the entry identifier as additional authenticated data
- Key handling
- Non-extractable CryptoKey, in memory only — never written to storage of any kind
- Unlock cost
- ≈0.45 s on a typical desktop — opening an entry afterwards takes under a millisecond
- Minimum password
- 12 characters, with a passphrase recommended
- Transport
- HTTPS with HSTS; strict Content-Security-Policy; no third-party scripts
Threat model
What this protects you from, and what it doesn’t
It protects against the server. A stolen database, a compromised hosting account, a rogue administrator, or a legal order served on us yields ciphertext and timestamps. We cannot produce your entries because we cannot read them.
It does not protect a compromised device. Malware, a keylogger, a shoulder-surfer, or someone using your unlocked browser sees exactly what you see. Encryption at rest cannot help once the diary is open in front of an attacker.
It does not rescue a weak password. Six hundred thousand iterations make each guess expensive, but a short or reused password is still the cheapest way in. Use a long passphrase you have never used anywhere else.
The limitation every web app shares
Safe Diary delivers its own cryptography as JavaScript from our servers. Anyone who could compromise our hosting or domain could, in principle, serve a modified version of the app to future visitors and capture keys as they are typed. No browser-based encrypted service can eliminate this — the honest answer is that it narrows the attack from “read the database” to “successfully tamper with a signed deploy without anyone noticing”, which is a far harder and far more visible thing to do. We publish this rather than let “zero-knowledge” imply otherwise.
Don’t take our word for it
Three checks you can run in two minutes
Open Safe Diary in a desktop browser, press F12 for developer tools, and see for yourself.
1. Watch an entry leave your browser
In the Network tab, save an entry and inspect the request body. You should see an encrypted blob and a nonce — no title, no text, no tags.
{ "id": "…", "encrypted_data": "qJ8f2…", "iv": "SVaoD4lXJQ6Eayp9" }
2. Check what is kept on your device
In the Application tab, look at Local Storage. You will find a salt, a session token and a user record — and no password, no key material, nothing readable from your diary.
Application → Local Storage → https://safediary.app
3. Confirm nothing third-party can run
Our Content-Security-Policy allows scripts only from our own origin and network connections only to our own API. Paste this in the Console to read the policy the page is enforcing.
document.querySelector('meta[http-equiv="Content-Security-Policy"]').content
Security review — September 2026
What we found, and what we changed
A review of the client cryptography, session handling, storage and HTTP headers. Findings and their current status are listed below; existing accounts were migrated to the new key schedule with their entries re-encrypted in the browser.
Key stretching raised to 600,000 iterations
PBKDF2-HMAC-SHA256 was running at 310,000 iterations. Raised to 600,000 to match current OWASP guidance, roughly doubling the cost of every password guess.
Encryption and login keys separated
The login verifier previously shared a derivation with the encryption key. The two are now expanded from the master secret through HKDF under distinct labels, so the value held by our server has no useful relationship to the key that opens your entries.
Entries bound to their own identity
Each entry’s identifier is now authenticated as part of its ciphertext, so a stored blob cannot be moved to another record without the app detecting it.
Login attempts rate-limited
Failed unlock attempts are throttled per account and per address with escalating delays, and the verifier is compared in constant time. Failure messages never reveal whether an account exists.
Minimum password length raised to 12
New accounts and password changes require at least 12 characters, with passphrases encouraged. Since there is no recovery path, password strength is the whole of your security.
Independent review
This report documents an internal review by the developer. It is not a third-party audit, and we do not claim one. If and when an external review is commissioned, its results will be published here whatever they say.