Browser-side encryption sounds like it ought to require a library. It does not. Every current browser ships a cryptographic implementation, exposed at window.crypto.subtle, and the path from a typed password to stored ciphertext is four calls against it.
This is a description of what those calls do, written from the code that runs on this site every time someone saves a note. Where something is a deliberate compromise rather than the strongest available option, it is marked as one.
Summary
The Web Crypto API gives browsers a native, non-extractable-by-default cryptographic implementation. The useful properties come less from the algorithms than from the design: keys are opaque objects rather than byte arrays, encryption is authenticated by default in GCM mode, and nothing in the path requires a network request. What it does not give you is protection against the code delivery problem — the browser will faithfully run whatever JavaScript the server sent.
What subtle actually is
window.crypto has two distinct halves, and conflating them causes real bugs.
crypto.getRandomValues() is synchronous and fills a typed array with cryptographically secure random bytes. It is the correct source for salts and initialisation vectors, and Math.random() is not — Math.random() is a fast pseudo-random generator with no security claims whatsoever, and its output is predictable from a handful of observed values.
crypto.subtle is the asynchronous half: key import, key derivation, encryption, decryption, signing, digests. Every method returns a Promise. The name is a deliberate warning, documented in the specification as a reminder that the primitives are easy to assemble incorrectly.
One constraint catches people early: crypto.subtle is only available in a secure context. On an http:// page that is not localhost, the property is simply undefined. This is not an inconvenience to work around. Delivering encryption code over a channel an attacker can rewrite makes the encryption decorative, and the browser is declining to participate in the pretence.
The four calls
1. Import the password as a key
A password is a string. The API will not accept a string, so the first step converts it into a CryptoKey object that is only allowed to be used for derivation:
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false, // not extractable
["deriveKey"] // and usable for nothing else
);The two arguments worth pausing on are the last two. false marks the key non-extractable: there is no subsequent API call that will hand its bytes back to JavaScript. The array restricts what it may be used for, and the browser enforces that — attempting to encrypt with this key throws rather than quietly doing something insecure.
2. Derive an encryption key
A password is not an encryption key. It is low-entropy, variable-length, and drawn from a distribution an attacker can model. Key derivation stretches it into something usable and makes each guess expensive:
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt, // 16 random bytes, unique per note
iterations: 310000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);The salt must be random and unique per record. Reusing one across records means identical passwords derive identical keys, which restores exactly the precomputation attack salting exists to prevent — the mechanism is set out in what a salt is and why every record needs its own.
The iteration count is a cost parameter, and 310,000 is the figure the [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/PasswordStorageCheat_Sheet.html) gives for PBKDF2-HMAC-SHA256. It is a compromise. PBKDF2 charges an attacker processor time and nothing else, which is the dimension specialised hardware is best at making cheap; a memory-hard function would be a better choice on the merits, and PBKDF2 vs Argon2 covers why we have not moved yet and what it would take.
3. Encrypt
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(plaintext)
);AES-GCM is an authenticated mode: the returned buffer is the ciphertext with a 128-bit authentication tag appended. That tag is what makes tampering detectable instead of silent, and it matters more than the key length does — the argument is in why authenticated encryption matters more than key size.
The initialisation vector must be fresh for every single encryption under a given key. This is the sharpest edge in the entire API, and it is covered below.
4. Decrypt, and handle the failure properly
try {
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
} catch {
// Wrong password, or altered ciphertext. You cannot tell which.
}decrypt rejects when the tag does not verify. The failure is deliberately indistinguishable between a wrong password and modified data, and you should not try to distinguish them in your own error messages either — an error that says "correct password, corrupted data" is an oracle, and oracles get used.
What gets stored, and what never leaves
Four values go to the server: the ciphertext, its authentication tag, the salt, and the IV. The salt and IV are not secrets. They must be unique and unpredictable, not hidden, and storing them alongside the ciphertext is standard and correct.
Three values never leave the browser: the password, the derived key, and the plaintext.
| Value | Stored on the server | Why |
|---|---|---|
| Ciphertext + tag | Yes | It is the record |
| Salt (16 bytes) | Yes | Needed to re-derive the key; not secret |
| IV (12 bytes) | Yes | Needed to decrypt; not secret |
| Password | Never | Never transmitted in any form |
| Derived key | Never | Non-extractable; exists only as a browser object |
| Plaintext | Never | Encrypted before the request is constructed |
That is the whole of the request body on this site, and the security architecture page states the same parameters, read from the same constant the running code reads.
Four mistakes that survive code review
Reusing an IV. Under GCM, encrypting two different messages with the same key and the same nonce is catastrophic — not weakened, broken. An attacker who obtains both ciphertexts can XOR them to recover the relationship between the plaintexts, and can forge messages that pass the tag check. NIST SP 800-38D states the uniqueness requirement explicitly. Generate the IV inside the encrypt function, never store it in a variable that outlives a single call, and never derive it deterministically from anything.
Using Math.random() for a salt or IV. It looks random in a console. It is a fast PRNG with no security claims, and its internal state is recoverable from a modest number of outputs. crypto.getRandomValues() is the only correct source.
Making the key extractable because a test needed it. Setting the extractable flag to true to log the key during debugging, then shipping it, converts a browser-enforced guarantee into a comment. If a test needs to verify derivation, derive a known vector in the test rather than loosening the production path.
Comparing tags or tokens with ===. String comparison short-circuits on the first differing character, so the time it takes leaks how much of a guess was right. The GCM tag check inside decrypt is already constant-time — the risk is in the code you write around it, such as comparing a share token.
What this does not protect you from
Being precise about the limits is the point of writing this down at all.
The code delivery problem. Encryption in the browser protects data from the server that stores it. It does not protect you from the server that sends you the JavaScript. A compromised or coerced server can serve a modified script that exfiltrates the password before encrypting, and the browser will run it faithfully. This is a real, structural limitation of every browser-based encryption tool, including this one. Subresource Integrity helps with third-party scripts; nothing fully solves it for first-party code short of a reviewed extension or a native application. Our threat model names this as an attack we do not defend against.
A weak password. PBKDF2 multiplies the cost of each guess. It does not add entropy. A six-character password remains guessable at 310,000 iterations; the iteration count buys time, not security the password never had.
A compromised device. Nothing in this API defends against a keylogger or malicious extension that reads the page. The plaintext exists in the DOM while you are editing it.
Metadata. Encryption hides content. It does not hide that a record exists, its size, or when it was written. The traffic and metadata analysis section of our threat model sets out what we can still observe.
Browser support and the practical caveats
The API is available in all current versions of Chrome, Firefox, Safari and Edge, on desktop and mobile. Two practical notes from running it:
PBKDF2 at 310,000 iterations is not free. On a recent laptop it is imperceptible; on an older Android phone it is a noticeable pause, often several hundred milliseconds. Run derivation off the main thread or show a state that explains the wait, because a UI that appears frozen gets reloaded by users mid-derivation.
Everything is Promise-based, including digest(). There is no synchronous escape hatch, which is deliberate — a synchronous API would invite blocking the main thread on exactly the operations designed to be slow.
Where to go next
The Web Crypto API specification is the normative reference and is more readable than most specifications. MDN's SubtleCrypto documentation has working examples per method. For the parameter choices rather than the mechanics, the [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/PasswordStorageCheat_Sheet.html) is the one to keep current with.
If you want to see the result rather than the code, open your browser's network tab and save a note on this site. The request body is the four values in the table above, and none of them is your password.
FAQ: the Web Crypto API
Q1. Is the Web Crypto API secure enough for real use?
Yes. It is a native browser implementation of standard primitives, and it is what password managers, messaging clients and this site use. The risks in a browser-encryption system are almost never the primitives — they are IV reuse, weak parameters, and the code delivery problem described above.
Q2. Why does crypto.subtle come back undefined?
Almost always because the page is not a secure context. The API is only exposed over HTTPS and on localhost. This is deliberate: encryption code delivered over a channel an attacker can rewrite provides no guarantee, so the browser declines to offer the API at all.
Q3. Can I use Math.random() for the IV if it is not secret?
No. The IV does not need to be secret, but it does need to be unique and unpredictable. Math.random() is a fast PRNG whose internal state can be recovered from a modest number of outputs, which makes future values predictable. Use crypto.getRandomValues().
Q4. What happens if I reuse an initialisation vector with AES-GCM?
The mode breaks, rather than weakens. An attacker holding two ciphertexts produced with the same key and nonce can recover the relationship between the plaintexts and can forge messages that pass the authentication check. NIST SP 800-38D states the uniqueness requirement explicitly.
Q5. Does non-extractable really mean the key cannot be read?
It means no Web Crypto API call will return its bytes to JavaScript, and the browser enforces that. It is not a defence against a compromised browser, a malicious extension with sufficient privileges, or a modified script that captures the password before derivation ever happens.
Q6. Is browser-side encryption as good as a native application?
On the primitives, yes. On the trust model, no — a native application is installed once and can be verified once, while a web page re-delivers its code on every visit. That difference is the code delivery problem, and it is the honest limit of every browser-based encryption tool.
Where to go next
- PBKDF2 vs Argon2 — why the derivation step above uses the function it does, and what that costs.
- Why authenticated encryption matters more than key size — what the 128-bit tag in step three is actually buying.
- Client-side vs server-side encryption — who holds the key in each model, and what changes during a breach.
- Inkrypt's threat model — the attacks this design stops, and the ones it does not.