A cryptographic utility for sealing a JSON object using symmetric key encryption with message integrity verification.
See the codeWebCrypto-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.
node:crypto or node:buffer usage; relies on standard WebCrypto@hapi/ironChoose 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-webcrypto
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/iron
Import 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'] }
Reference: jsDocs
Background: @hapi/iron docs
defaults: Commonly used SealOptions (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.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 (default 256).iterations: PBKDF2 iterations for string passwords (default 1).minPasswordLength: Minimum string length (default 32).salt (advanced): Pin the derivation salt (hex) instead of generating one per seal — makes sealing deterministic.iv (advanced, encryption only): Pin the initialization vector instead of generating one per seal.Additional seal options:
ttl: Expiration in milliseconds (0 means no expiry).timestampSkewSec: Allowed clock skew when validating expiry (default 60).localtimeOffsetMsec: Adjust local clock when sealing/unsealing (default 0).encode / decode: Custom serializers (defaults to lossless JSON encode/parse).Uint8Array.{ id, secret } or { id, encryption, integrity }.{ [id]: password | secret | specific } (used by unseal to look up passwordId embedded 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.
iron-webcrypto/gcm)The /gcm subpath seals with AES-256-GCM under a key derived via HKDF-SHA256. It produces a shorter Fe26.3*... token, needs no SealOptions boilerplate, and authenticates the token's framing (password id, salt, nonce, expiration) as additional data. Tokens from the two subpaths are not interchangeable.
import { createSealer, seal, unseal } from 'iron-webcrypto/gcm'
const sealed = await seal(payload, password, { ttl: 60 * 60 * 1000 })
const unsealed = await unseal(sealed, password)
// or import the secret once and reuse it
const sealer = createSealer(password, { ttl: 60 * 60 * 1000 })
const sealed2 = await sealer.seal(payload)
const unsealed2 = await sealer.unseal(sealed2)
seal(object, password, options?) / unseal(sealed, password, options?): Same shapes as the main entry point; options accepts ttl, timestampSkewSec, localtimeOffsetMsec, encode and decode, all optional.createSealer(password, options?): Returns a { seal, unseal } pair holding the imported secret as a non-extractable CryptoKey. Per-call options override the ones given here. With a hash, tickets are sealed under the first entry and unsealed under any, so { v2: current, v1: previous } rotates secrets.Uint8Array (32+ bytes) or { id, secret }. There is no split encryption/integrity form: one key does both.openssl rand -base64 32).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
}
@hapi/ironThe 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.
iron-webcrypto v1 to v2v2 uses the global crypto implementation 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.stringify to a lossless JSON stringifier that validates that data can be round-tripped without modification. The new encoder throws an error when it encounters data that cannot be reliably serialized and deserialized, such as:
Object.prototype or null)undefined (empty) values in arrays (which become null with the standard JSON.stringify)NaN, Infinity, -Infinity)BigInt, Map, Set, Date, RegExp, etc.)Note that, undefined values in objects are ignored during serialization, and -0 is converted to 0.
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 an id to a password enables password rotation and improves the security of your deployment. Passwords should be rotated periodically to reduce the risk of compromise. When a password ID is provided, the ID is included in the iron protocol string and must match the ID used during unsealing.
It is recommended to combine the password ID with the ttl option to generate iron protocol strings with limited validity. This approach allows passwords to be rotated without needing to retain all previous passwords -- only those used within the TTL window must be kept.
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:
deno task formatdeno task lintdeno task testdeno 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/iron@7.0.1/LICENSE.md
TypeScript
100.0%
A cryptographic utility for sealing a JSON object using symmetric key encryption with message integrity verification.
See the codeWebCrypto-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.
node:crypto or node:buffer usage; relies on standard WebCrypto@hapi/ironChoose 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-webcrypto
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/iron
Import 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'] }
Reference: jsDocs
Background: @hapi/iron docs
defaults: Commonly used SealOptions (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.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 (default 256).iterations: PBKDF2 iterations for string passwords (default 1).minPasswordLength: Minimum string length (default 32).salt (advanced): Pin the derivation salt (hex) instead of generating one per seal — makes sealing deterministic.iv (advanced, encryption only): Pin the initialization vector instead of generating one per seal.Additional seal options:
ttl: Expiration in milliseconds (0 means no expiry).timestampSkewSec: Allowed clock skew when validating expiry (default 60).localtimeOffsetMsec: Adjust local clock when sealing/unsealing (default 0).encode / decode: Custom serializers (defaults to lossless JSON encode/parse).Uint8Array.{ id, secret } or { id, encryption, integrity }.{ [id]: password | secret | specific } (used by unseal to look up passwordId embedded 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.
iron-webcrypto/gcm)The /gcm subpath seals with AES-256-GCM under a key derived via HKDF-SHA256. It produces a shorter Fe26.3*... token, needs no SealOptions boilerplate, and authenticates the token's framing (password id, salt, nonce, expiration) as additional data. Tokens from the two subpaths are not interchangeable.
import { createSealer, seal, unseal } from 'iron-webcrypto/gcm'
const sealed = await seal(payload, password, { ttl: 60 * 60 * 1000 })
const unsealed = await unseal(sealed, password)
// or import the secret once and reuse it
const sealer = createSealer(password, { ttl: 60 * 60 * 1000 })
const sealed2 = await sealer.seal(payload)
const unsealed2 = await sealer.unseal(sealed2)
seal(object, password, options?) / unseal(sealed, password, options?): Same shapes as the main entry point; options accepts ttl, timestampSkewSec, localtimeOffsetMsec, encode and decode, all optional.createSealer(password, options?): Returns a { seal, unseal } pair holding the imported secret as a non-extractable CryptoKey. Per-call options override the ones given here. With a hash, tickets are sealed under the first entry and unsealed under any, so { v2: current, v1: previous } rotates secrets.Uint8Array (32+ bytes) or { id, secret }. There is no split encryption/integrity form: one key does both.openssl rand -base64 32).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
}
@hapi/ironThe 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.
iron-webcrypto v1 to v2v2 uses the global crypto implementation 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.stringify to a lossless JSON stringifier that validates that data can be round-tripped without modification. The new encoder throws an error when it encounters data that cannot be reliably serialized and deserialized, such as:
Object.prototype or null)undefined (empty) values in arrays (which become null with the standard JSON.stringify)NaN, Infinity, -Infinity)BigInt, Map, Set, Date, RegExp, etc.)Note that, undefined values in objects are ignored during serialization, and -0 is converted to 0.
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 an id to a password enables password rotation and improves the security of your deployment. Passwords should be rotated periodically to reduce the risk of compromise. When a password ID is provided, the ID is included in the iron protocol string and must match the ID used during unsealing.
It is recommended to combine the password ID with the ttl option to generate iron protocol strings with limited validity. This approach allows passwords to be rotated without needing to retain all previous passwords -- only those used within the TTL window must be kept.
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:
deno task formatdeno task lintdeno task testdeno 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/iron@7.0.1/LICENSE.md
TypeScript
100.0%