cipher.ink Write a message

Learn · Technologies we use

The Web Crypto API

What crypto.subtle offers and leaves out, why it beats a JavaScript library, a probe that tests your own browser, and our real sealing code.

The Web Crypto API is real cryptography built into every modern browser. A page used to ship its own JavaScript implementation of AES or SHA-256. Now it can call crypto.subtle, which runs native, audited code and can hold keys that the page's own JavaScript isn't allowed to read. The W3C published the first version as a Recommendation on January 26, 2017. A second version, Web Cryptography Level 2, reached First Public Working Draft on April 22, 2025 and registers further algorithms such as Ed25519 and X25519. cipher.ink uses it for every cryptographic operation on a message. Here's what it offers, what it leaves out, and how to use it without hurting yourself.

What it gives you

Pages and Web Workers get an instance of SubtleCrypto as crypto.subtle, and only in a secure context, meaning HTTPS (or localhost). It has a dozen methods, all asynchronous and all returning promises. digest() hashes. encrypt() and decrypt() do what they say, as do sign() and verify(). generateKey(), importKey() and exportKey() handle keys, deriveKey() and deriveBits() cover key agreement and key derivation, and wrapKey() and unwrapKey() move keys around. The algorithms behind them, from the standard and MDN's table:

JobAlgorithms
Hash (digest)SHA-1, SHA-256, SHA-384, SHA-512
Encrypt and decryptAES-GCM, AES-CBC, AES-CTR, RSA-OAEP
Sign and verifyHMAC, ECDSA, RSASSA-PKCS1-v1_5, RSA-PSS, Ed25519
Derive keysPBKDF2, HKDF, ECDH, X25519
Wrap keysAES-KW

The random-number half of the API is separate and simpler. crypto.getRandomValues(array) fills an array with bytes from the operating system's secure random generator, synchronously, and unlike crypto.subtle it works in insecure contexts too (random numbers in the browser).

What it leaves out

  • Modes: there's no ECB, CFB or OFB, which is why we open 2016 messages through a CBC trick, and the standard has no ChaCha20-Poly1305.
  • Memory-hard password hashing: no Argon2, scrypt or bcrypt, so PBKDF2 is the only choice without extra code (password hashing).
  • Streaming hashes: digest() takes the whole input at once. To hash a large file in chunks you need your own code, and our file checksum tool uses a small vendored hash library for that.
  • Other hashes: no SHA-3, BLAKE2, BLAKE3 or MD5 in the standard. The hash generator here computes them with vendored code.
  • Big numbers: no arithmetic on large integers, so the RSA toy and the Diffie-Hellman tool use JavaScript's BigInt, which is exact but unsuitable for real keys.

Browsers are starting to add more, and the answers depend on your browser and version. The probe below tests each algorithm on your own browser, right now, with throwaway keys:

Needs JavaScript.

Why use it instead of a JavaScript library

  • Native, maintained code. The primitives run in the browser's own crypto library, which the rest of the browser shares and the browser vendor keeps up to date. A page written in JavaScript, with its garbage collector and optimizing compiler, has a hard time matching it on timing and memory behavior.
  • Non-extractable keys. Create a key with extractable: false and your JavaScript can use it but never read its bytes. A cross-site-scripting bug running in the page can still ask the key to encrypt, but it can't copy the key away.
  • Nothing to trust or download. No library, no version to track, no supply chain. Fewer bytes arrive and fewer lines can be wrong.
  • Speed. The 600,000 PBKDF2 rounds in our key derivation take a fraction of a second natively. The same loop in JavaScript would take many times longer, a pure loss for a defender, since a slow implementation means fewer iterations.

Keys are objects, not bytes

When you generate, import or derive a key, you get a CryptoKey object, not an array of bytes. It records its type (secret, public or private), its algorithm (key length included), its usages (a list such as encrypt and decrypt, fixed when the key is made) and whether it's extractable. The browser enforces all four. A key created for encrypt can't be used to sign, and a key made with extractable: false makes exportKey() reject with an InvalidAccessError, which you can check in a console in a few seconds. Where the bytes do have to travel, importKey() and exportKey() speak four formats: raw bytes, pkcs8 and spki for private and public keys, and jwk, the JSON form.

This is what makes the API different in kind from a library. In a library the key is a variable, and any code on the page, including a script injected through a bug, can read it and send it away. A non-extractable CryptoKey is a handle. Injected code could still ask the browser to decrypt with it, but it can't walk off with the key, which narrows what an attack on the page can take. That doesn't make the page safe, and it's one reason the site also forbids injected script in the first place (Content-Security-Policy).

Using it without hurting yourself

The object is called subtle for a reason. It gives you primitives, not a protocol, and several mistakes are easy to make:

  • The IV or nonce is your job. For AES-GCM, generate a fresh random one for every encryption and store it beside the ciphertext. Reusing one with the same key is the one fatal mistake (AES-GCM, explained).
  • CBC and CTR don't authenticate. Pick GCM unless you have a reason not to (authenticated encryption).
  • Errors say nothing on purpose. A failed decryption rejects with an OperationError and no explanation. That's deliberate, since explaining why would create an oracle. Handle every failure the same way.
  • Everything is a promise. Await it, and remember that nothing gets canceled if you navigate away.
  • Normalize and encode your inputs. A passphrase has to become bytes the same way on every device (Unicode normalization).

A real example: sealing a message

This is the core of cipher.ink's own crypto.js, simplified a little: derive a 256-bit key from a passphrase with PBKDF2, then encrypt with AES-GCM and a fresh IV. It runs in the console of any HTTPS page.

Passphrase to key to sealed message, with the browser's own crypto.

var te = new TextEncoder();
async function seal(passphrase, text) {
  var salt = crypto.getRandomValues(new Uint8Array(16));
  var iv = crypto.getRandomValues(new Uint8Array(12));
  var base = await crypto.subtle.importKey('raw', te.encode(passphrase.normalize('NFC')), 'PBKDF2', false, ['deriveKey']);
  var key = await crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt: salt, iterations: 600000 },
    base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);   // false: the key can never be read back out
  var ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, te.encode(text));   // ciphertext + 16-byte tag
  return { salt: salt, iv: iv, ct: new Uint8Array(ct) };
}
seal('correct horse battery staple', 'hello').then(console.log);

The real thing is longer only because it also records the format version, the iteration count and the salt in the payload, seals attachments with the same key, and has a second path for secret links, which skip the derivation because the key is already 256 random bits. You can read all of it at the code page.

How cipher.ink uses this

All the cryptography on a message goes through crypto.subtle: PBKDF2-HMAC-SHA256 for the key, AES-256-GCM for the sealing, SHA-256 for the receipt token, and getRandomValues for salts, IVs, secret-link keys and tokens. The keys are created non-extractable. The product has no cryptography library. The only third-party code is the HTML sanitizer and a QR-code generator (rendering HTML you do not trust), and the page makes no request to any other site (Content-Security-Policy). The exceptions are on the tools side, not the product: the hash tools and the file checksum use a vendored hash library because the standard has no BLAKE3, no SHA-3 and no streaming digest.

Further reading

  1. W3C: Web Cryptography API, Recommendation (26 January 2017)
  2. W3C: Web Cryptography Level 2, First Public Working Draft (22 April 2025)
  3. MDN: SubtleCrypto
  4. MDN: Crypto.getRandomValues()