Skip to content

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/decrypt are now async, decrypt throws instead of returning null, the pluggable cipher driver is 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 and MIGRATION.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

Terminal window
npm install @mongez/encryption

crypto-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-GCM
const value = await decrypt(cipher, "a long passphrase"); // { userId: 42 }
const tag = sha256(JSON.stringify({ q: "phones" })); // stable cache key

Any 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

ConceptTypeMental 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.
Envelopebase64 stringSelf-describing and authenticated: version, suite, work factor, salt, nonce.
Legacy pathopt-inv1.x AES-CBC ciphertext. Unauthenticated; off by default.
Hash function(string) => stringStateless, synchronous, no config, lowercase hex.
Module config{ key?, iterations?, legacyDecryption?, legacyDriver? }Process-global defaults via setEncryptionConfigurations.

Threat model — read this before reaching for it

Propertyv2
ConfidentialityYes — AES-256-GCM under a PBKDF2-derived 256-bit key.
Integrity / tamper detectionYes — 128-bit GCM tag over ciphertext and header.
Nonce and salt hygieneYes — fresh CSPRNG values per message, never reused.
Work-factor downgrade on existing ciphertextPrevented — the iteration count is authenticated as AAD.
CPU exhaustion via a forged headerBounded — 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 passphrasesNo. PBKDF2 raises the cost of an offline guess; it does not make a short secret safe.
Key management / rotationNo. No key identifier in the envelope — rotation means re-encrypting.
Binding ciphertext to a record or userNo. No caller-supplied AAD, so a ciphertext moved between rows still decrypts. Put the context inside the value.
Replay / freshness, length hidingNo. Add your own exp; pad if length leaks.
Secrets in a browserNo. A passphrase shipped to a page is readable by whoever controls the page.
Constant-time digest comparisonNo. Hex strings; === is not timing-safe.
md5 / sha1 collision resistanceBroken. Fingerprinting only.
FIPS / regulated complianceNo. 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

  • ConfigurationsetEncryptionConfigurations, work factor, legacy flags, key cache
  • Encrypt / decrypt — signatures, errors, envelope format, failure modes
  • Hashesmd5 / sha1 / sha256 / sha512
  • Recipes — URL tokens, field encryption, key rotation, migrating v1 data