npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sealed-fields

v0.2.0

Published

Field-level encryption for PII at rest: AES-256-GCM with versioned key rotation, key namespaces, HMAC blind indexes for equality lookups on encrypted columns, and row helpers with systemic-failure detection. Zero dependencies, bring your own database.

Readme

sealed-fields

Field-level encryption for PII at rest, extracted from three production applications. AES-256-GCM with versioned key rotation, key namespaces, blind indexes for equality lookups on encrypted columns, and row helpers that detect systemic key failures. Zero dependencies. Bring your own database.

npm install sealed-fields

What problem this solves

Your database holds columns that are radioactive in a leaked backup: free-text notes about people, emails, phone numbers, OAuth refresh tokens, TOTP secrets. Full-disk encryption does not help when the attacker has valid database credentials or a copy of the dump. Application-layer field encryption does: the column stores ciphertext, and only the application holding the key can read it.

This library is the shared core of that pattern, extracted after building it three separate times in production. It does not defend against a fully compromised application server, because the app must hold the key to function. That is a different threat model with different tools.

Quick start

import { createSealedFields, generateKey } from "sealed-fields";

// One-time setup: node -e "console.log(require('sealed-fields').generateKey())"
const sf = createSealedFields({
  namespaces: {
    PII: { key: process.env.SEALED_KEY_PII },
  },
  blindIndexKey: process.env.SEALED_BLIND_INDEX_KEY,
});

const token = sf.seal("free-text note about a real person");
// "v1:bxLki3..." goes in the database

sf.unseal(token); // the plaintext
sf.unseal("legacy plaintext row"); // passes through unchanged (see Migration)

Wire format: v1:<base64url(nonce | ciphertext | tag)>. The key version label is bound as additional authenticated data, so a token cannot be re-attributed to a different key version. Tampering with the prefix fails authentication.

Key rotation without a flag day

Every token names the key version that wrote it. Rotation is three steps and no downtime:

const sf = createSealedFields({
  namespaces: {
    PII: {
      key: process.env.SEALED_KEY_PII, // the NEW key
      version: "v2",
      retired: { v1: process.env.SEALED_KEY_PII_OLD }, // decrypt-only
    },
  },
});

New writes seal under v2. Old rows keep reading under v1. Re-encrypt at your leisure with a backfill script, then drop the retired key. If a token names a version you no longer hold, unseal throws instead of returning ciphertext. The onUnknownVersion: "passthrough" option exists for the migration window only.

Key namespaces limit rotation blast radius

Rotating your PII key after an incident should not also invalidate every stored TOTP secret, and vice versa. Namespaces are separate keyrings under one instance:

const sf = createSealedFields({
  namespaces: {
    PII: { key: process.env.SEALED_KEY_PII },
    TOTP: { key: process.env.SEALED_KEY_TOTP },
  },
});

sf.seal(totpSecret, "TOTP");

A value sealed in one namespace fails authentication in another. That is enforced by the key, not by convention.

Blind indexes: equality lookups on encrypted columns

Encrypted columns cannot be queried. A blind index is a deterministic HMAC-SHA256 of the normalized value, stored in a sibling column, so WHERE email_idx = ? works while the email itself stays sealed:

await db.insert(users).values({
  email: sf.seal(email),
  emailIdx: sf.blindIndex(email), // trim + lowercase, then HMAC
});

const row = await db.query.users.findFirst({
  where: eq(users.emailIdx, sf.blindIndex(inputEmail)),
});

Only index high-entropy values: emails, usernames, phone numbers. Never index a low-entropy field like state, zip code, or birth year. A deterministic MAC over a small domain is trivially dictionary-attacked.

Row helpers

Declare which fields on a table are sealed, once, and use the row helpers at the write and read boundaries:

const NOTE_FIELDS = { fields: ["content", "authorEmail"], jsonFields: ["snapshot"] };

await db.insert(notes).values(sf.sealRow(values, NOTE_FIELDS));
const rows = sf.unsealRows(await db.select().from(notes), NOTE_FIELDS);

The helpers carry three production lessons:

  1. A failed unseal nulls the field instead of handing raw ciphertext to your UI.
  2. If more than half of attempted unseals across a batch fail (minimum 5), unsealRows throws SystemicUnsealError. That pattern means the wrong key is configured, and a page of silent nulls is worse than an error page. Plaintext rows mid-migration do not dilute the rate; only genuinely sealed values count as attempts.
  3. jsonFields are stringified before sealing and parsed after unsealing, unconditionally in both directions. A conditional version of this (stringify only objects, parse everything) shipped a real bug: every string-valued row came back null. Seal and unseal must be true inverses.

Migration from plaintext, and from other wire formats

unseal passes non-token strings through unchanged, so a column can be read transparently while it still holds plaintext rows. Swap the write path first, then backfill whenever convenient.

If you already have encrypted data in one of these common shapes, enable its decoder and adopt without a stop-the-world re-encrypt:

const sf = createSealedFields({
  namespaces: { PII: { key } },
  legacy: ["iv-ct-tag"],      // <b64 iv>:<b64 ciphertext>:<b64 tag>
  // legacy: ["v1-iv-tag-ct"] // v1:<b64 iv>:<b64 tag>:<b64 ciphertext>
});

Reads understand the old tokens. Every write produces the canonical format. A legacy-shaped token that no key can authenticate throws, because that is an integrity problem, not plaintext.

Fail closed by default

With no key configured, seal throws. If you need the deploy window where code ships before keys are provisioned, opt in explicitly:

createSealedFields({ namespaces: { PII: {} }, mode: "dormant" });

Dormant mode writes plaintext and logs one loud warning per process. It exists because a silent fail-open version of this exact behavior is how PII ends up unencrypted in production for months. The warning is the point.

Environment convention

import { sealedFieldsFromEnv } from "sealed-fields";

// Reads SEALED_KEY_PII, SEALED_KEY_PII_VERSION, SEALED_KEY_PII_RETIRED,
// SEALED_KEY_TOTP, ..., and SEALED_BLIND_INDEX_KEY.
const sf = sealedFieldsFromEnv(["PII", "TOTP"]);

Namespaces are listed explicitly. The helper never scans the environment, so a typo cannot silently create a keyless namespace.

Utilities

generateKey() returns a fresh 32-byte key as base64url. hashToken(t) is a SHA-256 hex digest for one-time tokens (magic links, verification codes): store the hash, send the plaintext, and a leaked database cannot consume pending tokens. isSealed(v) is a structural check for the canonical format.

Cross-language

The wire format is deliberately simple and language-neutral: AES-256-GCM, nonce || ciphertext || tag, urlsafe base64, key version as AAD. A Python package with the identical API lives in python/ (pip install sealed-fields). Both test suites decode the same shared vectors (test/vectors.json), so cross-language compatibility is proven in CI, not asserted in prose.

What this is not

Not envelope encryption with a KMS, not searchable encryption beyond equality, not order-preserving anything, and not a defense against an attacker who owns your application server. For range queries or full-text search over encrypted data you need different (and heavier) machinery.

License

MIT