Skip to content

Field Encryption ​

@db.encrypted encrypts a field at rest: the core layer AES-256-GCM-encrypts the value before it reaches the adapter and decrypts it on read — completely transparent to application code. Ciphertext is stored as a plain string, so the feature works identically on all four adapters with no engine-specific setup.

Typical use: credential blocks on operational documents — API tokens, feed auth headers, provider passwords — where the invariant "never store this in cleartext" belongs in the model, not in a hand-rolled service layer.

Declaring Encrypted Fields ​

atscript
@db.table 'partners'
export interface Partner {
    @meta.id
    id: string

    legalName: string

    @db.encrypted
    apiToken?: string

    @db.encrypted
    creditCredentials?: {
        user: string
        pwd: string
    }
}

Any JSON-serializable type works — string, number, boolean, nested object, array. The declared type keeps governing validation and TypeScript typing; only the storage representation changes. An encrypted nested object is stored as one opaque text column (no flattening into child columns).

Configuring Keys ​

Pass an encryption block in the DbSpace options bag. Without it, any model declaring @db.encrypted fails fast with ENC_CONFIG_MISSING — plaintext is never silently stored.

typescript
import { DbSpace } from "@atscript/db";

const db = new DbSpace(() => new MongoAdapter(mongo, client), {
  encryption: {
    defaultKeyId: "k2",
    keys: {
      k1: process.env.DB_ENC_KEY_1!, // old key — kept for existing rows
      k2: process.env.DB_ENC_KEY_2!, // used for all new writes
    },
  },
});
OptionDescription
defaultKeyIdKey used for all new writes
keysKey registry: keyId → 32-byte key (Buffer, 64-char hex, base64, or 32-char string)
resolveKeyAsync resolver (KMS, Vault, env indirection) — called once per keyId, cached for the process lifetime
onUnencrypted'error' (default) or 'passthrough' — what to do when a stored value is not an encryption envelope (§ below)

Key material is validated eagerly at DbSpace construction — a wrong-size key throws ENC_KEY_INVALID immediately, not at first write.

AWS KMS / Vault ​

Use resolveKey with the envelope-encryption pattern — keep a KMS-encrypted 32-byte data key in config and decrypt it once at boot:

typescript
import { KMSClient, DecryptCommand } from "@aws-sdk/client-kms";

const kms = new KMSClient({});
const db = new DbSpace(adapterFactory, {
  encryption: {
    defaultKeyId: "k1",
    resolveKey: async (keyId) => {
      const { Plaintext } = await kms.send(
        new DecryptCommand({
          CiphertextBlob: Buffer.from(process.env[`DB_ENC_KEY_${keyId}`]!, "base64"),
        }),
      );
      return Buffer.from(Plaintext!); // must be 32 bytes
    },
  },
});

Note the threat-model boundary: keys live app-side by design. The key must exist in process memory; this protects against database dumps, backups, and DB operators — not against an attacker who controls the application process. If you need a no-key-in-process guarantee on MongoDB, use driver-level CSFLE/Queryable Encryption independently (construct the MongoClient with autoEncryption and don't combine it with @db.encrypted on the same fields).

How Values Are Stored ​

Stored values are single ASCII envelope strings:

aes1$<keyId>$<iv>$<tag>$<ciphertext>
  • Fresh random IV per write — two writes of the same plaintext produce different ciphertexts.
  • The plaintext is JSON-serialized before encryption, so round-trips are type-exact ("42" and 42 stay distinguishable).
  • Column types: TEXT on SQLite/PostgreSQL/MySQL, plain string on MongoDB. Declared-type column sizing does not apply — ciphertext length depends on the plaintext.

Reads, list queries, search results, and relation loads all decrypt transparently. A tampered or corrupted value fails with ENC_DECRYPT_FAILED (carrying table/field/keyId — never partial plaintext); an envelope referencing a key missing from the registry fails the same way, naming the keyId.

What's Not Allowed ​

Ciphertext is opaque, so anything that needs to interpret the stored value is rejected loudly:

At compile/build time — @db.encrypted cannot combine with: @meta.id, @db.rel.FK (or being an FK target), any @db.index.* (incl. geo), @db.search.vector/@db.search.filter, @db.mongo.search.*, @db.column.version, @db.default.increment/@db.default.now, or @db.patch.strategy "merge". (A plain @db.default 'literal' is fine — it's applied app-side before encryption.)

At query/patch time — engine-agnostic DbError rejections before any SQL/pipeline is built:

AttemptError
Filtering on an encrypted field (incl. nested paths into an encrypted object)ENC_FIELD_FILTER
$sort on an encrypted fieldENC_FIELD_SORT
$groupBy / aggregate referencesENC_FIELD_AGG
Arithmetic/array patch ops ($inc, $insert, …)ENC_FIELD_PATCH_OP

Plain assignment in updates is allowed — the new value is simply re-encrypted. Over HTTP all of these surface as 400.

/meta reports encrypted: true on the field with filterable: false and sortable: false (the adapter veto wins over any @db.column.filterable annotation).

HTTP responses return decrypted values

Encryption is at-rest protection, not transport-level redaction. Read endpoints return plaintext to authorized callers. Who may see the field over HTTP remains an authorization concern — use projections, transformProjection(), or permission guards exactly as you would for any sensitive field.

Key Rotation ​

Rotation is a config change: flip defaultKeyId to the new key and keep old keys in the registry. Each envelope records the keyId it was encrypted with, so old rows keep decrypting; they re-encrypt under the new key on their next natural write. To rotate eagerly, run a read-and-rewrite loop over affected rows. Drop an old key only after no envelopes reference it — afterwards those rows fail with ENC_DECRYPT_FAILED.

Migrating Existing Plaintext Columns ​

Adding @db.encrypted to a live, populated column does not encrypt existing rows (schema sync never rewrites data). Until they're migrated, reads of plaintext rows fail with ENC_NOT_ENCRYPTED under the default policy. The migration recipe:

  1. Deploy with onUnencrypted: 'passthrough' — plaintext rows are returned as-is; anything written goes out encrypted.
  2. Run a read-and-rewrite loop over the table (each write re-encrypts).
  3. Switch back to onUnencrypted: 'error' (the default) so stray plaintext fails loudly again.

Schema-sync notes: toggling @db.encrypted changes the field's storage type and the schema hash → sync runs (a column-type migration on SQL, metadata-only on MongoDB). Key material is never persisted, hashed into the snapshot, or otherwise written to the database. See What Gets Synced.

DOs and DON'Ts ​

  • DO keep every key that still has live envelopes in the registry — removing a key bricks its rows.
  • DO treat HTTP exposure separately — encrypted ≠ hidden; wire projections/permissions for read access.
  • DON'T filter, sort, group, or index on encrypted fields — design lookups around other fields (or wait for the planned blind-index support).
  • DON'T put @meta.id, FKs, or version columns under encryption — addressing and OCC need cleartext equality.
  • DON'T combine with MongoDB CSFLE on the same field — you'd double-encrypt.

Next Steps ​

Released under the MIT License.