Encrypt Files Before Upload Using AES-256 in Node.js
A developer at a healthcare startup uploaded patient records to cloud storage last Tuesday. The bucket was configured correctly, HTTPS was enabled, and the team assumed the data was safe. Three months later an audit revealed that the storage provider's internal logs briefly cached unencrypted metadata. The startup faced a compliance review and six weeks of remediation work.
Client-side encryption solves that problem by wrapping files in a cryptographic envelope before they leave your machine. Even if an intermediary caches a chunk or a storage provider suffers a breach, the payload remains unreadable without your key. AES-256 is the symmetric cipher approved by the U.S. National Institute of Standards and Technology for top-secret information, and Node.js ships with a crypto module that implements it without third-party dependencies.
This guide walks through the mechanics of encrypting a file with AES-256-CBC, generating initialization vectors, managing keys, and integrating the process into an automated upload workflow. You will see working code, common pitfalls, and concrete steps to verify that your encryption actually works.
Why encrypt on the client instead of relying on HTTPS
HTTPS encrypts data in transit between your machine and the server. Once the payload arrives, the server decrypts it to process or store the file. That means the storage provider, any logging middleware, and anyone with access to the server's memory can see your data in plaintext.
Client-side encryption shifts the trust boundary. You encrypt the file before it touches the network, and only you hold the decryption key. The server stores ciphertext. Even administrators at the storage company cannot read the content. This approach is essential when handling medical records, financial statements, or legal documents under regulations like HIPAA or GDPR, which require that data remain confidential at rest.
Organizations that manage large-scale file transfers often layer both protections. HTTPS prevents man-in-the-middle attacks during transmission; client-side encryption guards against breaches at the destination. Together they close gaps that single-layer security leaves open.
How AES-256-CBC works in the crypto module
AES operates on fixed 128-bit blocks. The 256 in AES-256 refers to the key length in bits, which determines the cipher's resistance to brute-force attacks. CBC, or cipher-block chaining, is a mode that XORs each plaintext block with the previous ciphertext block before encryption. This ensures that identical plaintext blocks produce different ciphertext, hiding patterns in the data.
Node.js exposes AES-256-CBC through the crypto.createCipheriv function. You supply three arguments: the algorithm string, a 32-byte key, and a 16-byte initialization vector. The IV must be random and unique for every encryption operation. Reusing an IV with the same key leaks information about the plaintext and undermines the security of the entire scheme.
A typical workflow looks like this. Generate a random 32-byte key and a random 16-byte IV. Create a cipher instance with those values. Pipe your file stream through the cipher, which emits encrypted chunks. Append the IV to the ciphertext so you can retrieve it during decryption. Store the key separately, never alongside the encrypted file.
Generate and store keys securely
The simplest way to create a key is crypto.randomBytes(32). This function pulls entropy from the operating system's secure random number generator. On Linux that means /dev/urandom; on Windows it uses CryptGenRandom. Both sources are suitable for cryptographic purposes.
Never hard-code keys in your source code or commit them to version control. A leaked key renders every file encrypted with it readable by anyone. Store keys in environment variables, a dedicated secrets manager like AWS Secrets Manager or HashiCorp Vault, or a hardware security module if your compliance framework requires it. Rotate keys periodically and re-encrypt files with the new key to limit exposure if an old key is compromised.
For teams that upload files from multiple machines, consider a key-derivation function like PBKDF2 or Argon2. These algorithms stretch a user password into a 32-byte key by applying thousands of hash iterations, making brute-force attacks prohibitively expensive. Node.js provides crypto.pbkdf2Sync for this purpose. Choose at least 100,000 iterations for PBKDF2-HMAC-SHA256 and store the salt alongside the encrypted file so you can regenerate the key during decryption.
Encrypt a file with a readable stream
Reading an entire file into memory before encrypting it works for small documents but fails when you handle gigabyte-scale archives or video files. Streams let you process data in chunks, keeping memory usage constant regardless of file size.
Here is a minimal example. Import the crypto and fs modules. Generate a key and IV. Create a read stream from the source file and a write stream to the destination. Call crypto.createCipheriv with 'aes-256-cbc', the key, and the IV. Pipe the read stream into the cipher, then pipe the cipher output into the write stream. Prepend the IV to the output file so decryption can retrieve it.
A working snippet: const crypto = require('crypto'); const fs = require('fs'); const key = crypto.randomBytes(32); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); const input = fs.createReadStream('document.pdf'); const output = fs.createWriteStream('document.pdf.enc'); output.write(iv); input.pipe(cipher).pipe(output); output.on('finish', () => console.log('Encrypted')); This writes the IV at the start of the ciphertext. During decryption you read the first 16 bytes to extract the IV, then pass the remainder through crypto.createDecipheriv.
Integrate encryption into an upload workflow
Once you have an encrypted file, treat it like any other binary blob. Upload it to S3, Google Cloud Storage, or an FTP server using the same libraries and retry logic you apply to unencrypted transfers. The storage provider sees only ciphertext and cannot index or search the content.
Automated pipelines benefit from chaining encryption and upload in a single stream. Instead of writing the encrypted file to disk and then uploading it, pipe the cipher output directly into an HTTP request or an S3 upload stream. This eliminates temporary files and reduces disk I/O. Libraries like aws-sdk for JavaScript support streaming uploads through the upload method with a readable stream as the Body parameter.
Track the IV and key metadata alongside each file. A common pattern is to store the IV as the first 16 bytes of the uploaded blob and to record the key identifier in a database row or a separate metadata file. When a user requests the file, retrieve the key, read the IV from the blob, decrypt the stream, and serve the plaintext. This design keeps encryption transparent to end users while maintaining strong security boundaries.
Verify encryption with a decryption test
The only way to confirm that your encryption works is to decrypt a file and compare it byte-for-byte with the original. Automated tests catch configuration mistakes before they reach production.
Write a test that encrypts a known plaintext file, decrypts the result, and asserts that the output matches the input. Use a fixed key and IV for the test so results are reproducible. Check that the encrypted file is larger than the plaintext by at least 16 bytes, accounting for the IV. Verify that changing a single bit in the key or IV causes decryption to fail or produce garbage, confirming that the cipher is actually protecting the data.
Run this test in your continuous integration pipeline. If a dependency update or a refactor breaks encryption, the test will fail immediately. Manual verification is unreliable because subtle bugs, like reusing an IV or truncating the ciphertext, may not produce obvious errors until an attacker exploits them.
Handle padding and block alignment
AES operates on 16-byte blocks. If your plaintext length is not a multiple of 16, the cipher must pad the final block. Node.js uses PKCS#7 padding by default, which appends bytes equal to the number of padding bytes needed. For example, if the plaintext is 14 bytes short of a full block, the cipher appends two bytes each with the value 0x02.
Decryption automatically strips the padding. You do not need to write extra code. However, if you manually manipulate ciphertext or implement a custom padding scheme, remember that incorrect padding causes decryption to throw an error or return corrupted data. Stick with the default unless you have a specific reason to change it.
Some developers worry that padding leaks information about plaintext length. In most file-transfer scenarios this risk is negligible. If you must hide length, consider adding random bytes to the plaintext before encryption or using a mode like AES-GCM that does not require padding.
Compare CBC with GCM for authenticated encryption
AES-256-CBC encrypts data but does not verify integrity. An attacker who intercepts the ciphertext can flip bits and alter the decrypted plaintext in predictable ways. If your application parses the decrypted data without validation, this can lead to security vulnerabilities.
AES-256-GCM, or Galois/Counter Mode, adds a message authentication tag that detects tampering. Node.js supports GCM through crypto.createCipheriv('aes-256-gcm', key, iv). After encryption, call cipher.getAuthTag() to retrieve a 16-byte tag and append it to the ciphertext. During decryption, extract the tag and pass it to decipher.setAuthTag(tag). If the ciphertext has been modified, decryption will throw an error.
GCM is usually the better choice for new projects. It provides both confidentiality and authenticity in a single operation, and the performance overhead is minimal on modern CPUs that support AES-NI instructions. The main reason to use CBC is compatibility with legacy systems that do not support GCM.
Key facts
- AES-256 uses a 32-byte key and operates on 16-byte blocks.
- The initialization vector must be 16 bytes and unique for each encryption.
- PKCS#7 padding appends bytes equal to the number of padding bytes needed.
- AES-GCM adds a 16-byte authentication tag to detect tampering.
Can I use the same key for multiple files?
Yes, as long as you generate a unique IV for each file. Reusing a key with different IVs is safe and simplifies key management. Rotate the key periodically to limit exposure if it leaks.
What happens if I lose the decryption key?
The encrypted file becomes permanently unreadable. AES-256 has no backdoor. Back up keys in a secure location and test your backup-and-restore process regularly.
Does encryption slow down uploads?
Modern CPUs with AES-NI instructions encrypt at several gigabytes per second. Network bandwidth is usually the bottleneck. Streaming encryption adds negligible overhead compared to disk I/O and HTTP latency.
Related reading: About us · What we do