How VoidNote works
The full technical picture — what we see, what we don't, and why.
The split-token design
A note link has two halves. Your device makes one; our server makes the other:
secret = 16 random bytes, 32 hex chars // generated on your device, never sent to the server
tokenId = 16 random bytes, 32 hex chars // assigned by the server when the ciphertext is uploaded
link = https://voidnote.net/n/<tokenId>#<secret>
The server stores only the tokenId alongside the encrypted ciphertext.
The secret sits after the # in the link you share.
Neither half is useful alone: the server has an encrypted blob with no key;
the link has a key with no data to find.
Encryption
The encryption key is derived from the secret half of the link. If you set a password, the key is derived from the password instead, with the secret as the salt:
key = SHA-256( hexToBytes(secret) )
// or, with a password:
key = PBKDF2-HMAC-SHA256( password, salt = hexToBytes(secret), 600,000 iterations, 32 bytes )
iv = 12 random bytes
ciphertext = AES-256-GCM( plaintext, key, iv )
AES-256-GCM provides both confidentiality and authenticated integrity — any tampering with the ciphertext
causes decryption to fail with an authentication error, not silently corrupt data.
Encryption runs in your browser using the native SubtleCrypto API.
A password-protected note needs both the link and the password, so you can send them over different channels.
The server records only which derivation was used, so the reader's browser knows to ask for a password.
What the server stores
| Field | Value | Sensitive? |
|---|---|---|
| token_id | Random id the server assigns | Low — a lookup key only |
| user_id | Your account, if you were signed in; empty otherwise | Links the note to your history |
| encrypted_content | AES-256-GCM ciphertext (hex) | Unreadable without key |
| iv | 12-byte initialisation vector (hex) | Public — needed for decrypt |
| title | Plaintext title (if any) | Yes — keep titles non-sensitive |
| kdf | sha256, or pbkdf2-sha256-600k for a password | Shows only whether a password was set |
| max_views / view_count | View limit and views so far | No |
| expires_at | The expiry you chose — up to 7 days without an account, 30 with one | No |
The secret — the half that makes decryption possible — is never stored with the note.
It exists in the URL you share.
Opening a note
Notes are created as voidnote.net/n/<tokenId>#<secret> links —
on this website and with the SDKs, CLI, VS Code extension and API.
The secret is in the URL fragment, which browsers and HTTP clients do not send.
The reader's browser fetches the ciphertext by its token id and decrypts it on their own device.
Our server never sees the key or the plaintext.
Older links in the form voidnote.net/note/<tokenId><secret>, which the website
used to create and older versions of the mobile apps still do, carry the secret in the path. When one of those is revealed, the full token is sent to our server,
which derives the key, decrypts the note in memory, returns the text over HTTPS and burns the view. The key is not saved,
but our server does handle the key and the plaintext at that moment. Those links expire within 30 days of being created.
The QR code on the share screen is drawn in your browser, so the link doesn't leave the page that way. The one place a full link is stored is your account's device inbox: "Send to my devices" in older versions of the mobile apps saves the link there, key included.
Reveal gate
When someone opens a note link, the page loads but does not automatically decrypt. The viewer must click "Reveal note" to trigger the decrypt request. This prevents browser link prefetching, URL scanning bots, and messaging app link previews from consuming a view.
Destruction
Notes are permanently deleted in two cases:
- • The view count reaches its limit (on the final view, deletion happens in the same database transaction)
- • The expiry you chose elapses (up to 7 days without an account, up to 30 with one — an hourly cleanup job removes all expired records)
Deletion is permanent. There is no archive, no soft-delete, no recovery path. Once gone, the ciphertext is gone — even if you held the decryption key, there would be nothing left to decrypt.
Streams
Void Streams are live encrypted channels over Server-Sent Events (SSE). The same split-token model applies: messages are encrypted in the browser, the server relays only ciphertext, and the decryption key lives in the share URL. The stream URL carries the key in its path, so it is sent to our server with each stream request; it is not saved with the stream. Streams support two modes — broadcast (one writer, many readers) and chat (everyone with the link can write). Streams auto-expire after their TTL (1, 6, or 24 hours) and can be closed early by the owner.
Vault
The Vault extends the same model to files. Files are encrypted client-side with AES-256-GCM before upload, stored encrypted in Cloudflare R2, and destroyed after the first download. The decryption key sits in the share URL's fragment, and the file is decrypted on the downloader's device.
Authentication
User accounts use post-quantum session tokens based on ML-DSA-65 (CRYSTALS-Dilithium),
implemented via the @noble/post-quantum library running as WASM.
Session tokens are signed with a Dilithium keypair stored in Cloudflare KV — not a symmetric secret or a password-derived key.
Passwords are hashed with PBKDF2 (100,000 iterations, SHA-256) with a per-user random salt.
What we can't do
- ✓ Read the content of stored notes — notes are stored without their decryption keys (an older
/note/link is decrypted on our server only at the moment it is opened) - ✓ Recover a destroyed note — deletion is irreversible at the storage level
- ✓ Extend a note's lifetime — expiry is enforced by the server regardless of any client request
- ✓ Recover your password — we store only a salted PBKDF2 hash
SDKs & CLI
VoidNote is infrastructure you can call from code. All SDKs implement the same split-token encryption client-side — no plaintext ever leaves your environment. Every language, one API.
npm install voidnote-sdk pip install voidnote go get github.com/quantum-encoding/voidnote-go cargo add voidnote zig fetch --save https://github.com/quantum-encoding/voidnote-zig/archive/refs/tags/v0.1.0.tar.gz implementation("net.voidnote:voidnote-java:0.1.0") # macOS / Linux
brew tap quantum-encoding/tap && brew install voidnote # anywhere with Go installed (the binary is named voidnote-cli)
go install github.com/quantum-encoding/voidnote-cli@latest