**Skill Level:** Intermediate | **Time to Complete:** 30-45 minutes | **Prerequisites:** Basic JavaScript knowledge, understanding of async/await
What You'll Learn
By the end of this tutorial, you'll be able to: ✓ Implement AES-256-GCM encryption using the Web Crypto API ✓ Derive secure encryption keys from passwords using PBKDF2 ✓ Generate cryptographically secure random values (IVs and salts) ✓ Handle encryption and decryption with proper error handling ✓ Understand browser compatibility and performance considerations ✓ Follow security best practices for client-side encryption
Why This Tutorial Matters
Encryption isn't just for security experts anymore. Modern web applications frequently need to protect sensitive user data, whether you're building a password manager, secure messaging app, file storage system, or any application handling private information.
The Web Crypto API brings military-grade cryptography to the browser, but its low-level nature makes it challenging to use correctly. Small mistakes can completely compromise security. This tutorial shows you the right way to implement AES-256 encryption that's both secure and practical.
**Real-World Use Cases:** - Building a client-side password manager where passwords never leave the user's device unencrypted - Creating a secure note-taking app with end-to-end encryption - Encrypting files before uploading to cloud storage - Implementing secure messaging features - Protecting sensitive form data before transmission
Understanding the Core Concepts
Before we dive into code, let's understand what we're working with.
AES-256-GCM Explained
AES (Advanced Encryption Standard) is a symmetric encryption algorithm, meaning the same key encrypts and decrypts data. The "256" refers to the key size in bits - longer keys mean stronger security. GCM (Galois/Counter Mode) is an operating mode that provides both encryption and authentication.
Think of it like a highly sophisticated lock and key system. The lock (encryption algorithm) is public knowledge - everyone knows how AES works. But without the exact key, encrypted data is practically impossible to decrypt. With a 256-bit key, there are 2^256 possible keys. To put that in perspective, that's more than the number of atoms in the observable universe.
Key vs Password: A Critical Distinction
Users think in terms of passwords: "mySecurePassword123". Cryptographic algorithms need keys: exactly 256 random bits for AES-256. We bridge this gap using key derivation.
A key derivation function (KDF) transforms a password into a cryptographic key. We use PBKDF2 (Password-Based Key Derivation Function 2), which repeatedly applies a cryptographic hash function to make the process computationally expensive. This slows down brute-force attacks.
The IV (Initialization Vector)
An IV is a random value used to ensure that encrypting the same data twice produces different ciphertext. This prevents attackers from identifying patterns in encrypted data.
Think of the IV like a random starting position. Even if you encrypt the same message multiple times with the same key, different IVs produce completely different encrypted output. IVs must be random and unique for each encryption operation, but they don't need to be secret - we can store them alongside the encrypted data.
Authentication Tag
AES-GCM produces an authentication tag during encryption. This tag proves the encrypted data hasn't been modified. When decrypting, if the data has been tampered with, the authentication check fails and decryption is rejected.
This is crucial because encryption alone doesn't protect against tampering. An attacker might not be able to read encrypted data, but they could potentially modify it in predictable ways. Authentication prevents this.
Prerequisites and Browser Support
**Required Knowledge:** - JavaScript ES6+ (async/await, Promises, ArrayBuffers) - Basic understanding of cryptography concepts (helpful but not required) - Familiarity with browser developer tools
Browser Compatibility:
The Web Crypto API is supported in all modern browsers: - Chrome/Edge 37+ - Firefox 34+ - Safari 11+ - Opera 24+
For production applications, always check current compatibility at caniuse.com/cryptography.
Checking Support:
```javascript if (window.crypto && window.crypto.subtle) { console.log('Web Crypto API is supported!'); } else { console.error('Web Crypto API is not supported in this browser.'); } ```
Step-by-Step Implementation
Let's build a complete, production-ready AES-256-GCM encryption system.
### Step 1: Generate a Cryptographic Salt
A salt is random data combined with a password before key derivation. Salts ensure that even if two users have the same password, they'll have different encryption keys.
```javascript /** * Generate a cryptographically secure random salt * @returns {Uint8Array} 16 bytes of random data */ function generateSalt() { return window.crypto.getRandomValues(new Uint8Array(16)); } ```
**Why 16 bytes?** This provides 128 bits of randomness, which is sufficient for PBKDF2 salts. Larger salts don't improve security significantly.
**Security Note:** Never reuse salts. Generate a fresh salt for each key derivation operation.
### Step 2: Derive an Encryption Key from a Password
This is where we transform a user-friendly password into a cryptographic key suitable for AES-256.
```javascript /** * Derive an AES-256 key from a password using PBKDF2 * @param {string} password - User's password * @param {Uint8Array} salt - Cryptographic salt * @returns {Promise<CryptoKey>} AES-256-GCM key */ async function deriveKey(password, salt) { // Convert password string to ArrayBuffer const passwordBuffer = new TextEncoder().encode(password);
// Import password as key material const keyMaterial = await window.crypto.subtle.importKey( 'raw', passwordBuffer, 'PBKDF2', false, ['deriveBits', 'deriveKey'] );
// Derive the actual encryption key return await window.crypto.subtle.deriveKey( { name: 'PBKDF2', salt: salt, iterations: 100000, // 100,000 iterations hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 // 256-bit key }, false, // Key is not extractable ['encrypt', 'decrypt'] ); } ```
Breaking Down the Code:
1. **TextEncoder:** Converts the password string into a Uint8Array that cryptographic functions can work with 2. **importKey:** Prepares the password for use with PBKDF2 3. **deriveKey:** Performs the actual key derivation with these parameters: - **iterations: 100000** - The KDF runs 100,000 times, making brute-force attacks computationally expensive. Higher is more secure but slower. - **hash: 'SHA-256'** - The underlying hash function - **length: 256** - Produces a 256-bit key - **extractable: false** - Prevents the key from being exported, keeping it in secure browser memory
Iteration Count Considerations:
100,000 iterations represents a balance between security and performance. OWASP currently recommends at least 100,000 iterations for PBKDF2-SHA256. On modern hardware, this takes approximately 50-100ms.
For higher security requirements, consider 200,000+ iterations, but test performance on your target devices. Mobile devices take significantly longer.
### Step 3: Generate an Initialization Vector (IV)
Each encryption operation needs a unique, random IV.
```javascript /** * Generate a random initialization vector for AES-GCM * @returns {Uint8Array} 12 bytes of random data */ function generateIV() { return window.crypto.getRandomValues(new Uint8Array(12)); } ```
**Why 12 bytes?** AES-GCM specifically recommends 96-bit (12-byte) IVs for optimal performance and security. While other sizes are technically possible, 12 bytes is the standard.
**Critical Security Rule:** Never reuse an IV with the same key. Each encryption operation must use a fresh, random IV.
### Step 4: Encrypt Data
Now we can encrypt data using our derived key and random IV.
```javascript /** * Encrypt data using AES-256-GCM * @param {string} plaintext - Data to encrypt * @param {string} password - User's password * @returns {Promise<{ciphertext: string, iv: string, salt: string}>} */ async function encryptData(plaintext, password) { try { // Generate random salt and IV const salt = generateSalt(); const iv = generateIV();
// Derive encryption key from password const key = await deriveKey(password, salt);
// Convert plaintext to ArrayBuffer const plaintextBuffer = new TextEncoder().encode(plaintext);
// Perform encryption const ciphertextBuffer = await window.crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv }, key, plaintextBuffer );
// Convert binary data to base64 for storage/transmission return { ciphertext: arrayBufferToBase64(ciphertextBuffer), iv: arrayBufferToBase64(iv), salt: arrayBufferToBase64(salt) }; } catch (error) { console.error('Encryption failed:', error); throw new Error('Failed to encrypt data. Please check your input and try again.'); } } ```
What's Happening Here:
1. Generate fresh random salt and IV 2. Derive encryption key from password using the salt 3. Convert plaintext string to binary data (ArrayBuffer) 4. Perform AES-GCM encryption 5. Return the ciphertext, IV, and salt (all base64-encoded for easy storage)
**Why Return IV and Salt?** To decrypt the data later, you need the same IV and salt. These aren't secret - they can be stored alongside the ciphertext.
### Step 5: Decrypt Data
Decryption reverses the process, using the stored IV and salt.
```javascript /** * Decrypt data using AES-256-GCM * @param {string} ciphertext - Base64-encoded encrypted data * @param {string} password - User's password * @param {string} ivBase64 - Base64-encoded IV * @param {string} saltBase64 - Base64-encoded salt * @returns {Promise<string>} Decrypted plaintext */ async function decryptData(ciphertext, password, ivBase64, saltBase64) { try { // Convert base64 strings back to ArrayBuffers const ciphertextBuffer = base64ToArrayBuffer(ciphertext); const iv = base64ToArrayBuffer(ivBase64); const salt = base64ToArrayBuffer(saltBase64);
// Derive the same key using the stored salt const key = await deriveKey(password, salt);
// Perform decryption const plaintextBuffer = await window.crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv }, key, ciphertextBuffer );
// Convert ArrayBuffer back to string return new TextDecoder().decode(plaintextBuffer); } catch (error) { console.error('Decryption failed:', error); throw new Error('Failed to decrypt data. Incorrect password or corrupted data.'); } } ```
Decryption Failures:
Decryption can fail for several reasons: - Incorrect password (most common) - Data has been tampered with (authentication tag fails) - Corrupted ciphertext, IV, or salt - Wrong IV or salt used
AES-GCM's authentication ensures you can't silently decrypt corrupted or tampered data. If authentication fails, decryption throws an error.
### Step 6: Helper Functions for Base64 Conversion
We need to convert between ArrayBuffers and base64 strings for storage.
```javascript /** * Convert ArrayBuffer to base64 string * @param {ArrayBuffer} buffer * @returns {string} */ function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); }
/** * Convert base64 string to ArrayBuffer * @param {string} base64 * @returns {ArrayBuffer} */ function base64ToArrayBuffer(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } ```
**Why Base64?** ArrayBuffers are binary data, which is difficult to store in databases, send over HTTP, or work with in JavaScript strings. Base64 encoding converts binary data to a text representation.
Complete Working Example
Let's put it all together in a working example you can test right now:
```javascript // Complete encryption/decryption system class AESEncryption { // Generate cryptographically secure salt static generateSalt() { return window.crypto.getRandomValues(new Uint8Array(16)); }
// Generate initialization vector static generateIV() { return window.crypto.getRandomValues(new Uint8Array(12)); }
// Derive key from password static async deriveKey(password, salt) { const passwordBuffer = new TextEncoder().encode(password);
const keyMaterial = await window.crypto.subtle.importKey( 'raw', passwordBuffer, 'PBKDF2', false, ['deriveBits', 'deriveKey'] );
return await window.crypto.subtle.deriveKey( { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); }
// Encrypt plaintext static async encrypt(plaintext, password) { const salt = this.generateSalt(); const iv = this.generateIV(); const key = await this.deriveKey(password, salt);
const plaintextBuffer = new TextEncoder().encode(plaintext); const ciphertextBuffer = await window.crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv }, key, plaintextBuffer );
return { ciphertext: this.arrayBufferToBase64(ciphertextBuffer), iv: this.arrayBufferToBase64(iv), salt: this.arrayBufferToBase64(salt) }; }
// Decrypt ciphertext static async decrypt(ciphertext, password, ivBase64, saltBase64) { const ciphertextBuffer = this.base64ToArrayBuffer(ciphertext); const iv = this.base64ToArrayBuffer(ivBase64); const salt = this.base64ToArrayBuffer(saltBase64);
const key = await this.deriveKey(password, salt);
const plaintextBuffer = await window.crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv }, key, ciphertextBuffer );
return new TextDecoder().decode(plaintextBuffer); }
// Helper: ArrayBuffer to Base64 static arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary); }
// Helper: Base64 to ArrayBuffer static base64ToArrayBuffer(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } }
// Usage example async function demo() { const password = 'MySecurePassword123!'; const secretMessage = 'This is a highly confidential message.';
// Encrypt console.log('Encrypting...'); const encrypted = await AESEncryption.encrypt(secretMessage, password); console.log('Encrypted:', encrypted);
// Decrypt console.log('Decrypting...'); const decrypted = await AESEncryption.decrypt( encrypted.ciphertext, password, encrypted.iv, encrypted.salt ); console.log('Decrypted:', decrypted); console.log('Match:', secretMessage === decrypted); }
// Run the demo demo().catch(console.error); ```
**Try It Yourself:** Copy this code into your browser's console and run it. You'll see the encryption and decryption process in action.
**Live Demo:** Visit NovelCrypt's Secure Message tool at https://novelcrypt.com to see this exact implementation in production. Our source code is open and available for inspection.
Security Best Practices Checklist
✓ **Never hardcode encryption keys** - Always derive keys from user passwords or generate them randomly
✓ **Use cryptographically secure random number generation** - Always use `window.crypto.getRandomValues()`, never `Math.random()`
✓ **Never reuse IVs** - Generate a fresh IV for every encryption operation
✓ **Store IVs and salts alongside ciphertext** - They're not secret and are required for decryption
✓ **Use sufficient PBKDF2 iterations** - Minimum 100,000, preferably more for high-security applications. Learn more about password security best practices including how to choose strong passwords for encryption.
✓ **Handle errors gracefully** - Don't expose cryptographic error details to users (prevents oracle attacks)
✓ **Clear sensitive data from memory** - Overwrite password variables after use (though JavaScript makes this difficult)
✓ **Never store passwords** - Store only derived keys or ciphertext
✓ **Use HTTPS always** - All this encryption is worthless if transmitted over unencrypted connections
✓ **Validate input** - Check password strength, data length limits, etc.
Common Pitfalls and How to Avoid Them
Pitfall #1: Reusing IVs
```javascript // WRONG - Reusing IV const iv = generateIV(); const encrypted1 = await encrypt(data1, password, iv); // Same IV const encrypted2 = await encrypt(data2, password, iv); // Same IV - DANGEROUS!
// CORRECT - Fresh IV each time const encrypted1 = await encryptData(data1, password); // New IV const encrypted2 = await encryptData(data2, password); // New IV ```
**Why It Matters:** Reusing IVs with the same key can leak information about the plaintext and potentially allow attackers to decrypt messages.
Pitfall #2: Weak Key Derivation
```javascript // WRONG - Too few iterations iterations: 1000 // Far too weak
// CORRECT - Sufficient iterations iterations: 100000 // Minimum recommended ```
**Why It Matters:** Low iteration counts make brute-force attacks feasible. An attacker can try millions of passwords quickly if derivation is fast.
Pitfall #3: Storing Keys in localStorage
```javascript // WRONG - Persistent storage of keys localStorage.setItem('encryptionKey', key); // NEVER do this
// CORRECT - Derive keys on-demand const key = await deriveKey(password, salt); // Generate when needed // Use key // Let key be garbage collected ```
**Why It Matters:** localStorage is accessible to JavaScript, including third-party scripts. Compromised keys compromise all encrypted data.
Pitfall #4: Inadequate Error Handling
```javascript // WRONG - Exposing cryptographic errors catch (error) { alert(`Decryption failed: ${error.message}`); // Gives attackers information }
// CORRECT - Generic error messages catch (error) { console.error(error); // Log for debugging alert('Decryption failed. Please check your password.'); // Generic to users } ```
**Why It Matters:** Detailed error messages can reveal information attackers can exploit (padding oracle attacks, etc.).
Testing Your Implementation
Before deploying encryption code to production, thoroughly test it.
```javascript // Test suite async function runTests() { console.log('Running encryption tests...');
// Test 1: Basic encryption/decryption const password = 'TestPassword123!'; const message = 'Test message'; const encrypted = await AESEncryption.encrypt(message, password); const decrypted = await AESEncryption.decrypt( encrypted.ciphertext, password, encrypted.iv, encrypted.salt ); console.assert(message === decrypted, 'Basic encryption test failed');
// Test 2: Wrong password should fail try { await AESEncryption.decrypt( encrypted.ciphertext, 'WrongPassword', encrypted.iv, encrypted.salt ); console.error('Test failed: Wrong password should throw error'); } catch (e) { console.log('✓ Wrong password correctly rejected'); }
// Test 3: Different IVs produce different ciphertext const encrypted1 = await AESEncryption.encrypt(message, password); const encrypted2 = await AESEncryption.encrypt(message, password); console.assert( encrypted1.ciphertext !== encrypted2.ciphertext, 'Different IVs should produce different ciphertext' );
// Test 4: Empty string const emptyEncrypted = await AESEncryption.encrypt('', password); const emptyDecrypted = await AESEncryption.decrypt( emptyEncrypted.ciphertext, password, emptyEncrypted.iv, emptyEncrypted.salt ); console.assert(emptyDecrypted === '', 'Empty string test failed');
// Test 5: Large data const largeData = 'x'.repeat(1000000); // 1MB of data const largeEncrypted = await AESEncryption.encrypt(largeData, password); const largeDecrypted = await AESEncryption.decrypt( largeEncrypted.ciphertext, password, largeEncrypted.iv, largeEncrypted.salt ); console.assert(largeData === largeDecrypted, 'Large data test failed');
console.log('All tests passed!'); }
runTests().catch(console.error); ```
**Security Testing Considerations:** - Test with various password strengths - Test with special characters, Unicode, etc. - Test data size limits - Verify that tampering with ciphertext causes decryption to fail - Performance test key derivation on target devices
Production Considerations
Browser Compatibility:
Always check for Web Crypto API support:
```javascript if (!window.crypto || !window.crypto.subtle) { // Fallback or error message alert('Your browser doesn't support encryption. Please update your browser.'); return; } ```
Performance Optimization:
Key derivation is computationally expensive. Consider: - Caching derived keys in memory (securely) during a user session - Using Web Workers for encryption operations to avoid blocking the UI - Providing visual feedback during long operations
```javascript // Example with progress feedback async function encryptWithProgress(data, password, progressCallback) { progressCallback(0.2); // Starting key derivation const key = await deriveKey(password, salt);
progressCallback(0.8); // Starting encryption const encrypted = await encrypt(data, key);
progressCallback(1.0); // Complete return encrypted; } ```
Key Management:
The hardest part of encryption isn't the algorithm - it's managing keys securely. For production systems: - Never log or transmit keys - Consider using the browser's IndexedDB with encrypted storage - Implement session timeout and require re-authentication - Provide key backup/recovery mechanisms for users
Audit and Compliance:
For applications handling regulated data (HIPAA, GDPR, etc.): - Get security audits from qualified professionals - Document your encryption implementation - Maintain audit logs (what was encrypted, when, by whom) - Have incident response plans
Next Steps and Advanced Topics
You now have a solid foundation in AES-256-GCM encryption in JavaScript. Here are directions to continue learning:
**Advanced Tutorials:** - Implementing RSA public-key encryption for key exchange - Building a complete zero-knowledge authentication system - File encryption with chunked processing for large files - Implementing secure key backup and recovery mechanisms
Related NovelCrypt Tools:
Try our production-ready encryption tools that use these exact techniques:
- **Secure Message:** Send encrypted messages that self-destruct (src/components/CreateMessage.tsx:45) - **File Encryptor:** Encrypt files in your browser before uploading anywhere (src/components/FileEncryptor.tsx:78) - **Password Vault:** Store passwords with zero-knowledge encryption (src/components/PasswordVault.tsx:120)
**Further Reading:** - Web Crypto API specification - NIST guidelines on key derivation - OWASP cryptographic storage cheat sheet - Our comprehensive guide on private, secure digital communication - Understanding why privacy matters in modern communications
Join the Community:
Have questions? Found a bug in our code? Want to contribute? - Submit issues or pull requests on our GitHub - Join our developer community discussions - Share your implementations and learn from others
Conclusion
You've learned how to implement production-grade AES-256-GCM encryption in JavaScript. This is the same encryption that protects classified government communications, financial transactions, and the most sensitive data in the world.
The Web Crypto API makes strong encryption accessible to web developers, but with great power comes great responsibility. Always prioritize security, follow best practices, and when in doubt, consult with security professionals.
Most importantly: test thoroughly, audit your code, and never assume your implementation is secure without verification.
Start building secure applications today. The tools are in your hands.