Client-side encryptie tutorial: bouw het vanaf nul
Stapsgewijze tutorial voor client-side encryptie implementatie. Versleutel bestanden in de browser voordat ze het apparaat verlaten.
Client-side bestandsversleuteling in de browser kost ruwweg 80 regels JavaScript met de Web Crypto API. Het patroon: genereer een AES-256-GCM sleutel in de browser, versleutel het bestand met een willekeurige 96-bits nonce, upload de ciphertext via HTTPS/TLS 1.3 en deel de resulterende URL met de sleutel ingebed in de fragment-identifier (#key=...) die browsers nooit naar servers sturen. De ontvanger ontsleutelt in de browser met datzelfde fragment. Deze tutorial doorloopt een werkende implementatie, inclusief chunking voor grote bestanden, wachtwoordafgeleide sleutels via PBKDF2 bij 600.000 iteraties en de valkuilen die eerste pogingen doen mislukken.
De architectuur in één schema
[Afzender Browser] [Server] [Ontvanger Browser]
Lees Bestand → AES sleutel (random) Accepteert POST GET ciphertext
Versleutel met AES-256-GCM Slaat ciphertext blob op Parseer sleutel uit URL #fragment
POST ciphertext Geen sleutel, geen plaintext Ontsleutel in browser
Bouw URL met #key=... Geeft download URL terug Sla bestand op schijf op
De server is een domme blob-opslag. Hij ziet alleen ciphertext en kan niet ontsleutelen. De ontsleutelingssleutel leeft in het URL-fragment, dat browsers speciaal behandelen: het wordt nooit meegestuurd in de HTTP-verzoekregel. Dit is de basis van elke zero-knowledge bestandsoverdrachtsservice inclusief HexaTransfer.
Stap 1: genereer een symmetrische sleutel
async function generateKey() {
return await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true, // extractable zodat we het kunnen exporteren naar de URL
["encrypt", "decrypt"]
);
}
De extractable: true vlag is vereist omdat we de sleutel moeten serialiseren naar een URL-fragment. Als je een stroom bouwt waarbij de sleutel alleen in het geheugen leeft (bijv. een plak-en-verzend tool), stel dit dan in op false.
Stap 2: lees het bestand als ArrayBuffer
async function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
Dit laadt het hele bestand in het geheugen. Prima voor bestanden onder de 500 MB. Voor grotere bestanden ga je verder naar de streaming-sectie.
Stap 3: versleutel de buffer
async function encryptFile(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
plaintext
);
// Voeg IV toe vóór de ciphertext zodat de ontvanger het kan extraheren
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
return combined.buffer;
}
De nonce (IV) is 96 bits (12 bytes), conform NIST SP 800-38D. Niet geheim, maar moet uniek zijn per sleutel. Willekeurige nonces zijn hier veilig omdat we een nieuwe sleutel genereren per bestand. De IV voor de ciphertext plaatsen is een gebruikelijke conventie; de ontvanger scheidt het er weer van af vóór het ontsleutelen.
Stap 4: upload de ciphertext
async function uploadCiphertext(ciphertext) {
const response = await fetch("/api/upload", {
method: "POST",
body: ciphertext,
headers: { "Content-Type": "application/octet-stream" },
});
const { fileId } = await response.json();
return fileId;
}
De server ontvangt een binaire blob, wijst er een ID aan toe, slaat hem op en geeft dat ID terug. Geen headers onthullen de bestandsnaam, geen queryparameters bevatten de sleutel. Als de harde schijf van de server morgen wordt gestolen, ziet een aanvaller wartaal.
Stap 5: bouw de deel-URL met de sleutel in het fragment
async function buildShareURL(fileId, key) {
const rawKey = await crypto.subtle.exportKey("raw", key);
const keyBase64 = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return `${location.origin}/f/${fileId}#${keyBase64}`;
}
Base64url-codering (met - en _ in plaats van + en /) vermijdt URL-escapingproblemen. De =-opvulling wordt verwijderd voor esthetiek.
Het fragment (#...) is hier het sleutelelement. Wanneer de ontvanger de URL laadt, houdt de browser het fragment client-side. De HTTP GET voor /f/{fileId} bevat #keyBase64 niet in de verzoekregel, zodat de server de sleutel nooit te weten komt. Controleer dit zelf door browser-devtools te openen op een URL met fragment en het tabblad Netwerk te bekijken.
Stap 6: ontsleuteling aan de ontvangstzijde
async function downloadAndDecrypt() {
const fileId = location.pathname.split("/").pop();
const keyBase64 = location.hash.slice(1);
const rawKey = Uint8Array.from(
atob(keyBase64.replace(/-/g, "+").replace(/_/g, "/")),
c => c.charCodeAt(0)
);
const key = await crypto.subtle.importKey(
"raw", rawKey, "AES-GCM", false, ["decrypt"]
);
const response = await fetch(`/api/download/${fileId}`);
const combined = new Uint8Array(await response.arrayBuffer());
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv }, key, ciphertext
);
const blob = new Blob([plaintext]);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "gedownload-bestand";
a.click();
}
De authenticatietag van GCM wordt gecontroleerd tijdens decrypt(). Als de ciphertext is gemanipuleerd, gooit de aanroep OperationError, een nette faalwijze.
Wachtwoordafgeleide sleutels via PBKDF2
Als gebruikers een wachtwoord opgeven in plaats van een willekeurige sleutel, leid de AES-sleutel af via PBKDF2:
async function deriveKey(password, salt) {
const passwordKey = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password),
"PBKDF2", false, ["deriveKey"]
);
return await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: 600000,
hash: "SHA-256",
},
passwordKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
600.000 iteraties van PBKDF2-SHA-256 is OWASP's 2023 baseline. De salt moet 16 willekeurige bytes zijn en worden opgeslagen naast de ciphertext (niet geheim, maar moet uniek zijn). Voor nieuwe code overweeg je Argon2id via een bibliotheek als argon2-browser — het biedt veel betere weerstand tegen GPU-aanvallen dan PBKDF2.
Streaming van grote bestanden
Bestanden boven de 500 MB moeten worden gesegmenteerd. Lees via File.stream(), versleutel elk stuk, upload opeenvolgend:
async function encryptStream(file, key) {
const reader = file.stream().getReader();
const chunks = [];
let chunkIndex = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const iv = new Uint8Array(12);
// Codeer chunk-index in de nonce om uniciteit te garanderen
new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, value
);
chunks.push({ iv, ct });
}
return chunks;
}
De nonce afleiden van de chunk-index garandeert uniciteit zonder staat bij te houden. Hersamenstelling aan de ontvangersijde ontsleutelt chunks in volgorde en voegt ze samen.
Voor echte streaming AEAD is libsodium's crypto_secretstream_xchacha20poly1305 via libsodium.js overzichtelijker en detecteert afkapingsaanvallen. Web Crypto heeft in 2026 nog geen gelijkwaardig primitief.
Testen en valkuilen
Veelgemaakte fouten om te vermijden:
Math.random()gebruiken voor sleutels of nonces: altijdcrypto.getRandomValues().- Een nonce hergebruiken met dezelfde sleutel: breekt GCM-beveiliging. Per-bestand willekeurige sleutels maken dit veilig; per-chunk stromen hebben unieke nonces per chunk nodig.
- Niet controleren op HTTPS:
crypto.subtleis undefined op onveilige origins. Test op localhost of met een zelfondertekend certificaat tijdens ontwikkeling. - Sleutels opslaan in
localStorage: elke XSS op je origin kan het lezen. Gebruik het URL-fragmentpatroon in plaats daarvan, of niet-extracteerbare sleutels. - Vergeten de IV mee te sturen bij de ciphertext: ontsleuteling mislukt zonder nuttige fout. Voeg altijd toe of serialiseer ernaast.
- Het fragment verkeerd behandelen: deel de URL (met fragment) niet per ongeluk via een dienst van derden. Deel alleen via end-to-end kanalen als het fragment gevoelig is.
Verantwoordelijkheden aan de serverzijde
De taak van de server in een client-side versleutelingsarchitectuur is klein: POST accepteren, blob opslaan, ID teruggeven, GET bedienen voor de blob, verwijderen bij vervaldatum. Geen crypto. Wat de server verder zou moeten doen:
- Bestandsgroottebeperkingen afdwingen (misbruik voorkomen)
- Uploads en downloads snelheidsbeperken
- Korte bewaarperiode instellen (7 dagen is een redelijke standaard, zoals HexaTransfer)
- Alleen loggen wat nodig is (uploadtijdstempel, geen IP's als privacy voorop staat)
- Bedienen via TLS 1.3 met HSTS
- CORS-headers beperken tot je eigen domeinen
Alles samenvoegen
Een minimale werkende app past in één HTML-bestand plus een backend van 50 regels Express. Totale afhankelijkheden: geen op de client (Web Crypto is native), Express plus multer op de server. De versleuteling is zo sterk als het AES-256-GCM primitief, want dat is letterlijk wat je gebruikt. Er is geen geheim algoritme dat je fout kunt krijgen, alleen de primitieven die je correct moet gebruiken.
De moeilijkste delen zijn de randgevallen: grote bestanden, wachtwoord-naar-sleutel stromen, ontvanger-UX wanneer ontsleuteling mislukt, verlopen links netjes afhandelen. De kern van de cryptografie is eenvoudig.
Probeer het op hexatransfer.com — gratis, zonder account, tot 10 GB.
Verstuur grote bestanden veilig met end-to-end-versleuteling
Draag bestanden tot 10 GB gratis over met end-to-end-versleuteling. Geen account nodig. Uw bestanden worden in uw browser versleuteld voordat ze worden geüpload — niemand anders kan ze lezen.
Een bestand verzenden