Payload Signing
Stem can sign every task envelope so workers can detect tampering or untrusted publishers. This guide focuses on newcomers: how signing works, how to wire it into your app, and where to look when something fails.
Why sign envelopes?
Signing lets workers verify that the envelope payload (args, headers, metadata,
and timing fields) is unchanged between the producer and the broker. When
signing is enabled on workers, any envelope missing a signature or carrying an
invalid signature is rejected and moved to the DLQ with a signature-invalid
reason.
How signing works in Stem
- Producers create a
PayloadSignerfrom environment-derived config and pass it intoStemto sign new envelopes. - Workers create the same signer (or verification-only config) and pass it into
Workerto verify each delivery. - Schedulers/Beat that enqueue tasks should also sign.
- Signatures are stored in envelope headers:
stem-signatureandstem-signature-key.
Signing is opt-in: if no signing keys are configured, envelopes are sent and accepted unsigned.
Quick start (HMAC)
- Generate a shared secret and export signing variables on every producer and worker (and any scheduler that enqueues tasks):
export STEM_SIGNING_ALGORITHM=hmac-sha256
export STEM_SIGNING_KEYS="v1:$(openssl rand -base64 32)"
export STEM_SIGNING_ACTIVE_KEY=v1
- Wire the signer into producers, workers, and schedulers.
These snippets come from the example/microservice project so you can see the
full context.
- Producer (enqueuer): sign outgoing envelopes
- Worker: verify signatures on every delivery
- Scheduler (Beat): sign scheduled tasks
Load signing config once at startup:
final config = StemConfig.fromEnvironment();
Create a signer from that config:
final signer = PayloadSigner.maybe(config.signing);
Attach the signer to the producer so envelopes are signed:
final stem = Stem(
broker: broker,
registry: registry,
backend: backend,
signer: signer,
);
Load signing config once at startup:
final config = StemConfig.fromEnvironment();
If your worker only needs to verify, the signer can be created from public keys:
final signer = PayloadSigner.maybe(config.signing);
Attach the signer to the worker so signatures are verified:
final worker = Worker(
broker: broker,
registry: registry,
backend: backend,
queue: 'greetings',
consumerName: 'microservice-worker',
concurrency: 4,
prefetchMultiplier: 2,
signer: signer,
observability: observability,
);
Load signing config once at startup:
final config = StemConfig.fromEnvironment();
Schedulers that enqueue tasks should also sign:
final signer = PayloadSigner.maybe(config.signing);
final beat = Beat(
store: scheduleStore,
broker: broker,
lockStore: lockStore,
tickInterval: const Duration(seconds: 1),
signer: signer,
);
Ed25519 (asymmetric signing)
Ed25519 keeps private keys only on producers while workers verify with public keys.
- Generate keys and export the values:
dart run scripts/security/generate_ed25519_keys.dart
- Set variables on producers, workers, and schedulers:
export STEM_SIGNING_ALGORITHM=ed25519
export STEM_SIGNING_PUBLIC_KEYS=primary:<base64-public>
export STEM_SIGNING_PRIVATE_KEYS=primary:<base64-private>
export STEM_SIGNING_ACTIVE_KEY=primary
- For workers, you may omit
STEM_SIGNING_PRIVATE_KEYSif you only want to verify signatures.
Key rotation (safe overlap)
- Add the new key alongside the old one in your key list.
- Update
STEM_SIGNING_ACTIVE_KEYon producers first. - Roll workers (they accept all configured keys).
- Remove the old key after the backlog drains.
Example: producer logging the active key and enqueuing during rotation (from
example/signing_key_rotation):
- Rotation: log active key + signing state
- Rotation: enqueue tasks with current key
final keyId = config.signing.activeKeyId ??
Platform.environment['STEM_SIGNING_ACTIVE_KEY'] ??
'unknown';
stdout.writeln(
'[producer] broker=${config.brokerUrl} backend=$backendUrl '
'signing=${config.signing.isEnabled ? 'on' : 'off'} '
'activeKey=$keyId tasks=$taskCount',
);
const options = TaskOptions(queue: rotationQueue);
for (var i = 0; i < taskCount; i += 1) {
final label = 'rotation-${i + 1}';
final id = await stem.enqueue(
'rotation.demo',
options: options,
args: {'label': label, 'key': keyId},
);
stdout.writeln('[producer] enqueued $label id=$id');
}
Reference: signing environment variables
| Variable | Purpose | Notes |
|---|---|---|
STEM_SIGNING_ALGORITHM | hmac-sha256 (default) or ed25519 | Defaults to HMAC. |
STEM_SIGNING_KEYS | HMAC secrets (keyId:base64) | Comma-separated list. Required for HMAC. |
STEM_SIGNING_ACTIVE_KEY | Key id used for new signatures | Required when signing. |
STEM_SIGNING_PUBLIC_KEYS | Ed25519 public keys (keyId:base64) | Comma-separated list. Required for Ed25519. |
STEM_SIGNING_PRIVATE_KEYS | Ed25519 private keys (keyId:base64) | Only needed by signers. |
Failure behavior & troubleshooting
- Missing or invalid signatures are dead-lettered with reason
signature-invalidand increment thestem.tasks.signature_invalidmetric. - If you see
signature-invalidin the DLQ, confirm all producers are signing and that workers have the same key set. - If the active key id is not present in the key list, producers will fail fast when trying to sign.
Next steps
- Review Prepare for Production for TLS guidance and deployment hardening.
- Use the Producer API guide for advanced enqueue patterns.