WebCrypto-based implementation of @hapi/iron. It seals JSON-like data using symmetric encryption, signs it for integrity, and returns a compact, URL-safe string that can later be unsealed with the same password.
Works anywhere crypto.subtle is available: Node.js v20+, Deno, Bun, Cloudflare Workers, etc.
- Stateless, tamper-evident blobs for session-like data
- Zero
node:cryptoornode:bufferusage; relies on standard WebCrypto - Compatible API with
@hapi/iron - ESM-only; typed TypeScript surface
Choose the variant that fits your toolchain:
npm add iron-webcrypto
pnpm add iron-webcrypto
yarn add iron-webcrypto
deno add npm:iron-webcrypto
bun add iron-webcryptoJSR package
npx jsr add @brc-dd/iron
pnpm add jsr:@brc-dd/iron
yarn add jsr:@brc-dd/iron
deno add jsr:@brc-dd/iron
bun x jsr add @brc-dd/ironImport it like this:
import * as Iron from '@brc-dd/iron'import * as Iron from 'iron-webcrypto'
const password = 'a_long_random_secret_please_change_me'
const payload = { userId: 123, scope: ['user'] }
const sealed = await Iron.seal(payload, password, Iron.defaults)
// => 'Fe26.2**...'
// later or elsewhere
const unsealed = await Iron.unseal(sealed, password, Iron.defaults)
// => { userId: 123, scope: ['user'] }- Check out unjs/h3, vvo/iron-session, and other examples to see this module in use.
- Store secrets in environment variables or a secrets manager; avoid hardcoding keys.
- While this module utilizes WebCrypto and technically functions in a browser environment, it is not recommended for client-side code due to the security risks inherent in exposing encryption secrets to the client.
Reference: jsDocs
Background: @hapi/iron docs
defaults: Commonly usedSealOptions(AES-256-CBC + SHA-256, 256-bit salts, no TTL).seal(object, password, options): Serializes, encrypts, and signs data into the iron token string.unseal(sealed, password, options): Verifies, decrypts, and parses a sealed string.encrypt(password, options, data)/decrypt(password, options, data): Low-level helpers for symmetric encryption.hmacWithPassword(password, options, data): Produces a URL-safe Base64 HMAC digest.generateKey(password, options): Derives aCryptoKeyand IV (for encryption) or salt (for HMAC).
SealOptions has two parts, encryption and integrity, each with:
algorithm: Encryption is'aes-256-cbc'(default) or'aes-128-ctr'; integrity is'sha256'.saltBits: Length of the randomly generated salt (default256).iterations: PBKDF2 iterations for string passwords (default1).minPasswordlength: Minimum string length (default32).
Additional seal options:
ttl: Expiration in milliseconds (0means no expiry).timestampSkewSec: Allowed clock skew when validating expiry (default60).localtimeOffsetMsec: Adjust local clock when sealing/unsealing (default0).encode/decode: Custom serializers (defaults to lossless JSON encode/parse).
- Simple: string or
Uint8Array. - With id:
{ id, secret }or{ id, encryption, integrity }. - Hash map:
{ [id]: password | secret | specific }(used byunsealto look uppasswordIdembedded in the token).
Most functions throw when inputs are missing, too short, or malformed (e.g., unknown algorithms, invalid Base64, expired token, or unserializable data). Catch and handle these to swallow errors or surface meaningful responses to callers.
Swap the default JSON serializer for MessagePack, CBOR, Protobuf, or similar to cover broader data shapes when sealing and unsealing.
import msgpack from '@msgpack/msgpack'
import { base64ToUint8Array, uint8ArrayToBase64 } from 'uint8array-extras'
import * as Iron from 'iron-webcrypto'
const options: Iron.SealOptions = {
...Iron.defaults,
encode: (obj) => uint8ArrayToBase64(msgpack.encode(obj)),
decode: (str) => msgpack.decode(base64ToUint8Array(str)),
}
const sealed = await Iron.seal(payload, password, options)
const unsealed = await Iron.unseal(sealed, password, options)Manage evolving data formats and encryption parameters by embedding version prefixes in the sealed token.
import * as Iron from 'iron-webcrypto'
const options = {
v1: Iron.defaults, // drop older versions once their TTL window closes
v2: {
...Iron.defaults,
encryption: { ...Iron.defaults.encryption, algorithm: 'aes-128-ctr', saltBits: 128, iterations: 1000 },
integrity: { ...Iron.defaults.integrity, iterations: 1000 },
},
} as const
async function seal(payload: unknown): Promise<string> {
const sealed = await Iron.seal(payload, password, options.v2) // use latest version to seal new data
return `v2.${sealed}`
}
async function unseal(sealed: string): Promise<unknown> {
if (sealed.startsWith('v2.')) {
return Iron.unseal(sealed.slice(3), password, options.v2)
}
if (sealed.startsWith('v1.')) {
return Iron.unseal(sealed.slice(3), password, options.v1)
}
throw new Error('Unknown version') // or choose a default behavior for legacy (unversioned) tokens
}The API is mostly compatible with @hapi/iron. Install the module and update your imports:
- import * as Iron from '@hapi/iron'
+ import * as Iron from 'iron-webcrypto'Note that implementation differences may result in variations in error messages due to the use of standard Web APIs instead of Node.js-specific modules.
-
v2 uses the global
cryptoimplementation by default, eliminating the need to pass WebCrypto as the first parameter:- const sealed = await Iron.seal(crypto, payload, password, Iron.defaults) + const sealed = await Iron.seal(payload, password, Iron.defaults) - const unsealed = await Iron.unseal(crypto, sealed, password, Iron.defaults) + const unsealed = await Iron.unseal(sealed, password, Iron.defaults)
-
The package is now ESM-only. Refer to this gist for migration help.
-
The default encoder has been updated from
JSON.stringifyto a lossless JSON stringifier that validates that data can be round-tripped without modification. Whileundefinedvalues inside objects are still intentionally ignored (matching the original behavior), the new encoder throws an error when it encounters data that cannot be reliably serialized and deserialized, such as:- Circular references
- Non-plain objects (with prototypes other than
Object.prototypeornull) - Symbol keys or non-enumerable properties and methods
undefined(empty) values in arrays (which becomenullwith the standardJSON.stringify)- Non-finite numbers (including
NaN,Infinity,-Infinity) - And any other data type not representable in JSON (e.g.,
BigInt,Map,Set,Date,RegExp, etc.)
This change ensures data integrity but may require updates to your code if you were previously relying on silent truncation of unserializable data. If you need to maintain the previous behavior, you have two options:
-
Use the original JSON methods in options:
const sealed = await Iron.seal(payload, password, { ...Iron.defaults, encode: JSON.stringify })
-
Pre-process data before sealing:
const sealed = await Iron.seal(JSON.parse(JSON.stringify(payload)), password, Iron.defaults)
You are responsible for securing your keys and integrating this library safely. Quoting MDN:
The Web Crypto API provides a number of low-level cryptographic primitives. It's very easy to misuse them, and the pitfalls involved can be very subtle.
Even assuming you use the basic cryptographic functions correctly, secure key management and overall security system design are extremely hard to get right, and are generally the domain of specialist security experts.
Errors in security system design and implementation can make the security of the system completely ineffective.
The cryptographic primitives used in the Iron algorithm have weakened over time. While AES-256-CBC and HMAC-SHA256 remain secure for most use cases, periodically review your security requirements, especially for sensitive data.
PBKDF2 with a single iteration is suboptimal for password hashing but was deemed acceptable for key derivation in this context. Mitigate this risk by using strong, high-entropy passwords. openssl rand -base64 24 is a handy way to generate one locally.
Modern applications should consider stronger algorithms like AES-GCM that provide Authenticated Encryption with Associated Data (AEAD). Future releases may explore using it with appropriate key management strategies like HKDF-derived per-payload keys or envelope encryption schemes.
Assigning the password used an id allows for password rotation to improve the security of your deployment. Passwords should be rotated over time to reduce the risk of compromised security. When providing a password id, the id is included with the iron protocol string and it must match the id used to unseal.
It is recommended to combine password id with the ttl option to generate iron protocol strings of limited time validity which also allow for rotating passwords without the need to keep all previous passwords around (only the number of passwords used within the ttl window).
This library is designed to provide confidentiality and integrity for data stored in untrusted environments, such as client-side storage or third-party services. However, it does not protect against all possible threats. Consider the following when using this library:
- Key Management: Ensure that encryption keys are stored securely and are not exposed to unauthorized parties. Compromise of the key compromises all data sealed with it.
- Replay Attacks: While the library supports TTL for tokens, it does not inherently prevent replay attacks. Implement additional measures if necessary.
- Side-Channel Attacks: Be aware of potential side-channel attacks that could leak information about the sealed data or keys through timing or other observable behaviors.
- Data Sensitivity: Evaluate the sensitivity of the data being sealed and ensure that the chosen algorithms and key sizes are appropriate for the level of security required.
- Format:
deno task format - Lint:
deno task lint - Test:
deno task test - Type check:
deno task type
@hapi/iron
Copyright (c) 2012-2022, Project contributors
Copyright (c) 2012-2020, Sideway Inc
All rights reserved.
https://cdn.jsdelivr.net/npm/@hapi/[email protected]/LICENSE.md