klk

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.

MDS

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

locked

Our servers

unlocked

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 trip

Jo's phone

Cabin trip

Jo 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.

Jo leaves

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.

unlocks

Your keysWe store them locked.

onto

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

  1. Recovery passphrase Only in your head.
  2. Argon2id, with a random salt
  3. Escrow key Derived on your phone when needed, never stored.
  4. XChaCha20-Poly1305, bound to your account id
  5. 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.
  6. Sealed box, bound to the klk, the key version and you
  7. klk content key One per klk per version, with a random 128-bit version id. Each member holds their own sealed copy.
  8. XChaCha20-Poly1305
  9. 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

What it doesn't protect against

Things you can check yourself

Think we got something wrong, or found a flaw? Tell us at support@klkapp.com.