JoobQ::Encryption
Transparent, opt-in, field-level encryption of job payloads.
JoobQ stores a job as JSON in Redis: in the queue list, the processing list, the retry and scheduled sets and the
dead-letter set. Anyone who can read Redis, a backup or an export can read the job's arguments. A property
declared with encrypted_property is written as an AES-256-GCM sealed string instead, and is read back to its
plain value when a worker loads the job, before perform runs:
class ChargeJob
include JoobQ::Job
property order_id : Int64 = 0 # stays readable (useful for the console and for debugging)
encrypted_property card_token : String = ""
encrypted_property billing : Hash(String, String)? = nil
def perform
charge(card_token) # plain text here
end
end
What this is: confidentiality of those properties at rest in Redis, and integrity: a sealed value that is moved to another job class or field, or altered, is refused. The keys are not in Redis.
What it is not: encryption of jobs in general (only the properties you declare); protection while the job runs,
in your own logs or in the messages your perform raises (the library scrubs the plain values of encrypted
properties from the error messages and backtraces it records, but it cannot know what you print); hiding the
size of a value.
Keys
One key (32 bytes, as 64 hex digits or base64, e.g. openssl rand -base64 32) is current and seals; any
number of older keys only open. A key is written id:key, the id being 1 to 16 characters of a-z 0-9 _ -
(a bare key without an id gets the id default). The id is stored with every sealed value, which is how a worker
knows which key to use.
JoobQ.configure do
payload_encryption current: "2026a:#{ENV["NEW_KEY"]}", previous: ["2025:#{ENV["OLD_KEY"]}"]
end
Without that call the keys come from the environment: JOOBQ_PAYLOAD_KEYS (comma separated id:key entries, the
first one current) or, as the simplest form, JOOBQ_PAYLOAD_KEY (one entry). The environment is read on first
use; changing it needs a restart.
To rotate: add the new key first, deploy, and keep the old one until no job sealed with it can be in Redis (queues, retries, scheduled jobs, dead letters); a job that is retried is written again with the current key. A value sealed with a key that is gone cannot be opened.
Failure mode
A job is never run with a value it could not open.
- Problems with the job: an unknown key id, a bad tag (wrong class or field, altered), a damaged value. The
worker moves it to the dead-letter set as the job itself (sealed values untouched, status Dead) with error
type
decryption_errorand a message that names the class, the field and the reason, never a value. It can be redriven (DeadLetterQueue#redrive, the API's retry) once the key is back, and runs then. - Problems with this process's keys: none configured, or
JOOBQ_PAYLOAD_KEYSmalformed (Encryption::ConfigurationError). The job is fine, so it is not dead-lettered: the worker logs, waits five seconds and puts it back on its queue. A queue of a job class with encrypted properties refuses to start (Queue#startraises) without valid keys, and enqueuing raises to the caller.
Payloads written before the property was encrypted (plain values) are read as they are, and written sealed the
next time the job is stored. JoobQ.config.allow_plaintext_payloads = false makes the worker refuse them
(Queue#add and the HTTP API still accept them: they are about to be sealed). While it is true, anyone who can
write to Redis can put an unsealed value in a job and it will run: sealing gives confidentiality, and integrity
only against tampering with a sealed value.
Limits worth knowing
- The associated data is the class and the property, not the job id: a sealed value can be copied between two jobs of the same class and property by someone who can write to Redis.
- Nonces are random 96 bit values: keep to about 2^32 seals per key, then rotate.
- Sealing is not deterministic: two serialisations of one job differ. Code that relies on identical strings collapsing (a sorted set member, a LREM by value) must not re-serialise a job to find it again.
- Strings shorter than
MIN_SCRUB(4) characters and numbers of fewer thanMIN_NUMBER(6) digits are not scrubbed from recorded errors, booleans never: they would match everywhere. - With
allow_plaintext_payloads = falsea worker that meets a plain value dead-letters the job without its payload (it holds the plain values); that legacy job is gone, so drain old jobs before flipping the flag.
Format
jqe1:<key id>:<base64url (no padding) of nonce, ciphertext and tag>: AES-256-GCM, a fresh random 96 bit nonce per value, the additional authenticated data
being joobq/payload/v1, the job class and the property name (separated by NUL). A String is sealed as it is; any other type is sealed as its JSON text. nil is stored
as null. Renaming the class or the property makes existing sealed values unreadable: pass job_name: to
encrypted_property to keep the name stable.
Constants
The same for the text of a number (an id of fewer digits would match everywhere).
Plain values shorter than this are not scrubbed from messages: a one- or two-character value would match everywhere and make every message unreadable. Do not put a secret that short in a job and expect it hidden.
Shown instead of a message that could not be scrubbed.
Begins every sealed value; used to recognise one without any key.
Replaces a plain value in text the library records.
Above this many bytes of secrets a single regex would not compile (PCRE2 has a pattern size limit).
Class methods
How many times a worker has put a job back for want of keys (a metric, and what specs wait on).
The additional authenticated data of a property.
How long a worker waits before it puts back a job it could not open for want of a keys (see ConfigurationError).
Raises Error (without any value in its message) for a value that is not sealed, was sealed for another class
or property, was altered, or was sealed with a key that is not configured.
Forgets the keys read from the environment, so the next use reads it again (specs, tools).
text with every given plain value replaced by REDACTED: in one pass when the values are few and small
(longest first, the replacement never searched again), value by value (longest first) otherwise. Invalid
UTF-8 in the text is replaced first.
text scrubbed of job's plain values; NOT_SCRUBBABLE when that cannot be done. Never raises.
Like scrub, but it never raises: whatever goes wrong, the text is replaced by NOT_SCRUBBABLE, so the
failure path that records an error can always finish (the job must still retry or dead-letter).
Every form in which value could show up in a message: for a string itself and its inspect/JSON text; for
a hash or array also its to_s, and every string leaf inside (numbers only from 6 digits, booleans never:
they would match everywhere); for a number its text if long enough. Longest first, without duplicates.
The plain values of the job's encrypted properties, or nil when they cannot be worked out.
Runs the block with plain payload values refused when allow_plaintext_payloads is false. Only the worker
path uses it: Queue#add and the HTTP API still accept plain values (they are about to be sealed).