How your photos stay private
Last updated October 17, 2026
What you post is locked on your phone, and only your klk can unlock it. Not even we can see it.
The short version
Locked on your phone
Before anything is uploaded.
Only your klk has the key
Every klk has its own.
Our servers see noise
So do Google and Cloudflare.
Locked Photos Videos Captions Comments Profile photos
A photo's trip
Your phone
Our servers
Your sister's phone
In between, the photo is just noise. Only your klk's key turns it back.
Every klk has its own key
Your phone
Family Cabin tripJo's phone
Cabin tripJo is only in Cabin trip, so nothing in Family opens on Jo's phone.
How someone new gets the key
klkapp.com/join#Xq3vR8…T9zA.8fKd2w…m2Q=
- The invite
- Lets them in.
- The key
- Goes from your phone to theirs. Never to us.
Browsers never send the part after # to a server; that's how the web
works. Treat an unused invite like a house key.
When someone leaves
Old key
Jo can still see these.
New key
Jo can't open these.
The klk gets a new key, and Jo never receives it.
Your recovery passphrase
Your passphraseOnly you know it.
Your keysWe store them locked.
Your new phoneEverything opens.
We can't reset it. There's no back door. If there were, we could see your photos.
What we can see
Who and when, not what: names, emails, who's in which klk, and who posted, liked or commented when. The full list is in the privacy policy.
If you report a post, your phone sends us just that post to review.
Geek stuff
The full details
Everything above, stated precisely, for anyone who builds or audits this kind of thing. klk is built from standard, openly reviewed primitives, combined in standard ways. None of the cryptography is invented here.
Building blocks
| Job | What klk uses |
|---|---|
| Captions, comments, key copies, the passphrase-locked key | XChaCha20-Poly1305 with a random 192-bit nonce |
| Photo and video files, and their thumbnails |
libsodium's crypto_secretstream_xchacha20poly1305, in 1 MiB
chunks
|
| Giving a member their copy of a klk key | X25519 with a fresh ephemeral key per copy, HKDF-SHA256 to derive the wrapping key, then XChaCha20-Poly1305 |
| Turning your passphrase into a key | Argon2id: 19 MiB of memory, 2 passes, parallelism 1 (OWASP's recommendation), 16-byte random salt |
| Randomness | The operating system's secure random number generator |
| Libraries | @noble/ciphers, curves and hashes (audited) for keys and text; libsodium on the device for files (pinned on iOS, lazysodium on Android); the reference Argon2 implementation, natively |
Every key is 256 bits. The Argon2id parameters are stored next to each passphrase-locked key, so they can be raised later without locking anyone out.
The key hierarchy
- Recovery passphrase Only in your head.
- Argon2id, with a random salt
- Escrow key Derived on your phone when needed, never stored.
- XChaCha20-Poly1305, bound to your account id
- Identity key (X25519) The secret half lives in the Keychain or Keystore, and we store a copy locked by the escrow key. The public half is on your profile, and the security rules stop it changing once it is set.
- Sealed box, bound to the klk, the key version and you
- klk content key One per klk per version, with a random 128-bit version id. Each member holds their own sealed copy.
- XChaCha20-Poly1305
-
Captions and comments
Stored as
klk1:<version>:<ciphertext>. File keys A fresh one for every photo or video. It seals the file in Cloudflare R2 and a 360px thumbnail kept on the post.
A file gets its own key, rather than the klk's, so the native code that handles bulk bytes only ever sees a key that opens one file, and a report can hand over one photo without exposing the klk. Profile photos work the same way: the photo has its own key, sealed under the current key of each klk you share with someone, plus one copy sealed to you.
What a post looks like to us
This is a post as it sits in our database, abridged, with long values cut short. The highlighted values are ciphertext we can't open. The rest is what our servers can read, and it matches the privacy policy's list.
{
"uid": "Vb3kQ…9QzE",
"cliqueId": "h1RkT…07Ws",
"timestamp": 1792256400000,
"likeCount": 4,
"commentCount": 2,
"text": "klk1:9f2c…e41a:Qm8xVt…Zw==",
"images": [
{
"type": "image",
"width": 1440,
"height": 1080,
"ext": "jpg",
"path": "cliques/h1RkT…07Ws/Vb3kQ…9QzE/5c1e…b2d0.klkm",
"key": "klk1:9f2c…e41a:Lp0TzA…aQ==",
"thumb": "S0xLTQIAABAA…"
}
]
}
Every sealed file starts with the same nine bytes: KLKM, a format
version and the chunk size. That is the S0xLTQIAABAA at the front
of the thumbnail, in base64. After it, there is only ciphertext.
What the design guards against
- Ciphertext moved to the wrong place. Each sealed thing is bound to where it belongs, as authenticated data. A klk key copy names its klk, its version and its recipient. A file key can't be opened as a caption, or a caption as a file key. A profile photo is bound to its owner, and the passphrase-locked key to your account. Move one and it fails to open instead of showing up somewhere it shouldn't.
- Planted content. There is no plaintext fallback. If a caption or photo on the server isn't properly sealed, the app shows nothing, and the security rules refuse to store it in the first place. Nobody with database access, us included, can plant a caption or photo that appears to come from a member.
- Forged key requests. A member whose phone is missing a klk's key can ask the others for it. The request names only who is asking. Other members' phones check that person is on the klk's member list and seal the key to the public key on their profile, so a forged request can't send the key anywhere else.
- Cut or shuffled files. secretstream chains each chunk to the one before it and marks the last one, so a file with chunks dropped, reordered or cut short won't open. The header is pushed through the stream too, so a doctored version or chunk size is caught.
- Open storage. The R2 buckets have no public access. Your phone asks a server function for a link, which checks that you are in the klk and signs a link to that one file, valid for an hour. Uploads go to a name the server chooses, signed for the exact number of bytes. The app only fetches from our own bucket, so a forged post can't make your phone contact someone else's server and reveal that you looked.
- Hidden details in the file. Before encrypting, your phone re-encodes photos, which removes their EXIF data, location included. Videos are re-encoded without metadata unless they are already small and carry no location.
- Leaks around the edges. Push notifications carry a title and nothing else. Crash reports never include decrypted text: the app drops the tap and console records that could otherwise carry what was on screen.
What it doesn't protect against
- People in the klk. Members can see, screenshot and save what is posted. Encryption decides who is in the room, not what they do there.
- An unlocked phone. Whoever has your phone and its passcode has your klks.
- A guessable passphrase. We store the passphrase-locked copy of your key. Argon2id makes every guess slow and memory-hungry, but a passphrase from a list of common passwords could still be guessed by someone holding a copy of our database.
- A server altered to lie. klk doesn't yet let you compare keys with another member, the way Signal's safety numbers do, so your phone trusts our server about who is in a klk and which public key belongs to whom. Someone who took control of the server, us included, could add a member who isn't real, which everyone would see in the klk's member list. Or they could swap a member's public key for their own, which nobody would see. Either way, they would receive what is posted after that. Content already stored stays sealed. This takes deliberate tampering, and key verification is what would close it.
- Moving a caption within a klk. Captions and comments are bound to their klk and key version, not to the individual post, and authorship isn't signed. So someone with database access could move a caption from one post to another in the same klk. They couldn't read it or write a new one.
- The app itself. As with any encrypted app, you are trusting that the app on your phone does what this page says. klk's source code is not public.
Things you can check yourself
- The invite page. If klk isn't installed, an invite opens klkapp.com/join, a static page. View its source: it reads the invite from the fragment, clears it from the address bar, sends no referrer, and passes the invite only to the klk app.
-
The traffic. Put klk's traffic through a debugging proxy and
every photo or video upload starts with the bytes
KLKMand is noise after that. Captions and comments go up asklk1:…tokens. - The notifications. Every push klk sends says who did something, never what they said or shared.
Think we got something wrong, or found a flaw? Tell us at support@klkapp.com.