How Wisp’s encryption actually works.
Plain English first, technical specifics second. If anything below turns out to be wrong, we want to hear about it — email hello@wisp.video.
The one-sentence version
Your video is encrypted in your browser before it leaves your device, we store only ciphertext (the plaintext video never reaches our backend), the recipient’s browser decrypts it once, then the ciphertext is wiped — and even if it weren’t, we couldn’t read it without the key. There’s one honest caveat about the key at send time; we describe it below.
What happens, step by step
- You record a short video. The bytes never leave your device until they’ve been encrypted.
- Your browser generates a fresh AES-GCM 256-bit key using
crypto.subtle.generateKey— the WebCrypto API built into every modern browser. - The video is encrypted with that key + a fresh 96-bit IV (also from the browser’s CSPRNG). The result is a ciphertext blob + an IV.
- The ciphertext blob is uploaded to our storage (Vercel Blob). The IV is uploaded to our database. The key is also sent to our backend at this single send-time step — used only to compose the recipient’s view-link email, then dropped. We don’t log it, don’t persist it, and don’t retain it after the email goes out. See the “Where the key actually lives” section below for the exhaustive picture.
- We give you back a URL like
wisp.video/v/<id>#k=<base64url-key>. The part after the#is the URL fragment. Browsers never include the fragment in HTTP requests — your browser knows it, the server does not. - The recipient’s browser reads the fragment, fetches the ciphertext + IV from us, decrypts in-browser, and plays.
- When playback finishes (or 24 hours elapse, whichever first), we hard-delete the ciphertext blob from storage.
What we can see
- The sender’s email (you give it to us — it’s your account).
- The recipient’s email (so we can deliver the link).
- An opaque ciphertext blob and its IV. Without the key, this is indistinguishable from random bytes.
- The clip’s creation time, view time, and TTL.
- A SHA-256 hash of the ciphertext (integrity check).
- The recipient’s IP / user-agent when they visit the view link (we log it for abuse review with last octet zeroed; see privacy policy).
What we cannot see
- The video content. We have no persisted copy of the decryption key, and we cannot derive it from anything we store. (The key passes through our backend transiently at send time, used only to compose the recipient’s email link — see the “Where the key actually lives” section. After that single send-step it’s gone.)
- The audio of the recipient’s voice reply. Same scheme — a fresh key generated in the recipient’s browser, the ciphertext uploaded with the key only in the fragment of the reply’s view URL.
- Anything the recipient typed about the wisp (we don’t have a comment / chat surface).
The cipher specifics, for engineers
- Algorithm: AES-GCM with a 256-bit key + 96-bit IV. Authenticated encryption — GCM’s built-in tag detects ciphertext tampering on the recipient side.
- Key derivation: none. The key is a freshly generated random key per clip. There is no password, passphrase, or KDF involved.
- IV reuse: impossible by construction — each encrypted blob (initial video + each reply) has its own key, encrypted exactly once, with a fresh IV.
- Key encoding in URL: base64url-encoded raw key bytes, placed in the URL fragment as
#k=.... - Browser primitives used:
crypto.subtle.generateKey,crypto.subtle.encrypt,crypto.subtle.decrypt,crypto.subtle.exportKey/importKey(raw),crypto.getRandomValues. - Integrity surface: ciphertext SHA-256 is stored server-side and recomputed on download. A flipped bit becomes a decryption failure, not a silently-corrupted video.
Where the key actually lives
URL fragments are never transmitted to origin servers per the URL spec (RFC 3986 §3.5 and HTML living standard). That’s the load-bearing property for the viewer side: when the recipient opens the link, their browser keeps the #k=… fragment local and only sends our server the /v/<id> path. So when the wisp is played back, the key never reaches us.
The one honest caveat: at send time our backend does briefly hold the key. The composer POSTs the key alongside the ciphertext to /api/upload in a form field (keyFragment), and our server uses it to assemble the recipient’s view URL (/v/<id>#k=…) for the outgoing email. The key isn’t written to the database, isn’t written to the blob store, isn’t logged, and isn’t retained on the server side after that one email-send step. If you don’t want the key to touch our backend at all, copy the link from the composer’s success screen and share it through your own channel — Signal, AirDrop, in person — instead of using our email-delivery convenience.
Where the fragment IS visible: Inside your browser, the address bar, your browser history, your recipient’s browser, and any program that has access to the full URL string after the user pastes it (email body, chat messages, screenshots).
Where the fragment IS NOT visible: our HTTP access logs, our database, our blob store, CDN edge nodes; and at the viewer roundtrip, our backend code (the fragment is held by the recipient’s browser, not sent to us). At send time the key is transiently in backend memory for the email-compose step described above.
Threat model — what we protect against
- Database breach: attacker gets ciphertext + IV + metadata. Cannot decrypt without the key.
- Blob store breach: same — ciphertext only.
- Hostile insider on stored data: we cannot decrypt your wisp from anything we’ve persisted — the key is not written to the database, blob store, or logs. (See “Where the key actually lives” above for the send-time exception: an insider with live process memory access during the email-compose step could in principle observe the key transiently. Stored state shows nothing.)
- TLS downgrade / MITM on upload: TLS to our backend is mandatory; ciphertext is opaque even if intercepted.
- Lawful subpoena: we can hand over what we store (ciphertext + IV + metadata + visit logs). We cannot hand over plaintext, because we don’t have it.
Threat model — what we do NOT protect against
Honest list. Wisp is not magic.
- Recipient screen-recording. If the recipient records their screen during playback, the recording survives outside Wisp. This is fundamental to any "view once" system and we cannot defend against it without DRM (which we will not introduce). The viewer applies three deterrents while a wisp is open: the recipient’s email + a live timestamp is watermarked across the surface (so a screenshot or screen-recording carries the recipient’s identity in the pixels), the surface blacks out on tab-blur or focus loss (so casual record-while-multitasking captures the blackout instead of the content), and right-click / drag-save is disabled (so the trivial "save video as" affordance is gone). These add friction and traceability — they are not prevention, and a determined recorder with a phone camera, OBS, or a screen-record key combo still wins. We will not market these as "screenshot-proof."
- Malicious browser extension on either side. An extension with full page access can read the URL fragment, capture the camera feed before encryption, or dump the decrypted video frames. Use a trusted browser.
- Compromised endpoint. If your device has malware that can read your browser’s memory, the key is in memory while you’re sending. We can’t protect against root-on-device adversaries.
- Email-pipeline observability. The view link (with key) lands in the recipient’s mailbox. Their email provider stores it. Anyone with access to that mailbox can click the link before the recipient does. Use this where the recipient’s mailbox is trusted.
- Quantum. AES-256 is widely viewed as post-quantum-resistant for confidentiality. We rely on that assumption.
Auditable code paths
The crypto logic lives in two TypeScript files you can read statically. Both run client-side only — they are shipped as part of the JS bundle to your browser, and there is no server-side counterpart that could quietly do something different.
src/lib/crypto.ts— key generation, AES-GCM encrypt / decrypt, IV handling, base64url helpers. Plain WebCrypto, no custom math.src/components/Composer.tsx— callsgenerateKey,encryptBlob,exportKey, posts the ciphertext plus the key (askeyFragment) to/api/upload, builds the view URL with the key in the fragment. The server uses the posted key only to compose the recipient’s email link, then drops it; seesrc/app/api/upload/route.tsfor the line-by-line.src/app/v/[id]/ViewExperience.tsx— reads the key fromwindow.location.hash, callsimportKey+decryptBlob, plays. The page’s server component does not receive the key.
Reporting a vulnerability
If you find a bug that breaks any claim on this page, email hello@wisp.video with the subject “Wisp security”. We’ll respond within 48 hours. We don’t have a paid bug bounty yet, but credible reports will be acknowledged publicly (with your permission) on this page once fixed.