How We Built Journoe So the Server Cannot Read Your Journal
Date Published

Most journaling apps ask for something they have not fully earned: trust.
They ask users to type their most private thoughts into a text box and assume that login screens, database encryption, and a privacy policy are enough. We did not think that was good enough. If someone is using a journaling app to record their daily life, process grief, write down fears, or store personal reflections, the product has to be built around one uncompromising idea:
Their words should belong to them.
That is why we built Journoe, a local-first, end-to-end encrypted journaling app designed so that our backend can store your data, sync your devices, and coordinate updates without ever being able to read what you wrote. The server is useful, but it is not trusted with plaintext.
This post is about how we built that system: client-side encryption in the browser, password-gated key material, multi-tab session handoff without persisting plaintext secrets, and conflict detection across devices when the server cannot read or merge the underlying content.
Trust as a system design problem
From the start, we did not want Journoe to be “secure” in the soft, familiar sense of the word. We did not want security to mean that the database is encrypted at rest, or that the backend promises to behave, or that only administrators can access user content.
We wanted something much stricter.
Journoe is built on a zero-knowledge model. The browser performs the cryptographic work. The backend stores encrypted blobs. That means the server can authenticate requests, persist ciphertext, enforce version rules, and fan out sync events, but it cannot inspect the journal entry itself. In practical terms, that changes the entire architecture. Once the server is blind to content, a lot of standard application patterns stop working. You cannot merge text on the backend. You cannot “fix” malformed state by reading the payload. You cannot casually recover from key drift. The system has to be designed around those constraints from day one.
That is exactly what made Journoe interesting to build.
Encrypting before the network
The most important rule in Journoe is simple: plaintext must not leave the browser.
Before a journal entry is sent over the network, both its title and body are encrypted client-side using AES-GCM through the native Web Crypto API. By the time the request reaches the backend, what the server sees is ciphertext and metadata, not readable journal content.
Just as important, the encryption key itself is never sent to the backend.
Instead, key material is derived on the client and protected locally behind the user’s password. We use PBKDF2-based derivation and wrap the locally stored device key material inside the browser, persisting it in IndexedDB rather than leaving usable plaintext secrets lying around in storage. That gives us a useful property: even if someone obtains local browser storage, they still do not automatically obtain a readable journal. Unlocking that state still requires the user’s password.
This is the line we cared about most while building Journoe: the backend should be able to store your journal, but it should not be able to read it.
Preventing undecryptable state
One of the less glamorous problems in encrypted systems is not encryption itself. It is stale state.
Devices get wiped. Passwords change. Local storage disappears. Browser sessions drift. A user logs in from a machine that no longer has the right key state and suddenly starts producing payloads that may sync successfully but can never be decrypted again. In an encrypted application, this is one of the easiest ways to create permanent damage without realizing it.
To guard against that, Journoe uses key fingerprinting.
Derived device keys are fingerprinted client-side with SHA-256, and the backend stores those fingerprints alongside the encrypted records it accepts. The server still never receives the raw key, but it gains enough verification context to detect when a device is clearly out of sync with the active encryption state. If that happens, the backend can reject or flag the write before it stores ciphertext that the user may never be able to open again.
This is a subtle part of the system, but it matters. In encrypted products, “successfully stored” is not the same thing as “safely recoverable.” Fingerprinting gave us a way to reduce that gap without weakening the zero-knowledge model.
The multi-tab problem nobody warns you about
One of the strangest UX problems in secure browser apps is that the safest place to keep a decryption key is often memory, and memory disappears the moment a tab does.
That creates an immediate tension. If Journoe keeps its active key material only in memory, that is great for security. But what happens when a user duplicates the tab, restores a session, or opens the app again while the current session is already active? Asking for the password every single time is secure, but it is also clumsy. Persisting the plaintext key is convenient, but it weakens the whole model.
We wanted a middle ground.
Our answer was what we call Active Push Session Handoff.
Journoe operates in a strict single-tab mode. If a second tab opens while an active session already exists, that new tab does not immediately try to compete for control. Instead, it waits in a locked handoff state. When the active tab exits cleanly, it uses the browser’s BroadcastChannel API to transfer the in-memory device key to the waiting tab, which can then take over the session immediately.
The important part is not just the smoothness of the handoff. It is where the key does not go. It is not written to disk in plaintext. It is not handed to the server. It is passed directly inside the browser context, in memory, from one live tab to the next.
That gave us the UX we wanted without giving up the security property we cared about.
Sync without blind overwrites
The moment Journoe became a multi-device app, we had to solve a harder problem: what happens when two devices edit the same encrypted note at the same time?
In a traditional application, the server can inspect the record and attempt a merge. In an end-to-end encrypted application, that option disappears. The server cannot read the entry, so it cannot responsibly merge conflicting writes. Pretending otherwise would be dishonest architecture.
So instead of hiding conflicts, we made them explicit.
Journoe uses optimistic concurrency control across all journal records. Every entry in PostgreSQL carries a version number. When a client saves changes, it includes the version it believes it is updating. If that version is stale, the backend rejects the write with a 409 Conflict instead of silently overwriting newer data.
That alone prevents data loss, but it does not reduce the number of conflicts users run into. To shrink that window, we pair version enforcement with Server-Sent Events. When one device updates an entry, the Go backend pushes the new list state and version metadata to other connected clients in real time. That allows the UI to refresh its local understanding of the record before the user accidentally edits an outdated version.
The server still cannot merge your journal entry. But it can do something just as important: protect the system from pretending that conflicting encrypted edits are safe to overwrite.
Why the stack looks the way it does
The frontend is built with React, TypeScript, and Vite because the browser is where the security model actually lives. This is not a thin client. It owns encryption, local persistence, unlock state, session continuity, and the UX around conflict recovery and handoff.
The backend is built with Go, Gin, GORM, and PostgreSQL because we wanted a simple, predictable server that could do a few jobs very well: authenticate users, persist encrypted blobs, enforce concurrency rules, coordinate real-time updates, and stay operationally boring. In a product like Journoe, that is a virtue. The backend should be reliable and strict, not clever.
That stack gave us a clean boundary:
- The browser owns plaintext and keys.
- The server owns sync, durability, and enforcement.
- The database stores encrypted state, never readable journal content.
Once that boundary was clear, a lot of implementation decisions became easier.
Building for trust
Journoe pushed us to be more honest about what privacy software should mean.
It is easy to say “we take your data seriously.” It is much harder to build a system where your access to the data does not depend on trusting the operator in the first place. That was the real goal behind Journoe. Not just stronger storage. Not just better auth. A product where the architecture itself limits what the server can know.
That meant doing the cryptography in the browser. It meant treating stale key state as a real product risk. It meant solving multi-tab handoff without persisting plaintext secrets. It meant rejecting conflicting writes instead of pretending the server could merge data it was never meant to see.
Journoe is still early, but the direction is fixed. We want journaling software to feel calm, fast, and personal on the surface while being uncompromising underneath. The app should be easy to use. The security model should not be.
Because when someone writes in a journal, they are not just saving text.
They are handing the product their inner life.
And that trust should have to be earned in code.
We made the project Open Source, It is available at Github.