civil-and-structural-engineering
Implementing Client-side Encryption for Sensitive Data in Javascript
Table of Contents
Why Client-Side Encryption Matters in Modern Web Applications
Web applications increasingly handle sensitive data such as personal identifiable information (PII), financial details, health records, and authentication tokens. A server-side breach can expose all unencrypted data stored or processed by your backend. Client-side encryption addresses this risk by ensuring that sensitive data is encrypted inside the user’s browser before it ever reaches your server. Even if an attacker gains full control of your database or API, they will only see ciphertext—useless without the decryption key that never leaves the client.
This approach is particularly valuable in zero-knowledge architectures, where the server should never have access to plaintext data. Popular applications like password managers (e.g., Bitwarden) and end-to-end encrypted messaging platforms rely on client-side encryption to maintain user trust. By implementing encryption in JavaScript, you can build systems where the server is effectively “blind” to the content it stores and transmits.
Understanding the Cryptographic Tools Available in JavaScript
Two primary approaches dominate client-side encryption in JavaScript: third-party libraries like CryptoJS and the native Web Crypto API. Each has distinct advantages and trade-offs in security, performance, and browser compatibility.
CryptoJS: Simplicity with Historical Context
CryptoJS is a mature JavaScript library that provides a wide range of cryptographic algorithms, including AES, DES, SHA‑256, and HMAC. Its API is straightforward, making it accessible for developers new to cryptography. Here’s a typical example of AES encryption with CryptoJS:
const message = "Sensitive information";
const key = CryptoJS.enc.Utf8.parse('1234567890123456'); // 16-byte AES-128 key
const encrypted = CryptoJS.AES.encrypt(message, key, {
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}).toString();
console.log(encrypted); // base64-encoded ciphertext
While easy to use, CryptoJS has known limitations. It relies on a custom random number generator that may not be cryptographically secure in all environments. The library also lacks built-in support for authenticated encryption modes like GCM, forcing developers to implement their own integrity checks—a common source of vulnerabilities. For legacy browsers or quick prototypes, CryptoJS can still serve, but for production systems, the Web Crypto API is strongly recommended.
Web Crypto API: The Modern Standard
The Web Crypto API is a native browser API that provides low-level cryptographic primitives with a strong security guarantee. It uses the operating system’s cryptographic services and hardware-backed secure random number generators. This API supports modern authenticated encryption modes such as AES-GCM and RSA-OAEP, which simultaneously provide confidentiality and integrity.
Here’s a complete example of encrypting data with AES-GCM using the Web Crypto API:
async function generateKey() {
const key = await crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt", "decrypt"]
);
return key;
}
async function encryptData(key, data) {
const encoded = new TextEncoder().encode(data);
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encoded
);
return { encrypted, iv };
}
// Usage
generateKey().then(async (key) => {
const { encrypted, iv } = await encryptData(key, "Sensitive info");
console.log(new Uint8Array(encrypted)); // ciphertext as ArrayBuffer
console.log(iv); // initialization vector (must be stored/transmitted for decryption)
});
The Web Crypto API requires asynchronous handling because cryptographic operations can be CPU-intensive. It also forces developers to explicitly manage key generation, storage, and export—which encourages better security practices. All major modern browsers support the Web Crypto API, making it the go-to choice for new projects.
Key Management: The Hardest Part of Client-Side Encryption
Encrypting data is relatively simple; managing keys securely is where most implementations fail. In a client-side encryption scenario, the decryption key must never leave the user’s device or be transmitted to the server. This creates a fundamental challenge: how does the user regain access to their encrypted data from a different device or after clearing browser storage?
Key Derivation from User Passwords
One common solution is to derive the encryption key from a user’s password using a key derivation function (KDF) like PBKDF2, Argon2, or bcrypt. The password is never stored on the server; instead, a salted hash is used for authentication, and the key is derived independently on the client side. For example:
async function deriveKeyFromPassword(password, salt) {
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"]
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 600000, // high iteration count for security
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
return key;
}
This approach allows the user to decrypt their data on any device by entering their password. However, it introduces a password-strength dependency—weak passwords can be brute-forced offline if the encrypted data and salt are exposed. Use strong password policies and consider combining with a hardware security key.
Storing and Transmitting Keys
Never store encryption keys in localStorage or sessionStorage in plaintext. If you must persist a key across sessions, consider encrypting it with a key derived from the user’s password or using the Web Crypto API’s export functionality with a wrapping key. For server-assisted key storage, employ a technique like key splitting (Shamir’s Secret Sharing) where the server holds one share and the client another, requiring both to reconstruct the key.
For data that must be shared between users (e.g., collaborative documents), implement a hybrid cryptosystem: encrypt the document with a symmetric key, then encrypt that key with the recipient’s public key using RSA-OAEP. The encrypted symmetric key can be stored on the server alongside the ciphertext.
Integrating Client-Side Encryption into a Fleet Directus Workflow
Fleet Directus often handles sensitive operational data such as vehicle locations, driver information, and customer contracts. By applying client-side encryption, you can ensure that even if the Directus database is compromised, the plaintext data remains inaccessible. The typical workflow involves three phases:
- Encrypt before sending: In your JavaScript frontend (e.g., a Next.js app or React dashboard), intercept the data before it is submitted to the Directus REST or GraphQL API. Encrypt fields like driver license numbers, credit card details, or geolocation coordinates using a per-session or per-document key.
- Store ciphertext: Transmit the ciphertext (as a base64 string) plus any necessary metadata (IV, salt, key ID) to Directus. Store these as separate fields or as a JSON blob in a Document or Collection item.
- Decrypt on retrieval: When fetching data, the client application receives the ciphertext and metadata, then decrypts using the same key (derived from password or stored in a secure client-side container).
Because Directus is headless, your API endpoints are fully customizable. You can use hooks or custom actions to validate that incoming data is already encrypted, ensuring no plaintext leaks to the server.
Best Practices for Robust Client-Side Encryption
- Always use authenticated encryption like AES-GCM or ChaCha20-Poly1305. These modes prevent tampering and ensure integrity.
- Generate random IVs/nonces for each encryption operation. Never reuse an IV with the same key—doing so completely breaks security.
- Salt and iterate when deriving keys from passwords. Use at least 600,000 iterations for PBKDF2 and prefer Argon2id if available (via a polyfill or WebAssembly).
- Transmit encrypted data over HTTPS. While the ciphertext is already confidential, HTTPS protects the metadata (like IV, salt, key IDs) and prevents man-in-the-middle attacks that could swap ciphertext blocks.
- Keep libraries updated. Subscribe to security advisories for CryptoJS or Web Crypto API wrappers. Prefer the native API to reduce supply chain risk.
- Never log or expose plaintext in client-side console statements or error messages. Production builds should strip debugging information.
- Use a key rotation policy. Allow users to re-encrypt their data with a fresh key, especially after a potential breach. This can be implemented by decrypting all user data server-side (but client-side encryption means the server can’t do that—instead, the client re-encrypts locally under the new key and uploads the updated ciphertext).
Common Pitfalls and How to Avoid Them
Treating Client-Side Encryption as a Silver Bullet
Client-side encryption protects data at rest on the server and in transit, but it does not protect against client-side attacks (e.g., XSS, malicious browser extensions). An attacker who can execute JavaScript in the user’s session can intercept the decryption key or plaintext. Therefore, client-side encryption must be combined with strict Content Security Policy (CSP), input sanitization, and secure session management.
Rolling Your Own Cryptography
Even experienced developers can inadvertently introduce vulnerabilities when combining algorithms. Always use well-vetted libraries and standard protocols. Avoid implementing custom encryption schemes like XOR with a static key or “obfuscation” as a substitute for real encryption.
Neglecting to Plan for Key Recovery
If a user loses their password or authentication device, encrypted data becomes permanently inaccessible. Offer backup methods like recovery codes, passphrase-based key escrow (where the encrypted key is split), or hardware security keys. Document these options clearly in the user interface.
Overlooking Performance and UX Impacts
Encrypting large files or high-frequency data streams (e.g., real-time GPS updates) can degrade performance. Web Crypto API operations are asynchronous, but still block the main thread for small payloads. Consider using Web Workers for encryption of large datasets, or only encrypting the most sensitive fields rather than entire documents.
External References and Further Reading
- MDN Web Docs: Web Crypto API
- OWASP Cryptographic Storage Cheat Sheet
- NIST Guidelines on Cryptographic Algorithms and Key Lengths
- CryptoJS GitHub Repository (archived)
- Soatok: Client-Side Encryption Is Broken (critical perspective)
Conclusion
Client-side encryption in JavaScript is a powerful tool for building privacy-preserving web applications, but it requires careful attention to key management, algorithm selection, and threat modeling. By leveraging the Web Crypto API with authenticated encryption modes, deriving keys from user passwords via strong KDFs, and never exposing plaintext to the server, you can protect sensitive data even in the event of a server compromise. While not a complete security solution on its own, client-side encryption—when implemented correctly—forms a critical layer in a defense-in-depth strategy for fleet management systems, healthcare portals, finance dashboards, and any application handling data that must remain confidential.
Start by auditing your current data flows: identify which fields truly require end-to-end secrecy. Then prototype with the code examples above, test thoroughly across browsers, and invest in secure key recovery mechanisms. Your users’ trust—and your compliance with regulations like GDPR, HIPAA, and PCI‑DSS—depend on getting encryption right from the client side up.