Flowvault 1.5 ships an Encrypted File Send: drop a file (up to 10 MiB), pick how long it lives (max 7 days) and how many times it can be downloaded, and share the link. The file is AES-256-GCM encrypted in your browser before a single byte leaves your device, so our Cloud Storage bucket only ever sees opaque ciphertext. You also get a separate secure delete link— a token bound to the upload by SHA-256 — so you can destroy the upload yourself at any moment, without waiting for the expiry or the download cap.
Why a file primitive when Encrypted Send already exists
Flowvault's Encrypted Send is for short text: a password, an API key, a recovery phrase. It caps the plaintext at 128 KiB precisely because it isn't supposed to be a file transfer tool. But every few days I hit a case where the secret is a file:
- A signed PDF with a banking instruction the recipient is supposed to read once and shred.
- A KeePassXC database, a wallet's recovery JSON, or a screenshot of a recovery sheet that doesn't fit cleanly into a copy-pasted code block.
- A small archive (a
.zipof TLS certs, a provisioning bundle, a serialized config) that I'd rather not park in chat history for the next decade. - A short voice memo or a few-second screen recording that happens to contain a number nobody should be able to read twice.
For all of those, the right primitive is the same as Encrypted Send — one-shot, view-capped, expiring, end-to-end encrypted — just sized for files and with proper file semantics on both ends (a real name, a real contentType, a Save dialog instead of a textarea). That is what File Send is.
The shape of a file send
From the sender's side, the flow is identical to Encrypted Send except step 1:
- Drop a file at /file/new. The browser reads the bytes locally; nothing is uploaded yet.
- Pick an expiry presets (1 hour, 1 day, 3 days, 7 days — 7 is the hard ceiling).
- Pick a download cap (1, 2, 5, or 10).
- Optionally tick Also require a password to download and type a password. This is the same Argon2id-derived inner layer Encrypted Send uses, mixed into HKDF before the content key is derived.
- Click
Create file send. The browser encrypts the file, uploads ciphertext to Cloud Storage, then writes a metadata document to Firestore. - You're shown two links. Copy them now — we don't store them and we can't show them again.
Download link (share with recipient) useflowvault.com/file/<id>#k=<base64url-256-bit-key> Secure delete link (keep for yourself) useflowvault.com/file/<id>/delete#t=<base64url-256-bit-token>
Both fragments stay in the browser. Browsers never include the part after # in HTTP requests, so the AES key and the delete token never reach Flowvault, even if the recipient clicks the link from inside a chat client that aggressively previews URLs.
The crypto, in twelve lines
File Send uses the same WebCrypto primitives as the rest of Flowvault: AES-256-GCM for confidentiality + integrity, Argon2id (64 MiB / 3 iterations) for the optional password layer, HKDF-SHA-256 for domain separation. Nothing exotic, nothing rolled by hand.
K = randomBytes(32) // url fragment key
deleteToken = randomBytes(32) // delete-link token
salt = password ? randomBytes(16) : null
baseKey = password
? K || Argon2id(password, salt)
: K
contentKey = HKDF(baseKey, info="flowvault:fileSend:v1:content")
metadataKey = HKDF(baseKey, info="flowvault:fileSend:v1:metadata")
contentCiphertext = AES-256-GCM(contentKey, fileBytes)
metadataCiphertext = AES-256-GCM(metadataKey, JSON({name, contentType, size}))
deleteTokenHash = SHA-256(deleteToken)The content ciphertext is uploaded to fileSends/<id> in Cloud Storage. The metadata ciphertext, the delete-token hash, the expiry, the view counters, and (when password mode is on) the Argon2id salt go into the Firestore document at the same id. Direct client reads are denied on both sides; the only path to the bytes is through a Cloud Function that atomically consumes a view.
Reading the file (without burning the cap)
The viewer at /file/<id>deliberately click-gates everything. Because the default cap is 1, fetching on mount would let a chat-client preview, a browser prefetch, or React Strict Mode’s double-effect silently burn the only download. Instead the page shows a confirmation card and does nothing until the recipient clicks Open the file.
On click, the flow is:
- The browser calls
readFileSend(id), a Cloud Function. In a Firestore transaction, the function checks expiry and view count, increments the counter, and (on the last view) marks the document for deletion. - The function generates a v4 signed URL for the storage object that lives 5 minutes, then returns it alongside the metadata ciphertext, the password salt (if any), and a
passwordProtectedflag. - The viewer first decrypts the metadata blob in the browser using
K. If the file is password-protected and no password has been provided yet, this fails fast and the UI shows a password prompt — beforespending bandwidth on a possibly-10 MiB body. - With metadata in hand (filename, MIME type, original size), the viewer streams the ciphertext from Cloud Storage with a progress bar. The bytes go directly browser ↔ Storage; the Cloud Function never proxies the body.
- The browser AEAD-decrypts the ciphertext into a
Blob, slaps anobject URLon it, and renders aSave to devicebutton with the original filename. The recipient clicks, the OS save dialog opens, the plaintext lands on disk. - If that download was the final allowed one, the Firestore doc is already gone — the next reload of the link will say Already downloaded. The Storage object itself is collected on the next sweep tick (the signed URL stays valid for a 5-minute grace window so the in-flight download isn't cut off).
The secure delete link, and why it's a separate token
The secure delete link is the feature that distinguishes File Send from a plain “send and forget” uploader. It gives the sender an immediate, unilateral kill switch — useful when:
- You realise you uploaded the wrong file, or attached the wrong recipient's document.
- The recipient says “I’ve got it,” and you want the upload gone now rather than at the 7-day expiry.
- The link gets accidentally CC'd to a wider distribution and you need to revoke it before the cap is consumed.
The token is a fresh 256-bit value generated alongside K. The server stores only its SHA-256:
# at create time:
deleteToken = randomBytes(32)
deleteTokenHash = SHA-256(deleteToken)
firestore.set({
..., deleteTokenHash: <bytes>,
})
# at delete time:
provided = base64UrlDecode(t) // from URL fragment
SHA-256(provided) == deleteTokenHash // constant-time compare
? delete storage object + firestore doc
: forbiddenThe hash-only-on-the-server design means a Flowvault employee, a curious sysadmin, or someone with a leaked database snapshot cannot use what they see to destroy your upload. The token is only in your URL fragment, which never reached us in the first place.
It also means we can't send you a “recover your delete link” email, because we don't have your email and we don't have the token. Treat the secure delete link the same way you treat the download link: copy it when we show it to you, save it somewhere you control, and understand that we cannot help you re-derive it later.
What the server actually sees (the honest list)
Same disclosure standard as the rest of Flowvault. The server sees:
- Storage object
- The ciphertext bytes (
iv || AES-GCM ciphertext || tag). Same length as your file plus 28 bytes of AEAD overhead. MIME type forced toapplication/octet-streamon the way in. - Ciphertext size
- Approximately equal to the original file size. So encrypted file size leaks — the same way it does for Bitwarden Send, OneTimeSecret, and every other zero-knowledge uploader.
- Metadata blob
- A small AEAD-encrypted JSON object containing
name,contentType, andsize. The server can't open it. - Expiry, view counters
- Needed for enforcement.
- passwordProtected, passwordSalt
- A boolean and (when on) a 16-byte Argon2id salt, so the recipient's browser can re-derive the same password key. The salt alone is useless without the password and the URL fragment.
- deleteTokenHash
- A 32-byte SHA-256 digest. Useless without the original token.
- Upload time, object id
- Cloud Storage bookkeeping. The id is a random 24-char nanoid; we never assigned anything semantic to it.
It does not see: the filename, the MIME type, the file content, the AES key, the password (if any), the delete token, an account, an email, an IP-correlated session, or any long-lived identifier.
The lifecycle, including failure modes
Two scheduled functions run every hour and clean up state. The sender doesn't need to think about either.
- fileSendsSweep handles three cases:
- Documents whose
expiresAthas passed — storage object + Firestore doc both deleted. - Documents flagged consumed (
consumedAtolder than the signed URL TTL plus a small buffer) — the recipient's 5-minute download window has definitely closed, so the storage object is safe to drop. The Firestore doc is already gone from the read transaction. - Orphan storage objects: an upload that finished but whose Firestore
createnever landed (network hiccup, refused write, etc.). These are detected by listing the bucket and cross-referencing Firestore, and are deleted once they're older than the 7-day max retention plus an hour of slack.
- Documents whose
- readFileSendis the only path that increments the view counter. It runs in a Firestore transaction, so two concurrent reads never both see “available” on the last view.
- deleteFileSend deletes the storage object first, then the Firestore document. If the storage delete fails (rare; usually a permissions blip), the doc still gets deleted so the link stops working immediately, and the orphan-cleanup branch of the sweep eventually catches up.
The honest threat-model section
The same caveats that apply to Encrypted Send apply here. A few are worth re-stating because file uploads feel weightier than text snippets:
- The link is the credential. Anyone who has the full URL (including the fragment) can download until the cap is consumed. Treat it like the file itself.
- Chat clients sometimes prefetch links.A link in Slack, Discord, or some email scanners can be fetched by a server-side bot. The browser-only fragment-key design means the prefetcher cannot decrypt the file, but for non-password sends, a prefetch counts toward the download cap if the bot follows redirects far enough. If you're sending into one of these channels, prefer either a download cap of 2 (so the bot doesn't starve the human) or a password gate (which the bot won't have).
- Network observers see file size.A 9.4 MiB ciphertext is a 9.4 MiB file plus 28 bytes. We don't pad. If size leakage is in your threat model, archive multiple files into a fixed-size container before uploading.
- Lose either link, lose the use case. Without the download link, no one (including us) can decrypt the file. Without the delete link, you're waiting on the cap or expiry. We literally cannot help recover either.
- 10 MiB is a hard cap, by design. Flowvault is not a Dropbox replacement. If you need to send a hundred-MiB build artifact, use OnionShare, Magic Wormhole, or Bitwarden Send Files. We may raise the cap in a future release, but it will stay scoped to “documents and screenshots,” not “datasets and videos.”
How it compares
A condensed version of the comparison table from Encrypted Send vs Bitwarden Send vs Privnote, adjusted for file-shaped products.
Flowvault Bitwarden OnionShare Hat.sh Firefox
File Send Send Files (web) Send (RIP)
Account required no yes no no n/a
Open-source frontend yes yes yes yes yes
Open-source server yes yes n/a (P2P) yes yes
Self-hostable yes yes yes yes defunct
Key in URL fragment yes yes n/a (Tor) yes yes
Sender-controlled delete yes yes n/a no no
Default lifetime 1 day 30 days while open one shot 24 h
Max retention 7 days 30 days n/a one shot ~30 d
Default download cap 1 configurable n/a one shot one shot
Max payload 10 MiB ~500 MB unbounded any ~2.5 GB
Recipient experience web link web link Tor browser web link web link
Tor / VPN friendly yes yes yes (req) yes yesFile Send is the smallest, simplest one in that table. That is the point. If your file is tiny, ephemeral, and meant for one or two recipients, the right shape is the one that gives you a link, an expiry, a cap, and a kill switch — and gets out of the way. If your file is large or your audience is broad, use a different tool.
Practical recipes
Sending a recovery PDF to a colleague
Use a download cap of 2 and a 1-day expiry. Tick the password gate; share the password over a different channel (signal, in person, voice). Tell the colleague: “This link expires in a day; if it says ‘Already downloaded’, ping me and I'll regenerate.” When they confirm receipt, open your secure delete link to remove the upload immediately.
Sharing a CI artifact with a contractor
Use a 7-day expiry and a download cap of 5 (so they can re-download from a different machine without a panic). Skip the password unless the artifact contains secrets the contractor shouldn't be able to read in their email thread. Hold on to the secure delete link and fire it the moment the contract ends.
Sending yourself a config across an air-gap
Use a 1-hour expiry and a download cap of 1, no password. The purpose is “move bytes from machine A to machine B in the next ten minutes” — the secure delete link is your reassurance that if you accidentally close the laptop before downloading, you can wipe it without waiting an hour.
See also
/file/new— create a File Send link in three clicks.- Encrypted Send vs Bitwarden Send vs Privnote — the cousin primitive when the secret is text rather than a file.
- /security— the exact crypto and security rules for File Send: AES-256-GCM with HKDF subkeys, optional Argon2id password layer, Cloud Storage rules, Cloud Function source.
- The beginner's guide — the broader walkthrough that now includes File Send.