Signed queues in Symfony 8.2: queue messages are untrusted input
TL;DR: A message in your queue is input from outside, and I treat it that way. Symfony 8.2 adds a sign option on transports that refuses unsigned messages before anything is deserialised. My advice is to plan the rollout and the secret rotation before the first deploy.
A queue looks like a private corridor between two parts of my own system. I think that picture is wrong, because the queue is a shared place where anything that can write to it can hand your worker a payload. Yes, I’d call that untrusted input.
The risky step is deserialisation. My worker takes bytes, builds objects from them and only then asks whether it should have trusted them. That order is backwards, I think. Personally I’d rather check who wrote the message first, and read it second.
On 21 Oct 2026 the Symfony blog described how 8.2 does that: “New in Symfony 8.2: Signed Transports and Secret Rotation”. A transport with sign: true refuses a message without a valid signature before it reads the type or the stamps. Symfony 8.2 isn’t out yet. The stable release is planned for November 2026, so details can still move, and I’d read the docs again at release.
Signing isn’t new, by the way. Symfony 7.4 already had it per handler, with #[AsMessageHandler(sign: true)]. 8.2 moves it to the transport and adds secret rotation. Per-handler signing is deprecated in 8.2.
Moving from 7.4 to 8.2
My first question is what happens to the messages already sitting in the queue. They have no signature. Once sign is on, they are refused, they land in the failure transport, and a retry there fails again. The docs say to enable it only on a transport with no pending messages. Another way is a new signing transport (their example is async_signed) and routing the sensitive messages to it.
Normal transports get no lenient mode in the docs, as far as my reading goes. That is silence, not a promise, so I’d test it on your version before relying on it.
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
sign: true
failed:
dsn: 'doctrine://default?queue_name=failed'
sign: true
serializer:
signing_secret: '%env(MESSENGER_SIGNING_SECRET)%'The failure transport has to sign too, and so does the outbox. If they don’t, the container won’t compile. Simple as that. Here, in my eyes, that is good, because a half-signed setup fails early and loudly.
UPGRADE-8.2 lists a BC break that I’d read twice: messenger.default_serializer no longer signs, because signing now lives in a serializer per transport. If your serializer writes headers beyond the usual ones, 8.2 adds a Sign-Headers header that older versions refuse. Consumers go first, producers second.
Sadly, I can’t tell you whether a 7.4 producer’s signature passes an 8.2 signing transport, because the docs don’t say. So on staging, my first job would be a test with a mixed fleet.
The secret falls back to kernel.secret when signing_secret is empty. The signature is an HMAC with sha256 by default, and the docs show no YAML key for the algorithm. In my case I’d leave the algorithm alone.
Rotating signing secrets
In my book a signing secret is a secret like any other. It leaks, people leave, and someone asks when you last changed it, and on a queue that question is harder than it sounds, because old and new messages sit side by side for a while. That is the whole problem.
signing_secret takes a list of secrets, as the docs say. The first entry signs, and every entry verifies. That is my favourite part. A retry is signed again with the first secret, so messages signed with the old secret move to the new one. Drop the old secret only when every message signed with it is consumed, and that includes the failure transport. Look at the failure transport first, since messages signed with the old secret can wait there. For me that means checking that queue by hand.
With several apps on one transport, add the new secret as the second entry in every app first. Then move it to first place everywhere, which is how we avoid a gap. I’d never do it in one step, since one app would then sign with a secret another app doesn’t know yet.
Personally I wouldn’t change kernel.secret directly just to rotate this. It also breaks remember-me, signed URIs and login links. 8.2 adds secret lists per feature for that reason.
What I’d sign first
I don’t think signing replaces a sane network setup. It’s one more layer, and in my view a cheap one. So my rule is simple: I’d sign first the transport whose messages move money or run code.
My list is payment commands such as capture, refund and payout, plus Symfony’s own RunCommandMessage and RunProcessMessage. A forged refund costs money, and a forged RunProcessMessage runs a process on your worker. Everything else can wait, and the other queues can follow later. Symfony already puts sign on the handlers of those two messages, so you aren’t starting from zero.
Would you sign every transport? Honestly, I’m not sure I would.