Encryption
One authenticated await encrypt(value, key) / await decrypt(cipher, key) pair, so no caller has to assemble WebCrypto by hand — key import, KDF choice, work factor, a fresh nonce per message, tag handling, base64 both ways. Plus fast hex hashes (md5/sha1/sha256/sha512) for content fingerprinting.
v2.0 is a breaking security release.
encrypt/decryptare now async,decryptthrows instead of returningnull, the pluggable cipherdriveris gone, and v1.x ciphertext is rejected by default. v1.x wrote AES-CBC with no authentication tag and a one-round-MD5 key derivation — anything it produced is malleable and should be re-encrypted. See Migration andMIGRATION.md.
Highlighted features
Authenticated encryption
AES-256-GCM with a 128-bit tag. One flipped bit anywhere and decrypt throws instead of returning altered data.
Real key derivation
PBKDF2-HMAC-SHA256 at 210,000 iterations over a fresh 16-byte salt per message. Tunable, with a 100,000 floor.
Self-describing envelope
Version, suite, work factor, salt and nonce travel with the ciphertext — and are authenticated, so none of them can be downgraded in transit.
No insecure fallback
No crypto.subtle or no CSPRNG means UnsupportedRuntimeError — never a quiet downgrade to Math.random or an unauthenticated cipher.
Install
npm install @mongez/encryptionyarn add @mongez/encryptionpnpm add @mongez/encryptioncrypto-js ships as a transitive dep — it backs the hash exports and the opt-in legacy decrypt path only. Nothing this package writes goes through it.
Runtime requirement
encrypt/decrypt need WebCrypto: Node.js 20+, or a browser in a secure context (HTTPS or localhost). Plain-HTTP origins and Node ≤ 16 have no crypto.subtle and throw UnsupportedRuntimeError. React Native/Hermes needs a polyfill exposing both subtle and getRandomValues. Jest’s default jsdom environment may need the node environment or an injected require("node:crypto").webcrypto.
The hash exports have no such requirement.
Quick peek
import { encrypt, decrypt, sha256 } from "@mongez/encryption";
const cipher = await encrypt({ userId: 42 }, "a long passphrase"); // AES-256-GCMconst value = await decrypt(cipher, "a long passphrase"); // { userId: 42 }const tag = sha256(JSON.stringify({ q: "phones" })); // stable cache keyAny JSON-encodable value round-trips — primitives, arrays, nested objects, unicode. Ciphertext is a base64 string using the standard alphabet (+, /, =), so URL-encode it before putting it in a URL.
Mental model
| Concept | Type | Mental model |
|---|---|---|
encrypt(value, key?, options?) | (any, string?, EncryptOptions?) => Promise<string> | JSON-wrap as { data: value }, derive a key with PBKDF2 over a fresh salt, seal with AES-256-GCM under a fresh nonce, return base64 header ‖ ciphertext ‖ tag. |
decrypt(cipher, key?, options?) | (string, string?, DecryptOptions?) => Promise<any> | Validate the header, re-derive from the envelope’s own salt, verify the tag, JSON-parse, return .data. Throws on any failure. |
tryDecrypt(...) | same → Promise<any | null> | decrypt, but null for a DecryptionError. Everything else still throws. |
| Envelope | base64 string | Self-describing and authenticated: version, suite, work factor, salt, nonce. |
| Legacy path | opt-in | v1.x AES-CBC ciphertext. Unauthenticated; off by default. |
| Hash function | (string) => string | Stateless, synchronous, no config, lowercase hex. |
| Module config | { key?, iterations?, legacyDecryption?, legacyDriver? } | Process-global defaults via setEncryptionConfigurations. |
Threat model — read this before reaching for it
| Property | v2 |
|---|---|
| Confidentiality | Yes — AES-256-GCM under a PBKDF2-derived 256-bit key. |
| Integrity / tamper detection | Yes — 128-bit GCM tag over ciphertext and header. |
| Nonce and salt hygiene | Yes — fresh CSPRNG values per message, never reused. |
| Work-factor downgrade on existing ciphertext | Prevented — the iteration count is authenticated as AAD. |
| CPU exhaustion via a forged header | Bounded — a declared work factor above 5,000,000 is rejected before key derivation. |
| Format confusion (v1 blob swapped for a v2 envelope) | Prevented by default — the legacy path is opt-in. |
| Weak passphrases | No. PBKDF2 raises the cost of an offline guess; it does not make a short secret safe. |
| Key management / rotation | No. No key identifier in the envelope — rotation means re-encrypting. |
| Binding ciphertext to a record or user | No. No caller-supplied AAD, so a ciphertext moved between rows still decrypts. Put the context inside the value. |
| Replay / freshness, length hiding | No. Add your own exp; pad if length leaks. |
| Secrets in a browser | No. A passphrase shipped to a page is readable by whoever controls the page. |
| Constant-time digest comparison | No. Hex strings; === is not timing-safe. |
md5 / sha1 collision resistance | Broken. Fingerprinting only. |
| FIPS / regulated compliance | No. Not a validated module. |
Reach for it when: encrypting values at rest in browser storage, opaquing query-string parameters, field-level encryption of a database column, sealing payloads that pass through untrusted intermediaries — anywhere a wrong-key or tampered value must fail rather than degrade.
Do NOT reach for it when: password storage (bcrypt/scrypt/Argon2id), session tokens (signed JWT/JWS or server-side sessions), key exchange or public-key work (libsodium, WebCrypto ECDH), streaming large files, or anywhere a compliance regime demands a validated module or a managed KMS.
Where to go next
- Configuration —
setEncryptionConfigurations, work factor, legacy flags, key cache - Encrypt / decrypt — signatures, errors, envelope format, failure modes
- Hashes —
md5/sha1/sha256/sha512 - Recipes — URL tokens, field encryption, key rotation, migrating v1 data