“We encrypt client-side” sounds like one line in a security checklist, next to TLS and password hashing. It isn’t. Deciding to encrypt data before it ever leaves the machine that produced it is an architectural commitment, and it changes what every other part of the system is allowed to assume about the data it’s holding. Once ciphertext is all a server ever sees, that server has to be designed as if it can’t be trusted with the content, because it genuinely can’t reconstruct it.
The alternative, encrypting at the server, keeps the server able to see plaintext at some point in its pipeline, even if only briefly. That’s a completely different trust posture: the server is a participant that happens to encrypt data before persisting it, not a component that’s structurally blind to it. Client-side encryption forecloses that. The server becomes a place to store and move bytes it cannot interpret, and every feature that would benefit from interpreting those bytes has to either be redesigned around ciphertext or dropped.
What breaks first: deduplication
Deduplication is the clearest casualty, because it’s easy to build correctly against plaintext and easy to build silently wrong against ciphertext without realizing it.
SecStore, a small erasure-coded object store, dedupes uploads by hashing the file and checking for an existing row with the same hash before doing any erasure-coding work:
# coordinator/main.py
async def core_upload(filename: str, file_bytes: bytes, k: int, n: int, encryption_iv: str = None):
content_hash = get_hash(file_bytes)
...
cursor.execute(
'SELECT file_id, version_id, original_size, padding_len, k, n, manifest '
'FROM files WHERE content_hash = ? LIMIT 1',
(content_hash,)
)
existing = cursor.fetchone()
if existing:
...
return {"message": "Deduplicated successfully", "filename": filename, "deduplicated": True}
Encryption happens on the client, before the HTTP upload, in client.py:
# client.py
def encrypt_data(data: bytes, key: bytes):
aesgcm = AESGCM(key)
iv = os.urandom(12)
ciphertext = aesgcm.encrypt(iv, data, None)
return iv, ciphertext
# client.py — upload()
iv, file_bytes = encrypt_data(file_bytes, key)
...
resp = requests.post(f"{COORDINATOR_URL}/upload/",
files={"file": (os.path.basename(args.local_path), file_bytes)},
data={"k": args.k, "n": args.n, ...})
By the time file_bytes reaches core_upload and gets hashed, it’s already ciphertext. content_hash is a hash of the encrypted output, not the plaintext. That has a direct consequence: AES-GCM uses a fresh random 12-byte IV on every call to encrypt_data, so encrypting the exact same file twice produces two different ciphertexts and two different hashes. Upload the identical document twice with --encrypt, and the server has no way to know they’re the same file. Deduplication, which worked perfectly on plaintext, silently stops doing anything useful the moment encryption is turned on, and nothing in the code path raises an error to say so. It just always takes the “no match, store a new copy” branch.
This isn’t a bug to patch. It’s the direct, correct consequence of the server never seeing the plaintext. Fixing it while keeping the server blind would mean deduplicating on a plaintext hash computed client-side and sent alongside the ciphertext, which trades some confidentiality (the server now learns which uploads share content, an information leak of its own) for the storage savings back. There’s no version of this where you get server-side dedup and a server that’s fully blind to content at the same time. You pick one, and the choice has to be made deliberately rather than discovered later when the storage bill doesn’t match expectations.
What still works, because it never needed plaintext
Not everything server-side breaks. SecStore’s erasure coding, integrity checks, and replication topology all operate on the ciphertext exactly the same way they’d operate on plaintext, because none of that logic ever needed to interpret the bytes, only to split them, checksum them, and put them back together correctly:
# coordinator/main.py — download_file()
if isinstance(r, Exception) or r.status_code != 200 or get_hash(r.content) != meta_list[i]["hash"]:
missing.append(idx_list[i])
The per-chunk SHA-256 verification here works identically whether the underlying bytes are plaintext or ciphertext, because integrity checking only asks “is this the same bytes I wrote,” never “what do these bytes mean.” That’s the general shape of what survives client-side encryption: anything defined purely over the byte stream, without needing semantic access to it, keeps working. Anything that needs to understand or compare content, deduplication, full-text search, thumbnail generation, virus scanning that inspects file contents, stops working or has to move to the client.
What the server never has: the key
The other half of the design is what never crosses the wire to the coordinator at all. The AES-256 key is generated client-side and kept in a local keyring file; only the per-file IV travels with the upload, as a request field and later a response header:
# client.py
key = AESGCM.generate_key(bit_length=256)
key_b64 = base64.b64encode(key).decode()
print(f"[!] Generated new encryption key (Please copy/save this!): {key_b64}")
save_key(args.remote_path, key_b64)
An IV isn’t secret; it doesn’t need to be, since GCM’s security depends on the key staying secret and the IV never repeating under the same key, not on the IV being hidden. Sending it to the server costs nothing. Sending the key would cost everything: the moment the server has both ciphertext and key, “client-side encryption” is just encryption-in-transit with extra steps, because whoever controls the server can decrypt on demand. A coordinator that’s compromised, subpoenaed, or simply misconfigured to log request bodies still can’t produce plaintext from what it stores, because it was never handed the one piece of information needed to do so.
The general principle
The decision to encrypt client-side isn’t a checkbox next to “use AES-256 instead of AES-128” or “use GCM instead of CBC.” It’s a decision about which component in the system is allowed to understand the data it’s holding, and it has to be made before you design any feature that wants to inspect, index, deduplicate, or otherwise reason about that data server-side. Pick client-side encryption and mean it, and the server has to be built as infrastructure for opaque bytes: reliable, available, verifiable at the byte level, and permanently unable to answer any question that requires reading the content. Every feature request that implicitly assumes the server can see what it’s storing is really a request to reopen that decision, and it’s worth recognizing it as one before quietly building a compromise nobody agreed to.