Meeting compliance: how to talk to your Redis broker over TLS
The questionnaire asks whether data is encrypted in transit, and you know the answer for the edge. Then you remember the broker — the one connection every service holds open all day, carrying every argument and every return value, and speaking a protocol that puts them on the wire as text. Here is what encrypting it takes, what it costs, and the two places it is easy to get wrong.
The security questionnaire arrives with forty questions, and thirty-nine of them you can answer from memory. Then you reach this one: is data encrypted in transit between all system components?
You think about the edge first, because that is the part everybody looks at. TLS terminates at the load balancer, the certificates renew themselves, the browser shows a padlock. That has been true for years and nobody has had to think about it since.
Then you remember the broker.
It is the connection every service in the fleet holds open all day. Every RPC argument goes through it. Every return value comes back through it. The cache sits on it, the job queues sit on it, and the whole thing has been quietly working so well that the last time anyone looked at its configuration was when they first set the hostname.
And the honest answer, for most fleets, is: no. That part is not encrypted.
What is actually on the wire
Redis speaks RESP, a line-oriented protocol that is deliberately simple and
entirely plaintext. That is not a flaw; it is a design choice that makes the
server fast and its wire format debuggable with nc. But it does mean that
anyone who can read the bytes between your service and your broker can read
everything.
Everything, in this case, is more than most people picture. It is the arguments
of every service call, which for a user service means the user records
themselves. It is the return values. It is the job payloads, which tend to
accumulate exactly the fields nobody wanted to look up twice — addresses, order
totals, whatever the job needs to run without another round trip. And because
authentication happens in-band, it is also the password: on an unencrypted
connection the AUTH command crosses the wire before anything else does, in the
clear, once per connection.
The usual reassurance is that this all happens inside the perimeter. That was a better answer when the perimeter was a rack. Today the broker is frequently a managed instance in someone else's account, reached across a VPC peering link, or a hop that traverses a network segment you do not own and cannot audit. The question is not whether you trust your colleagues. It is whether you can demonstrate — to an auditor, or to yourself after an incident — that the bytes were unreadable in transit. If they were plaintext, you cannot.
One option, and it covers every channel
The first thing worth knowing is that a queue is not one connection.
@imqueue/core opens up to four: a reader that blocks on
the queue, a writer that publishes, a watcher that handles delivery safety, and
a subscription channel. They are separate sockets doing separate jobs.
They are also all created by the same internal factory, which is why TLS is one option rather than four:
import IMQ from '@imqueue/core';
import { readFileSync } from 'node:fs';
const queue = IMQ.create('user-service', {
host: 'redis.internal',
port: 6380,
tls: { ca: readFileSync('/etc/ssl/internal-ca.crt') },
});
await queue.start();
Set tls once and the
whole bus is encrypted — reader, writer, watcher and subscription alike. There
is no per-channel setting to forget.
The value is boolean | TlsOptions. Passing true connects with Node's
defaults, which means the broker's certificate is verified against the system
trust store — correct for a managed Redis with a certificate from a public CA,
and almost never what you want internally. Passing an object hands it to
tls.connect() as given, so anything Node accepts works, and the option is not
a curated subset that will need extending the first time you meet a real
deployment.
The broker has to be listening for TLS, which in Redis means tls-port. A
common production shape is to give it a TLS port and turn the plaintext one off
entirely:
redis-server --port 0 --tls-port 6380 \
--tls-cert-file /etc/redis/server.crt \
--tls-key-file /etc/redis/server.key \
--tls-ca-cert-file /etc/redis/ca.crt
--port 0 is the part that matters. Encryption you can opt out of is a
suggestion; a broker with no plaintext listener is a guarantee.
If your brokers announce themselves so services can discover them, reach for the
redis-broker image rather than those
flags. From v1.2.0 it composes them from IMQ_TLS_*, keeps the TLS listener
on 6379 so no port changes anywhere, and — the part that matters here —
advertises the port it is actually listening on. With an announcer module from
before that release, a broker configured by hand as above advertised <ip>:0:
port was what it announced, and port is now 0. See
encrypting the fleet.
It fails closed — and it will not tell you why
The reassuring half of this is that nothing quietly downgrades. There is no negotiation step that can be talked out of encryption, because there is no negotiation: either both ends are speaking TLS or the connection does not happen. I checked each way it can be wrong against a real broker, and all of them fail:
| Misconfiguration | Result | Time to report |
|---|---|---|
| Plaintext client, TLS-only broker | rejected | immediate |
| TLS client, plaintext broker | rejected | about 10 seconds |
| Certificate signed by an untrusted CA | rejected | immediate |
servername that the certificate does not carry |
rejected | immediate |
| No client certificate, broker requires one | rejected | immediate |
The unreassuring half is the diagnostics. Every one of those rejections surfaces
as the same message from the underlying client — Connection is closed. — with
no error event carrying the actual TLS reason. The verification failure happens
below the queue, and what comes back up is a closed socket.
So when a deploy comes up unable to reach the broker and the log says the
connection closed, resist the urge to treat that as a network problem. Reach for
openssl s_client against the broker with the same trust anchors and read the
real error there:
openssl s_client -connect redis.internal:6380 \
-CAfile /etc/ssl/internal-ca.crt -servername redis.internal
The one failure that is diagnostic is a missing certificate file, and it is
deliberately loud. If TLS material is named but cannot be read — a mistyped
path, a secret that did not mount — construction throws an error carrying the
code IMQ_TLS_MATERIAL_UNREADABLE and naming the variable at fault. It does not
fall back to an unencrypted connection. That distinction is the whole design:
the failure mode of a broken TLS configuration is a service that will not start,
never a service that starts and talks in the clear.
Turning it on across a fleet without touching the code
The code above is fine for one service. It is the wrong shape for thirty, because it puts a security decision in thirty repositories, where enabling it means thirty pull requests and verifying it means thirty code reviews.
So when tls is left unset, @imqueue/core consults the environment instead,
through envTls():
| Variable | Effect |
|---|---|
IMQ_REDIS_TLS |
Enables TLS with Node's default verification |
IMQ_REDIS_TLS_CA_FILE |
PEM bundle of trust anchors, for a private CA |
IMQ_REDIS_TLS_CERT_FILE |
Client certificate, for mutual TLS |
IMQ_REDIS_TLS_KEY_FILE |
The client certificate's private key |
IMQ_REDIS_TLS_KEY_PASSPHRASE |
Passphrase for an encrypted key |
IMQ_REDIS_TLS_SERVERNAME |
Expected certificate name |
IMQ_REDIS_TLS_REJECT_UNAUTHORIZED |
Set to 0 to accept an unverified certificate |
Three rules govern how they combine, and each one is a decision rather than an accident.
Supplying key material is enough to turn TLS on. A CA_FILE on its own
enables it, because there is no other reason to have named one. You do not have
to remember to set the switch as well as the paths.
The off switch beats everything. IMQ_REDIS_TLS=0 disables TLS even when
certificates are configured, and it short-circuits before the files are read —
so a rollback works even if the certificates have already been removed from the
image. Booleans are read generously, 1/true/yes/on and their negatives,
case-insensitively.
Options that only shape a connection cannot start one. Setting
IMQ_REDIS_TLS_REJECT_UNAUTHORIZED=0 by itself does not enable TLS. Neither
does SERVERNAME alone. Reading an enable out of "and by the way, do not
verify the certificate" would be a perverse way to arrive at an encrypted
connection, so it does not happen.
The payoff is that encrypting the fleet becomes a deployment change: set the
variables in the environment every service already inherits, restart, done. One
place to change, one place to audit, and it covers @imqueue/core,
@imqueue/rpc caches and @imqueue/job queues identically — they all consult
the same variables.
If one service genuinely must stay in plaintext — a local reproduction, a
sidecar on a loopback interface — passing tls: false explicitly declines the
environment fallback. Unset means "ask the environment"; false means "no".
A private CA, and then mutual TLS
Internal brokers rarely have certificates from a public CA, so the realistic configuration verifies against your own trust anchor:
const tls = { ca: readFileSync('/etc/ssl/internal-ca.crt') };
That gets you an encrypted connection to a broker you have authenticated. The next step up is authenticating in the other direction too — the broker verifying the client — which is mutual TLS, and it is the configuration that lets you stop treating the broker password as the only thing standing between an attacker and your queues:
const tls = {
ca: readFileSync('/etc/ssl/internal-ca.crt'),
cert: readFileSync('/etc/ssl/user-service.crt'),
key: readFileSync('/etc/ssl/user-service.key'),
};
With tls-auth-clients yes on the broker, a client that does not present a
certificate signed by that CA is refused at the handshake, before it ever gets
to send AUTH. For a compliance conversation this is a materially different
claim: not "the connection is encrypted" but "the broker will only speak to
services holding a certificate we issued".
Two practical notes.
The certificate is verified against the host you connected to. If you reach the
broker by IP address, its certificate needs that address as an IP SAN, or
verification fails — this is standard TLS behaviour and it catches people who
address brokers by IP out of habit. Set servername to the name on the
certificate, or better, reach the broker by that name.
And if you are tempted by rejectUnauthorized: false, know that the queue logs
a warning the moment you construct it with that set, saying in as many words
that the connection is encrypted but the server is not authenticated. That is
worth internalising rather than filtering out of the logs: without verification
you have confidentiality against a passive eavesdropper and nothing at all
against an active one, because anything that can intercept the connection can
also present its own certificate. It is a fine flag for a local experiment. In a
deployment it is the kind of thing that passes a questionnaire and fails an
incident.
Pooled connections have to remember how they were opened
Here is the part that is easy to get wrong, and it is not obvious until it bites.
The writer and watcher connections are shared per broker within a process. Two
queues pointing at the same host:port reuse one socket, which is the right
call — a process running eight services should not open eight identical writers.
But now add TLS to one of them. If the pool were keyed by address alone, a queue that asked for an encrypted connection could be handed a plaintext socket that some earlier queue opened first, and it would never know. The security posture of a connection would depend on construction order. That is the sort of bug that does not show up in tests, does not throw, and is discovered by someone reading a packet capture.
So the pool key includes the transport.
tlsFingerprint() reduces a TLS
configuration to a stable digest, and the pool slot is the address plus that
digest:
// plaintext queues pool on the address alone
'redis.internal:6380'
// encrypted ones carry their configuration in the key
'redis.internal:6380#bc0ae316c743a8b05632409fe1fbdf1fa593fdd1'
The fingerprint compares by value, not by identity, so two option objects built
independently in different modules still share a connection as long as they
describe the same thing — key order does not matter, true and {} agree,
undefined entries are ignored, and CA buffers compare by their contents.
Anything that genuinely changes the security posture changes the digest: a
different trust anchor, a different client certificate, a different
servername, rejectUnauthorized, even a different checkServerIdentity
callback, which is compared by its source text so that two connections differing
only in their verifier are not pooled together.
One caveat is worth knowing. An opaque host object — a prebuilt SecureContext,
an Agent — has no readable structure to compare, so it is fingerprinted by its
class name alone, and two distinct instances of the same class collide. If you
build contexts yourself, pass the certificate material rather than the
prebuilt object and the comparison stays exact.
The cache is one connection, and the first caller wins
@imqueue/rpc accepts the same tls option for its Redis
cache, and reads the same environment variables, so in the ordinary case caches
get encrypted along with everything else and you never think about it.
The exception is worth knowing about, because it behaves differently from
queues. The cache connection is process-wide: the first
RedisCache initialised in a process opens
it, and every later one reuses it — including one configured differently.
Where a queue would give you a second connection, the cache hands back the
existing one and logs a warning that it is not the transport these options
asked for.
It is a sensible design for a shared cache, but it means the configuration that takes effect is the one that ran first, which in a process with several initialisation paths is not always the one you would predict. Two things keep it boring: configure caches through the environment so every caller agrees by construction, and treat that warning as a real finding rather than noise, since it fires exactly when a cache is not encrypted the way its caller believed.
Jobs inherit all of it
@imqueue/job sits on the same core queue, so
JobQueueOptions.tls passes straight
through — and when you leave it unset, it is omitted entirely rather than being
set to undefined, which is what lets core's environment fallback work for job
queues too.
The one thing to notice is addressing. Job queues take their broker as a
cluster array rather than top-level host and port:
import { JobQueueWorker } from '@imqueue/job';
const worker = new JobQueueWorker({
name: 'invoices',
cluster: [{ host: 'redis.internal', port: 6380 }],
tls: { ca: readFileSync('/etc/ssl/internal-ca.crt') },
});
worker.onPop(async job => { /* ... */ });
await worker.start();
For a genuinely clustered broker there is a useful detail here: each cluster
entry may carry its own tls, overriding the top-level one for that server
alone, falling back to the top level when it does not. That is what you need
during a migration where the nodes do not yet share trust anchors. Per-entry
username and password are ignored — credentials stay top-level.
What it costs
Less than people expect, and not where they expect it.
The handshake is real and measurable. On loopback with RSA-2048 certificates, establishing a mutually-authenticated connection took a median of 9.5 ms against 1.6 ms for plaintext — call it 8 ms of TLS. Over a real network with real round trips it will be more.
Steady-state throughput, though, is where the intuition misleads. Pushing 5,000 small messages through an encrypted queue and a plaintext one, alternating, the difference disappeared into the measurement noise — some rounds the encrypted queue was faster. Symmetric encryption on a modern CPU is not the bottleneck; on loopback, neither is the network. Your own results will differ with message size and real latency, but the shape holds: the cost is the handshake, not the traffic.
And the handshake is paid once per connection, not once per message, because the queue holds its connections open. A service that runs for a week pays it at startup and never again. That is the whole budget, and it is one of the better security-to-cost ratios available to you.
What TLS does not do
Worth being precise about, because "encrypted in transit" gets stretched to cover things it does not.
It protects the bytes between your service and the broker. Redis decrypts them on arrival, so the broker holds your data in memory as plaintext, and anything persisted to disk is written as plaintext unless you have arranged encryption at rest separately. If your threat model includes the broker host itself, TLS is not the control you are looking for — payload-level encryption is.
Mutual TLS authenticates callers; it does not authorize them. A service holding a valid certificate can read any queue it knows the name of. Redis ACLs are the tool for restricting what an authenticated client may do, and they compose with everything above.
And turning encryption on adds an availability dependency you did not have before: certificates expire. A queue whose broker certificate lapsed does not degrade to plaintext, it stops — which is the correct behaviour, and also a scheduled outage waiting to happen if nobody owns renewal. Put the expiry dates somewhere that alerts before they matter, and rehearse a rotation before you need one.
None of that argues against encrypting the broker. It argues for describing what you did accurately, which is the thing the questionnaire was really asking.
FAQ
How do I enable TLS for a Redis connection in Node.js?
Set the tls option on the queue. tls: true connects with Node's defaults and
verifies the broker against the system trust store, which is right for a managed
Redis with a publicly-signed certificate. For an internal broker, pass an object
with your own trust anchor — tls: { ca: readFileSync('/etc/ssl/ca.crt') } — and
it is handed to tls.connect() as given. In @imqueue one setting covers every
channel the queue opens, so there is nothing else to configure.
Can I turn on TLS without changing application code?
Yes, and for a fleet this is the better path. Leave tls unset and
@imqueue/core reads IMQ_REDIS_TLS and its companion variables from the
environment, so encrypting every service becomes a deployment change in one
place rather than a pull request per repository. The same variables cover
@imqueue/rpc caches and @imqueue/job queues. Setting tls: false
explicitly declines the fallback for a service that must stay in plaintext.
Does @imqueue support mutual TLS with client certificates?
Yes. Pass cert and key alongside ca — plus passphrase for an encrypted
key — and the client presents its certificate during the handshake. With
tls-auth-clients yes on the broker, a client without a valid certificate is
refused before it can send its password. Configured through the environment, the
equivalent variables are IMQ_REDIS_TLS_CERT_FILE and IMQ_REDIS_TLS_KEY_FILE.
What happens if the broker is not listening for TLS?
The connection is refused. There is no negotiation and no fallback, so an encrypted client cannot be downgraded to plaintext by a broker that is not configured for TLS, and a plaintext client cannot reach a TLS-only broker. Both directions fail, though not at the same speed: a plaintext client against a TLS broker is rejected immediately, while an encrypted client against a plaintext broker takes about ten seconds to give up.
Why does my Redis TLS connection fail when I connect by IP address?
Because the certificate is verified against the address you connected to, and a
certificate issued for a hostname does not match a bare IP unless it carries
that IP as a subject alternative name. Either reach the broker by the name on
its certificate, set servername to that name, or reissue the certificate with
an IP SAN. This is standard TLS behaviour rather than anything specific to the
queue. If the address comes from discovery and changes with every reschedule,
only the middle option survives — see the next answer.
How do I encrypt a broker fleet that services discover at runtime?
The same tls option, plus the one thing discovery changes: the address is not
knowable in advance. Brokers announce whatever IP the scheduler gave them, so no
certificate can carry it and there is no name to connect by. Issue one
certificate for the fleet, with a name that will never be resolved —
CN=imq-broker.internal and a matching DNS SAN — and pin it on the services
with IMQ_REDIS_TLS_SERVERNAME. Node checks that name against the certificate
while the connection still goes to the announced IP, so a broker pod that dies
and returns on a different address needs nothing reissued. On the broker side,
ghcr.io/imqueue/redis-broker v1.2.0 or newer advertises whichever port is
really listening; before that a TLS broker announced port 0 and disappeared
from the fleet. The longer version is in
auto-scaling Redis broker.
Is rejectUnauthorized: false acceptable in production?
No. It keeps the encryption and discards the authentication, which protects you
from someone passively reading the wire and not at all from someone
intercepting it — an attacker in the path can present any certificate and be
accepted. @imqueue logs a warning at construction whenever it is set, for
exactly that reason. If verification is failing, the fix is to supply the right
trust anchor through ca, or the right name through servername.
How much does TLS slow down a Redis message queue?
Almost nothing in steady state. Measured on loopback, throughput over a mutually authenticated connection was indistinguishable from plaintext across repeated runs of thousands of messages. The measurable cost is the handshake — roughly 8 ms per connection in that test, more over a real network — and because the queue holds its connections open, that is paid at startup rather than per message.
Do caches and job queues need to be configured separately?
Only if you configure them in code, in which case each takes its own tls
option. Configured through the environment they are covered together, since
@imqueue/core, @imqueue/rpc and @imqueue/job all consult the same
IMQ_REDIS_TLS* variables. The one behaviour worth knowing is that the RPC
cache connection is process-wide, so the first cache initialised in a process
decides the transport for all of them and later mismatched ones get a warning.
Reference
IMessageQueueAuthConnection.tls ·
envTls() ·
tlsFingerprint() ·
IMQOptions.cluster ·
JobQueueOptions.tls ·
IRedisCacheOptions ·
RedisQueue ·
FAQ: how do I encrypt the connection between my services and the broker? ·
FAQ: how do I turn on TLS across a fleet without changing application code?
Read this page as plain markdown — no HTML, no navigation. For pasting into an LLM, or for an agent to fetch.
Building on @imqueue? The open-source packages live on GitHub and the docs at imqueue.org. Shipping inside a closed-source product? See commercial licensing & support.