Building a pure-frontend encryption toolset with no backend dependency.
Web Crypto API is powerful — but has some gotchas.
Why Pure Frontend Encryption?
- Privacy — Sensitive user data should never reach a server
- Trust — Open-source frontend code is auditable; black-box backends aren't
- Cost — Zero server cost
What Web Crypto API Supports
✅ SHA-1 / SHA-256 / SHA-384 / SHA-512 (digest)
✅ RSA-OAEP / RSA-PSS (asymmetric)
✅ AES-GCM / AES-CBC / AES-KW (symmetric)
✅ HMAC (signatures)
✅ ECDSA / Ed25519 (elliptic curve)
❌ MD5 (not in spec)
❌ Blowfish / TWOFISH (not in spec)
SHA Hash Family
Simplest usage:
async function sha256(message) {
const encoder = new TextEncoder()
const data = encoder.encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}
sha256('hello')
// → "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
All five hashes in one call:
async function hashAll(text) {
const encoder = new TextEncoder()
const data = encoder.encode(text)
const algorithms = ['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512']
const results = {}
for (const algo of algorithms) {
const hashBuffer = await crypto.subtle.digest(algo, data)
results[algo] = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
return results
}
MD5: Not in Web Crypto, Implement It Yourself
MD5 is a weak hash, superseded by SHA-2, but still heavily used in practice.
Web Crypto doesn't support it. Implement it yourself:
function md5(string) {
function md5cycle(x, k) {
var a = x[0], b = x[1], c = x[2], d = x[3]
a = ff(a, b, c, d, k[0], 7, -680876936)
d = ff(d, a, b, c, k[1], 12, -389564586)
// ... (full MD5 four-round computation)
x[0] = add32(a, x[0])
x[1] = add32(b, x[1])
x[2] = add32(c, x[2])
x[3] = add32(d, x[3])
}
// ... (padding, processing, hex output)
// ~100 lines total
}
The complete MD5 implementation is about 100 lines — pure bitwise operations, no dependencies.
Performance: For short text (<10KB), pure JS MD5 takes ~5-10ms. More than acceptable.
AES-GCM Encryption
// Generate random key
async function generateAesKey() {
return crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // exportable
['encrypt', 'decrypt']
)
}
// Encrypt
async function aesEncrypt(plaintext, key) {
const encoder = new TextEncoder()
const iv = crypto.getRandomValues(new Uint8Array(12)) // 96-bit IV
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
encoder.encode(plaintext)
)
// Return IV + ciphertext (IV must be transmitted together)
const result = new Uint8Array(iv.length + ciphertext.byteLength)
result.set(iv)
result.set(new Uint8Array(ciphertext), iv.length)
return btoa(String.fromCharCode(...result)) // Base64 output
}
// Decrypt
async function aesDecrypt(encodedText, key) {
const data = Uint8Array.from(atob(encodedText), c => c.charCodeAt(0))
const iv = data.slice(0, 12)
const ciphertext = data.slice(12)
const plaintextBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
ciphertext
)
return new TextDecoder().decode(plaintextBuffer)
}
Security Best Practices
- Random IV every time — Same key encrypting different messages must use different IVs
- IV doesn't need to be secret — Transmit it alongside ciphertext
- GCM mode includes authentication — Tampered ciphertext fails decryption with an error
- Don't persist keys — Key disappears on page refresh
HMAC Signatures
async function hmacSha256(message, keyString) {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(keyString),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
)
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message))
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
async function verifyHmac(message, keyString, expectedSignature) {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(keyString),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
const signatureBytes = Uint8Array.from(
expectedSignature.match(/.{1,2}/g).map(b => parseInt(b, 16))
)
return crypto.subtle.verify('HMAC', key, signatureBytes, encoder.encode(message))
}
Performance Comparison
Size Algorithm Time
────────────────────────────────
1 KB SHA-256 < 1ms
1 KB MD5 (JS) ~2ms
1 KB AES-GCM encrypt ~1ms
100 KB SHA-256 ~2ms
100 KB AES-GCM ~3ms
1 MB SHA-256 ~15ms
1 MB AES-GCM ~20ms
For a tool site processing KB-level data, Web Crypto performance is more than sufficient.
MD5 Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Pure JS (~100 lines) | No dependencies, controllable | Self-maintained |
blueimp-md5 library |
Mature and stable | Extra dependency |
The project chose pure JS implementation.
Best Practices Summary
- Prefer Web Crypto API — Native, secure, fast
- Pure JS for MD5 — Small code, doesn't affect architecture
- Don't store keys — Gone on refresh
- Generate random IV — Every encryption gets a new IV
- Use GCM over CBC — GCM has built-in authentication
- HMAC for integrity — More reliable than hash alone
Top comments (0)