Encrypting data in the browser used to mean pulling in a heavy JavaScript library and hoping the implementation was correct. Today, every modern browser ships with the Web Crypto API — a native, performant, and audited cryptographic interface that runs entirely on the client. This guide walks through AES encryption using the Web Crypto API, with a focus on AES-256-GCM (the mode you should be using) and practical key derivation with PBKDF2.
Everything here runs locally in the browser. No data leaves the machine, no server is involved, and no third-party library is required.
AES Modes: Why GCM Over CBC
AES (Advanced Encryption Standard) is a symmetric block cipher — the same key encrypts and decrypts. But AES alone only encrypts fixed 128-bit blocks. The "mode of operation" defines how those blocks chain together to handle arbitrary-length data. The two modes you'll encounter most:
AES-CBC (Cipher Block Chaining)
- Each block is XORed with the previous ciphertext block before encryption.
- Requires an Initialization Vector (IV) for the first block.
- Provides confidentiality but NOT integrity. An attacker can modify ciphertext without detection (padding oracle attacks, bit-flipping attacks).
- To get integrity, you must add a separate HMAC (Encrypt-then-MAC), which is error-prone to implement correctly.
AES-GCM (Galois/Counter Mode)
- Combines encryption and authentication in a single operation.
- Produces ciphertext + an authentication tag (typically 128 bits).
- Provides both confidentiality AND integrity. Any modification to the ciphertext or associated data causes decryption to fail.
- No padding needed (works as a stream cipher internally).
- Faster in hardware (modern CPUs have AES-NI + CLMUL instructions that accelerate GCM specifically).
The recommendation is clear: use AES-256-GCM unless you have a specific legacy requirement for CBC. GCM gives you authenticated encryption out of the box, eliminating an entire class of implementation bugs.
AES-256-GCM at a glance:
- Key size: 256 bits (32 bytes)
- IV/Nonce: 96 bits (12 bytes) — must be unique per encryption, never reused with same key
- Auth tag: 128 bits (16 bytes) — appended to ciphertext automatically
- Output: ciphertext (same length as plaintext) + 16-byte tag
Complete AES-256-GCM Example with Web Crypto API
Here's a full, working implementation of encrypt and decrypt using AES-256-GCM with PBKDF2 key derivation from a password:
Key Derivation with PBKDF2
You rarely have a raw 256-bit key lying around. Usually, you start with a password or passphrase. PBKDF2 (Password-Based Key Derivation Function 2) stretches a password into a cryptographic key by applying many iterations of HMAC-SHA-256:
async function deriveKey(password, salt) {
// Import the password as raw key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey']
);
// Derive an AES-256-GCM key using PBKDF2
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 600000, // OWASP 2023 recommendation for PBKDF2-SHA256
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
Key points:
- Salt: Must be random and unique per encryption. Store it alongside the ciphertext (it's not secret).
- Iterations: 600,000 is the current OWASP recommendation for PBKDF2-HMAC-SHA256. More iterations = slower brute-force attacks.
- The derived key is non-extractable (
false), meaning JavaScript can't read the raw key bytes back — a defense against XSS-based key theft.
Encryption
async function encrypt(plaintext, password) {
// Generate random salt (16 bytes) and IV (12 bytes)
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
// Derive key from password
const key = await deriveKey(password, salt);
// Encrypt
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
new TextEncoder().encode(plaintext)
);
// Pack: salt (16) + iv (12) + ciphertext
// Salt and IV are needed for decryption but are NOT secret
const packed = new Uint8Array(salt.length + iv.length + ciphertext.byteLength);
packed.set(salt, 0);
packed.set(iv, salt.length);
packed.set(new Uint8Array(ciphertext), salt.length + iv.length);
// Return as Base64 for easy storage/transmission
return btoa(String.fromCharCode(...packed));
}
Decryption
async function decrypt(packed64, password) {
// Unpack from Base64
const packed = Uint8Array.from(atob(packed64), c => c.charCodeAt(0));
// Extract salt, IV, and ciphertext
const salt = packed.slice(0, 16);
const iv = packed.slice(16, 28);
const ciphertext = packed.slice(28);
// Derive the same key from password + salt
const key = await deriveKey(password, salt);
// Decrypt (GCM verifies the auth tag automatically)
const plainBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
ciphertext
);
return new TextDecoder().decode(plainBuffer);
}
Usage
const encrypted = await encrypt('API secret: sk-abc123...', 'my-strong-passphrase');
console.log(encrypted);
// "dGhpcyBpcyBhIGJhc2U2NCBlbmNvZGVkIHN0cmluZy4uLg=="
const decrypted = await decrypt(encrypted, 'my-strong-passphrase');
console.log(decrypted);
// "API secret: sk-abc123..."
// Wrong password throws an error (GCM auth tag verification fails)
try {
await decrypt(encrypted, 'wrong-password');
} catch (e) {
console.error('Decryption failed: wrong password or tampered data');
}
Critical Implementation Details
Never Reuse an IV with the Same Key
GCM's security completely breaks if you encrypt two messages with the same (key, IV) pair. An attacker who sees two ciphertexts encrypted with the same IV can XOR them to recover information about both plaintexts and forge valid ciphertexts.
// ALWAYS generate a fresh IV per encryption
const iv = crypto.getRandomValues(new Uint8Array(12));
// Never: const iv = new Uint8Array(12); // all zeros = catastrophic reuse
Use crypto.getRandomValues, Not Math.random
Math.random() is not cryptographically secure — its output is predictable. Always use crypto.getRandomValues() for salts, IVs, and any random values in a cryptographic context.
Associated Data (AAD)
GCM supports optional Additional Authenticated Data — data that is authenticated (integrity-protected) but not encrypted. Useful for binding ciphertext to context:
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: new TextEncoder().encode('user-id:12345') // AAD
},
key,
plaintext
);
// Decryption must provide the same AAD or it fails
Performance
Web Crypto operations are asynchronous and run in native code. For reference, on a modern laptop:
- AES-256-GCM encrypt: ~2 GB/s (hardware-accelerated via AES-NI)
- PBKDF2 with 600,000 iterations: ~200-400ms (intentionally slow — that's the point)
The key derivation is the bottleneck by design. If you're encrypting many messages with the same password, derive the key once and reuse it:
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(password, salt); // Slow: ~300ms
// Fast: encrypt many messages with the same derived key
const msg1 = await encryptWithKey(key, 'message one'); // <1ms
const msg2 = await encryptWithKey(key, 'message two'); // <1ms
When to Use Browser-Side Encryption
Browser-side AES encryption makes sense when:
- Sensitive data shouldn't leave the client. Encrypting a message before sending it means the server only ever sees ciphertext. Even if the server is compromised, the data is protected.
- Local storage encryption. Encrypting API keys, tokens, or notes before storing them in localStorage/IndexedDB protects against XSS-based data theft (the attacker gets ciphertext, not plaintext).
- End-to-end encryption. Two parties derive a shared key and encrypt messages client-side. The server relays ciphertext without ever having the key.
- Zero-knowledge architectures. The service provider literally cannot read your data because encryption/decryption happens only in your browser.
When it does NOT make sense:
- Server-side data at rest (use your cloud provider's encryption or a KMS).
- TLS/HTTPS (that's transport encryption, handled by the browser automatically).
- When the server needs to read/process the data (you can't encrypt it if the server needs plaintext access).
Want to experiment with AES encryption without writing code? The AES Encrypt/Decrypt tool on DevToolkit Pro runs AES-256-GCM entirely in your browser using the Web Crypto API. Your plaintext and password never leave your machine — the encryption happens locally, in native code, with no server round-trip. It's a quick way to encrypt a sensitive string, verify a ciphertext, or understand the output format before implementing it in your own application.
相关文章
SHA-256 vs MD5: When to Use Which Hash Algorithm (2026)
SHA-256 vs MD5 compared: security, speed, and use cases. Learn when MD5 is still acceptable and where you must use SHA-256. Practical decision guide.
Online RSA Encryption/Decryption: Asymmetric Crypto and Digital Signatures
Learn RSA asymmetric encryption algorithm principles and applications. Understand public/private key encryption, digital signatures, key pair generation, and use in HTTPS and API authentication.
Complete Guide to MD5 Hash: How It Works, Uses, and Security
Deep dive into the MD5 algorithm, its common uses, and security vulnerabilities. Learn the differences between MD5, SHA-1, and SHA-256, and when MD5 is still okay to use.