Queen can encrypt message payloads with AES-256-GCM before they are written to PostgreSQL, so a database dump, a stolen backup or a read-only replica does not yield message contents. It needs exactly two things true at once: a key on the process and a flag on the queue. With only one of the two in place the broker stores plaintext without raising an error.
Enabling it
-
Generate 32 random bytes as 64 hexadecimal characters, set
QUEEN_ENCRYPTION_KEYto them, and restart. Every broker sharing the database needs the same key, or messages one writes are unreadable to another.openssl rand -hex 32 -
Confirm the boot log line. If it is absent, encryption is off: see the silent failure.
encryption service initialized (AES-256-GCM) -
Flag each queue that should be encrypted, with
encryptionEnabledin the options ofPOST /api/v1/configure:curl -s -X POST http://localhost:6632/api/v1/configure -H 'Content-Type: application/json' -d '{"queue":"orders","options":{"encryptionEnabled":true}}'
/configure is a full replace: a later call that
does not repeat encryptionEnabled: true turns encryption off for that queue, and
subsequent pushes store plaintext. Include every non-default option the queue
should keep, not only the flag.
What gets encrypted
The flag lives on queen.queues.encryption_enabled, written by
configure_queue_v1, and the broker caches the answer per tenant and queue.
Encryption applies when the key is loaded and the flag is true, on all four
write paths: POST /api/v1/push, POST /api/v1/transaction, the stream emit
path, and the disk spool the broker falls back to when PostgreSQL is unreachable,
so a payload buffered to local disk during an outage is enveloped before it is
written, not after it is replayed.
Two cases resolve to “no encryption”, both without an error: a queue with no
queen.queues row yet (created implicitly by a push, never /configured), and a
flag lookup that could not reach the database.
A stream’s sink output is enveloped like any other write. Before
POST /streams/v1/cycle packs a cycle’s push_items, the broker resolves
encryptionEnabled for each sink queue and encrypts that queue’s payloads, so a
closed window lands in the log as ciphertext exactly as a pushed message does. The
flag is resolved on the default tenant on this path, which is the only tenant a
streams deployment serves. Consumers of the sink queue decrypt by shape on the way
out, so nothing downstream has to know a stream produced the message.
Only the payload is enveloped. The transactionId, trace name, queue and
partition names, offsets, timestamps, producerSub, consumer-group cursors and
DLQ metadata are not, so anyone with database access still sees the shape of your
traffic, the names of your queues, and the identity of your producers.
The stored envelope
{
"encrypted": "<base64 ciphertext>",
"iv": "<base64, 16 bytes>",
"authTag": "<base64, 16 bytes>"
}Standard base64 with padding, authTag in camelCase. The envelope stores all 16
IV bytes but only the first 12 are fed to the cipher: the previous
OpenSSL-based implementation never set the GCM IV length, so it used OpenSSL’s
default 12-byte nonce, and matching that byte for byte is what keeps messages
written by 0.16.0 readable here, and vice versa.
Reading it back
Decryption is triggered by shape, not by the stored flag: any payload that
parses as a JSON object carrying encrypted, iv and authTag string fields is
decrypted. That is why a queue whose flag has since been turned off still returns
readable messages, and why messages migrated from an older broker decrypt without
touching their flags. The isEncrypted field in a message detail response reports
the stored per-message flag, a separate thing from whether the broker just
decrypted it.
Three refusals, all of which return the payload as stored:
| Refusal | Behaviour |
|---|---|
| No key configured | The read serves the envelope JSON itself: a consumer receives {"encrypted":"…","iv":"…","authTag":"…"} as its message body. That is the signal that a broker in the group is missing the key |
| Wrong key | GCM tag verification fails and the envelope is served as-is. No error, no log line on the read path |
| Wrong sizes | An IV or tag that is not 16 bytes is rejected without attempting the cipher |
A bad key silently stores plaintext
QUEEN_ENCRYPTION_KEY is read at boot, and every rejection path disables
encryption for the whole process and lets the broker start normally:
| Key value | Outcome | Log |
|---|---|---|
| Unset or empty | Encryption disabled | none |
| Not exactly 64 characters | Encryption disabled | warn: QUEEN_ENCRYPTION_KEY must be 64 hex chars; encryption DISABLED |
| 64 characters, not all hex | Encryption disabled | warn: QUEEN_ENCRYPTION_KEY is not valid hex; encryption DISABLED |
| 64 hex characters | Encryption enabled | info: encryption service initialized (AES-256-GCM) |
With encryption disabled, a queue whose flag is true keeps accepting pushes and
stores every payload in plaintext: the push succeeds, the response says queued,
and only the boot log shows it. The same design covers a runtime cipher failure:
if the cipher call fails for an individual message, the broker warns (sampled at
one line per 10,000 occurrences, so it cannot flood stderr at ingest rate) and
stores that message in plaintext rather than failing the push.
The streams cycle degrades the same way and for the same reason. A cipher failure
on a sink payload logs encryption failed; stored plaintext under the streams
target, with the queue name and a suppressed count on the same 1-in-10,000
sampler, and the cycle commits: the window result, the state writes and the source
ack are one transaction, so failing the encryption would mean replaying the whole
window rather than storing one message in the clear. Alert on that log line, since
the cycle response reports success either way.
Verify at the deployment level, since the queue’s flag alone does not tell you whether the key loaded:
-
Grep the boot log for
encryption service initialized. Its absence, with a key set, means the key is malformed. -
Push one message to a flagged queue and read the row directly in PostgreSQL: a segment’s frames should not contain your plaintext.
-
Make that check part of the deploy, not a one-off. A key arriving from a secret store as an empty string, or with a trailing newline, fails the 64-character check above.
Rotation
Rotation is a drain-then-restart operation: drain the affected queues first (consume to the head, or let retention age the old segments out), then restart every broker in the group with the new key at the same moment. Verify the drain with the queue’s own depth, not with elapsed time.
The drain is what makes it safe. One key is loaded per process and decryption tries only that key, so every message still in the log written under the old key becomes an undecryptable envelope from the instant the new key is in place.
Related
- Trust boundaries: what else is and is not protected in a Queen deployment.
- PostgreSQL TLS: protecting the payload in transit to the database, a different problem from protecting it at rest.
- The
QUEEN_ENCRYPTION_KEYentry, and the boot block that masks it, are in the configuration reference.