Skip to content

Payload Codec

Simple Durable Jobs can transform payload bytes before they are written to the database. This is useful for encrypting job arguments, job results, checkpoint results, signal payloads, and handler error text (last_error, the dead_letter_reason suffix, and a checkpoint’s error / error_cause) at rest without changing handler code.

Error text is encrypted selectively: the fixed dead_letter_reason label (for example "max retries exhausted: ") is non-PII and stays plaintext, so the SQL that distinguishes exhausted-retry from non-retryable failures keeps working; only the error suffix is encrypted. Under the default IdentityCodec the error text is stored verbatim, with no transformation.

Interface

Payload codecs operate on serialized bytes:

type PayloadCodec interface {
	Encode(plaintext []byte) (stored []byte, err error)
	Decode(stored []byte) (plaintext []byte, err error)
}

JSON marshaling and unmarshaling still happen in the queue, call, and signal layers. The codec only transforms the resulting []byte at the GORM storage boundary.

Configure GORM storage

Pass a codec when creating storage:

store := jobs.NewGormStorage(db, jobs.WithCodec(myCodec))

If no codec is configured, storage uses jobs.IdentityCodec, which returns bytes unchanged. Nil or empty payloads are not encoded or decoded.

What the codec does not cover

The codec transforms payload and error bytes only. Columns you populate through the API — notably unique_key, tenant, queue, and the metadata JSON — are stored as you supply them, in cleartext, under every codec including Secretbox.

For unique_key this is structural rather than an oversight: deduplication works by an equality lookup against a unique index, so the value has to be stored in a form the database can compare. Encrypting it with a nonce would make two identical keys store as different ciphertexts and dedup would stop working entirely.

Choose these values accordingly — derive unique_key from a hash or an opaque ID rather than embedding an email address, account number, or other PII directly.

Secretbox encryption

The built-in codec uses NaCl Secretbox:

var key [32]byte
copy(key[:], []byte("32-byte-secret-key-material-here!!"))

codec, err := jobs.NewSecretbox(key)
if err != nil {
	return err
}

store := jobs.NewGormStorage(db, jobs.WithCodec(codec))

Each write uses a fresh random nonce. Stored ciphertext is prefixed with a stable version marker so the codec can distinguish encrypted payloads from legacy plaintext.

Zero-migration rollout

Secretbox decode is intentionally tolerant: if stored bytes do not have the Secretbox marker, they are returned unchanged as legacy plaintext. This means you can enable the codec on an existing database without rewriting old rows. New writes are encrypted; old rows continue to read normally.

No database migration is required. Existing []byte payload columns already store the encoded bytes.

Key rotation

New writes use the primary key. Older keys can be supplied as decrypt-only fallbacks:

codec, err := jobs.NewSecretbox(newKey, oldKey)

Rows encrypted with oldKey continue to decrypt. Rows written after rotation use newKey.

Operational notes

Codec-encoded payloads are opaque to database-side filtering and inspection. The dashboard and application APIs still receive decoded plaintext because they read through the storage layer, but direct SQL queries see ciphertext for encoded rows.

This applies to the two error TEXT columns (last_error and dead_letter_reason) as well. Because Secretbox output contains NUL and non-UTF8 bytes that a TEXT column cannot store directly, the encrypted segment is base64-encoded behind an sdjenc: tag — for example max retries exhausted: sdjenc:<base64-ciphertext>. The plaintext label before the tag is preserved; the suffix after it is the encoded error. Storage decodes these columns transparently on readback, so the dashboard and APIs show the original error text, but direct SQL queries see the tagged base64 form.