Encrypted fields
Authenticated field-level encryption at rest for twelve Payload field types, with zero-config keys, rotation, and masked admin UX.
import { encryptedField } from '@10x-media/fields/encrypted'
fields: [
...encryptedField({ name: 'apiToken', type: 'text' }),
...encryptedField({ name: 'notes', type: 'textarea' }),
...encryptedField({ name: 'contactEmail', type: 'email' }, { queryable: true }),
]encryptedField(source, options?) takes the field config you would have written as its first argument, and encryption options (queryable, protection, maskDots, keys, onDecryptFailure, overrides) as its second. It returns an array (the stored field, plus a blind-index sibling when queryable), so spread it into fields.
Values are encrypted with AES-256-GCM before they reach the database and decrypted on read. The database column holds an opaque versioned string on every adapter (Mongo and Postgres behave identically); your API and admin see plaintext, your database, backups, and dumps do not. With no configuration, keys derive from PAYLOAD_SECRET; production setups should configure explicit keys.
Supported field types
text, textarea, email, number, checkbox, date, select and radio (including hasMany), code, json, point, and richText (Lexical). Values round-trip through JSON before encryption, so numbers stay numbers and dates stay dates in your API responses, and generated TypeScript types keep their real shapes via an emitted typescriptSchema.
Some types trade queryability for confidentiality (encrypted point loses geo queries, encrypted richText loses sub-field queries); read Limitations before choosing what to encrypt, and Design rationale for when not to encrypt at all.
Admin UX
Every encrypted field renders as its native Payload variant with a fixed run of dots while concealed, plus an inline eye toggle (the same pattern as Payload's password and API-key fields) that swaps in the real, editable native component on reveal. Editing the revealed component and saving re-encrypts the value. The eye sits inside the input for text, email, and number; in the top-right corner for textarea; and on the label row for checkbox, select, radio, date, code, json, point, and richText.
protection (in the options argument) chooses whether to mask at all: 'masked' (default) conceals behind dots with the reveal toggle; 'none' renders the native component directly, value visible, no mask. A 'masked' field with no stored value renders as the plain native control, since there is nothing to conceal, and starts masking only once it holds a value, so new documents and empty forms look fully native. In the list view, a 'masked' field renders a lock badge with dots and never exposes the decrypted value; a 'none' field shows its value there too, matching its unmasked editor.
maskDots sets how many dots show while concealed (default 8, clamped to 1-64). The count is cosmetic and decoupled from the real value length, which stays unknown until reveal, so a known-length secret can advertise its shape without exposing it:
encryptedField({ name: 'apiKey', type: 'text' }, { maskDots: 32 })
encryptedField({ name: 'internalNote', type: 'textarea' }, { protection: 'none' })Behavior guarantees
- Validation runs on plaintext. Your
validate(or the stock one for the type) sees the incoming plaintext value, so an encrypted email field still rejects a malformed email. Your hooks compose with the plugin's; nothing is swallowed. - Null is null. Clearing a field stores
null, not an encrypted empty value. - Tampering fails loudly. GCM authentication plus additional authenticated data binding means a ciphertext moved between fields or collections fails to decrypt instead of decrypting into the wrong place. The binding stops at the field, though: read Limitations for the same-field caveats. The
onDecryptFailurepolicy ('throw'default,'null','passthrough', or a callback) decides what a failed read does;'passthrough'exists for adopting encryption on existing plaintext.
Querying
Encrypted columns are opaque, so where clauses cannot see into them. Opt-in blind indexing restores exact-match queries (equals, not_equals, in, not_in, exists) and unique: see Querying.