DEV Community

Mark
Mark

Posted on

Client-Side Encryption with Web Crypto API

Building a pure-frontend encryption toolset with no backend dependency.
Web Crypto API is powerful — but has some gotchas.

Why Pure Frontend Encryption?

  1. Privacy — Sensitive user data should never reach a server
  2. Trust — Open-source frontend code is auditable; black-box backends aren't
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

  1. Random IV every time — Same key encrypting different messages must use different IVs
  2. IV doesn't need to be secret — Transmit it alongside ciphertext
  3. GCM mode includes authentication — Tampered ciphertext fails decryption with an error
  4. 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))
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Prefer Web Crypto API — Native, secure, fast
  2. Pure JS for MD5 — Small code, doesn't affect architecture
  3. Don't store keys — Gone on refresh
  4. Generate random IV — Every encryption gets a new IV
  5. Use GCM over CBC — GCM has built-in authentication
  6. HMAC for integrity — More reliable than hash alone

Project

Hash GeneratorAES Encrypt/DecryptHMAC Generator

Top comments (0)