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.6.1

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.

Rotate well before one key has sealed about 4 billion (2^32) values. Each seal uses a random 96-bit nonce, and NIST SP 800-38D caps random-nonce GCM at 2^32 encryptions per key, because past that point a repeated nonce becomes likely enough to matter. A repeated nonce under one key leaks the XOR of the two plaintexts and lets an attacker forge tokens. Most applications never get close, but a hot column with frequent rewrites can.

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.

Bind tokens to their column

A token only authenticates its key version. Someone who can write to the database without the key can still copy a sealed value from one column or row to another, and it decrypts there. Pass a context to bind the token to where it lives:

const token = sf.seal(email, { namespace: "PII", context: ["users", "email"] });
sf.unseal(token, { namespace: "PII", context: ["users", "email"] });

The context goes into the GCM additional authenticated data, so the token format is unchanged. Unsealing under any other context, or with none, fails authentication like a tampered token. Parts are joined with a NUL byte, so ["a.b", "c"] and ["a", "b.c"] never collide.

Bind table and column by default. Adding the row id also stops copying between rows, but only works when the id exists before the write. With database-generated ids (SERIAL, auto-increment) that means insert-then-update, which is easy to get wrong. Use it on tables with client-generated ids such as UUIDs, and reseal the value if the id ever changes.

Row helpers take a context function: { fields: ["email"], context: (field) => ["users", field] }. Use the same spec for sealRow and unsealRows. It receives (field, row) when you do bind row ids.

Migrating existing rows. Rows sealed before you added a context won't authenticate under it. Set contextFallback: true during the migration. A read that fails under the context is retried without it, logged once per namespace, and counted in sf.stats().unboundReads. Run a backfill that reads and reseals every row, confirm unboundReads stays at zero on every instance over a full read cycle, then remove contextFallback. Fallback only drops the context, so a token that was already bound still cannot move. Legacy-format tokens have no AAD and so cannot be bound. With a context they read only under contextFallback, and count as unbound.

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.

normalize can also be a function ({ normalize: (v) => v.trim().toUpperCase() }) when existing index tokens were built under a different rule. Every writer and reader of a column must use the same normalization or lookups silently miss. The default trims exactly what JS String.prototype.trim() trims, in both languages. It does not apply Unicode normalization. If input can arrive decomposed (NFD, common from macOS and iOS), use (v) => v.normalize("NFC").trim().toLowerCase().

Pass context to keep columns apart: sf.blindIndex(email, { context: "users.email" }). Without it, the same email produces the same token in every table, so anyone reading the database can join users to contacts on the index column without any key. Adding a context changes the token, so use it on new columns, or backfill the index when you add it to an existing one.

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 four 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.
  4. sealRow seals every listed string, even one that already looks like a token, so call it exactly once per write. Before 0.4.0 it skipped token-shaped values, which let a user store chosen "ciphertext" unencrypted and, with five such values on a page, trip SystemicUnsealError for everyone.

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.

Audit plaintext before turning on a key or a legacy decoder. Plaintext written in dormant mode before 0.6.1, written before 0.4.0, or still sitting in a mixed table may contain user input shaped like a token. Once a key or decoder is on, those values fail authentication. The row helpers null them, and enough of them on one page trip SystemicUnsealError. Before enabling, scan the column with isSealed (plus your legacy shape). Re-seal or clear any row that matches a token shape but doesn't decrypt under your keys.

Close the door once the backfill is done. By default unseal passes plaintext through, which also lets a value written around the library, by a raw SQL fix or a second service, sit unencrypted forever without anyone noticing. After every row is sealed, set plaintext: "reject". Then unseal throws on a non-token value, and the row helpers null it and count it toward SystemicUnsealError. It cannot be combined with mode: "dormant", because dormant mode writes plaintext. Since 0.6.1, dormant mode refuses to write a value shaped like a token (canonical or an enabled legacy format) and throws SealedFieldsError instead.

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. Keys are accepted as 64-char hex or 43-char base64/base64url (a trailing = is fine, so openssl rand -base64 32 works). Keys must be random. A 43-character passphrase can pass the shape check, but it is not a key. 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. The Python package also ships SQLAlchemy column types (pip install "sealed-fields[sqlalchemy]": SealedText, SealedString, SealedJSON), documented in python/README.md.

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