---
title: "Payload encryption at rest"
description: "AES-256-GCM on flagged queues in two commands: the key format, which messages it covers, and the failure that stores plaintext with only a warning."
---

> Queen MQ documentation, for AI agents
> Complete self-contained summary of Queen MQ: https://queenmq.com/llms-brief.txt
> Fetch that first when the question is about the product rather than about this page.
> Index of all pages: https://queenmq.com/llms.txt

# Payload encryption at rest

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

1. Generate 32 random bytes as **64 hexadecimal characters**, set
   `QUEEN_ENCRYPTION_KEY` to them, and restart. Every broker sharing the database
   needs the same key, or messages one writes are unreadable to another.

```bash
openssl rand -hex 32
```

2. Confirm the boot log line. If it is absent, encryption is off: see
   [the silent failure](#a-bad-key-silently-stores-plaintext).

```text
encryption service initialized (AES-256-GCM)
```

3. Flag each queue that should be encrypted, with `encryptionEnabled` in the
   options of `POST /api/v1/configure`:

```bash
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](/reference/queue-options): 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. A timer payload is enveloped too, at
**schedule** time rather than at fire, on the timers route and on a
transaction's timer operations alike: the fire runs inside the sweeper,
possibly days later, so a late envelope would leave the payload in cleartext at
rest until then. The consequences of encrypting early are declared on
[timers](/reference/http/timers/#encryption-happens-at-schedule).

Two cases resolve to "no encryption", both without an error: a queue with no
`queen.queues` row yet (created implicitly by a push, never `/configure`d), 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 request's tenant, never on another tenant's same-named
queue, so a sink is enveloped by its owner's own configuration. 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

```json
{
  "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 |

> **Caution**
>
> A decrypt failure is never an error to a consumer: it is a message whose body
> happens to be an envelope, so a client fails on the field names, not on a status
> code. If you rely on encryption, assert on the decrypted shape in your consumer,
> and alert on it.

## 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:

1. Grep the boot log for `encryption service initialized`. Its absence, with a key
   set, means the key is malformed.

2. Push one message to a flagged queue and read the row directly in PostgreSQL: a
   segment's frames should not contain your plaintext.

3. 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](/deploy/security/): what else is and is not protected in a
  Queen deployment.
- [PostgreSQL TLS](/reference/security/postgres-tls/): protecting the payload in
  transit to the database, a different problem from protecting it at rest.
- The `QUEEN_ENCRYPTION_KEY` entry, and the boot block that masks it, are in the
  [configuration reference](/reference/config/).

Source: https://queenmq.com/reference/security/encryption/index.mdx
