signature
Deterministically signs every message with the edge node's long-lived Ed25519 identity and attaches the signature, key ID, and algorithm to the message — either as message metadata, spliced into the JSON body, or both. The payload bytes themselves pass through unchanged.
pipeline:
processors:
- signature: {}
The processor's position in the pipeline is the signature's meaning. Place it first and the signature attests to the raw ingest payload; place it last (after enrichment, mapping, or metadata) and the signature covers the enriched output. There is no built-in verifier processor — verification is the consumer's responsibility, and the canonicalisation rules a verifier must follow are described in the Verification section below.
When to Use
Use the signature processor when you need to:
- Attest data integrity — give downstream consumers cryptographic proof that a message has not been altered between the signing node and the sink.
- Bind data to a node identity — every signature is tied to the producing node's identity via a keyId of the form
node:<nodeID>#sha256:<thumbprint>. - Sign HTTP requests via metadata propagation — combined with an output that exposes
metadata.include_patterns(e.g.http_client,kafka,nats), the signature rides on transport headers without modifying the body. - Persist self-contained signed records — when writing to file or object storage, where headers don't survive, splice the signature into the body so the record is verifiable on its own.
Don't use this if:
- You need a keyed MAC (HMAC) or RSA/ECDSA signature — the processor is Ed25519-only and signs with the node's identity key. Algorithm negotiation is not supported.
- You need replay protection — see Limitations.
- You need to sign with a key supplied per-message — keys are bound to the node identity at service start.
Configuration
- Common
- Advanced
# Common config — every field is optional. This is the default behavior.
pipeline:
processors:
- signature: {}
# All config fields, showing default values
pipeline:
processors:
- signature:
target: meta # "meta" | "body" | "both"
on_signing_error: ignore # "ignore" | "fail"
# Metadata mode (target: meta or both)
metadata_signature_key: expanso_signature
metadata_keyid_key: expanso_keyid
metadata_algorithm_key: expanso_signature_alg
# Body mode (target: body or both)
body_format: nested # "nested" | "flat"
body_key: _signature # root-level key for nested layout
_execution_idThe processor accepts an internal _execution_id field that is automatically populated by the Expanso Edge runtime when the pipeline starts. User-supplied values are rejected by the validator. Do not set this field in your pipeline configuration.
Fields
| Field | Type | Default | Description |
|---|---|---|---|
target | enum | meta | Where the signature is attached. meta writes three message-metadata keys. body splices into the JSON body. both does both. |
on_signing_error | enum | ignore | What happens if signing or body-splicing fails. ignore logs the error and lets the message pass through unsigned. fail returns a processor error so error-handling patterns can route the message. |
metadata_signature_key | string | expanso_signature | Metadata key holding the base64-encoded signature. |
metadata_keyid_key | string | expanso_keyid | Metadata key holding the signing key ID. |
metadata_algorithm_key | string | expanso_signature_alg | Metadata key holding the algorithm name (always Ed25519). |
body_format | enum | nested | Body layout when target is body or both. nested writes a single root-level key holding {alg, kid, sig}. flat writes the three metadata-style keys at the root. |
body_key | string | _signature | Root-level key used by the nested body layout. Ignored in flat mode. |
The validator rejects unknown fields, unknown enum values, and combinations that mean nothing (for example target: meta with a custom body_key). Identical checks run again at processor construction so hand-built configs cannot bypass them.
Targets and body layouts
target: meta (default)
Three configurable keys are written to message metadata; the payload bytes are not touched. Outputs that propagate metadata as transport headers (HTTP, Kafka, NATS) carry the attestation alongside the data.
# Default behavior — propagate signature as HTTP headers
pipeline:
processors:
- signature: {}
output:
http_client:
url: https://example.com/ingest
verb: POST
metadata:
include_patterns:
- "^expanso_" # passes the three signature keys as headers
target: body
The signature is spliced into the JSON body. The body must be a single JSON object at its root — arrays, scalars, plaintext, and trailing content are rejected through on_signing_error. No metadata is stamped, so the message is self-contained: useful for file sinks, object storage, or any transport that doesn't carry headers.
# Nested layout (default)
pipeline:
processors:
- signature:
target: body
Wire body for an input of {"temp_c":21.5,"sensor_id":42,"marker":"hello"}:
{
"_signature": {
"alg": "Ed25519",
"kid": "node:a1b2c3d4-...#sha256:nVaBCZ...quU",
"sig": "Vb0/zoMrfNlGhAaUWfvSbctp...mzRm1FnBw=="
},
"marker": "hello",
"sensor_id": 42,
"temp_c": 21.5
}
Switch to a flat layout when downstream tooling expects the metadata-style keys at the root:
pipeline:
processors:
- signature:
target: body
body_format: flat
{
"expanso_signature": "Vb0/zoMrfNlG...",
"expanso_keyid": "node:a1b2c3d4-...#sha256:nVaBCZ...quU",
"expanso_signature_alg": "Ed25519",
"marker": "hello",
"sensor_id": 42,
"temp_c": 21.5
}
target: both
Writes to both metadata and body simultaneously. Useful when a pipeline fans out to mixed outputs — some that propagate metadata, some that don't. The metadata write runs first and cannot fail; the body splice runs second and is governed by on_signing_error. In the default ignore mode, a body-splice failure leaves the metadata write in place (documented partial success).
Collision handling
The processor refuses to overwrite user-supplied fields. If the body already contains the configured body_key (nested) or any of the three expanso_* keys (flat), the splice fails and is routed through on_signing_error. Rename the keys via body_key or metadata_*_key if your payload already uses those names — but remember to configure your verifier identically.
Pipeline position is the signature's meaning
The signature covers exactly the bytes the processor sees when the message arrives at it. Two placements give two different attestations:
# (A) Sign raw ingest — signature attests to what the producer emitted
pipeline:
processors:
- signature: {}
- mapping: |
root.enriched_at = now()
# (B) Sign enriched output — signature covers the lineage stamp as well
pipeline:
processors:
- metadata:
include: [core, node, pipeline]
target: body
format: nested
body_key: lineage
- signature: {}
Both are correct; they answer different questions. Decide explicitly which one you want before deploying — once a downstream consumer trusts a signature, changing the placement silently changes what it means.
Canonicalisation rule
The bytes that are signed are not necessarily the bytes on the wire. The processor canonicalises each payload so verifiers don't depend on accidental ordering:
- JSON payloads (the entire input parses through Go's
json.Decoder) are decoded into a generic value withjson.Decoder.UseNumber()enabled, then re-marshalled with Go'sencoding/json. The effect: object keys are sorted in byte-wise lexicographic order,int64precision is preserved exactly (no truncation tofloat64), the output has no inter-token whitespace, and the characters<,>, and&are escaped as<,>,&(Go's HTML-safe default). - Non-JSON payloads (binary, plaintext, anything with trailing content after a top-level JSON value) are signed as the raw bytes received.
A verifier that does not reproduce that exact byte sequence will silently fail. This is the single most common source of false negatives — see Verification.
Error handling
on_signing_error | Behavior |
|---|---|
ignore (default) | The error is logged and the message passes through without signature attachment. The pipeline continues. |
fail | The processor returns a structured error. The message is routed via your configured error-handling pattern, so you can dead-letter or retry. |
Three additional failure modes are surfaced at startup, not at runtime:
- Old edge binary (predating the
signatureprocessor) rejects the unknown processor type at stream init, so a misconfigured rollout is caught at deploy time. - New edge binary without a signing identity refuses to construct the executor with a clear "no signing identity provided; this node cannot run pipelines that use the
signatureprocessor" message. - Signer missing for an execution returns a runtime error from the builder — this indicates an internal bug and should be reported.
Verification
The processor signs; it does not verify. A downstream consumer that wants to validate a signed message needs to apply the same canonicalisation rule the producer used and call an Ed25519 verifier. The protocol is the same regardless of whether the signature was carried in metadata or in the body.
Recover the canonical bytes
Metadata mode (target: meta, or target: both when consuming via a header-propagating transport):
- Read the signature, key ID, and algorithm from message metadata (HTTP headers, Kafka headers, NATS headers, …).
- Parse the body as JSON with the equivalent of Go's
json.Decoder.UseNumber()into a generic map and re-marshal it, sorting keys. If the body is not JSON, treat the raw bytes as the canonical form.
Body-nested mode (target: body, body_format: nested):
- Parse the body as JSON with
UseNumber()into a map. - Extract the
alg,kid, andsigfields under the configuredbody_key(default_signature). - Delete that root-level key from the map.
- Re-marshal the remaining map with sorted keys — that is the canonical input to the verifier.
Body-flat mode (target: body, body_format: flat):
- Parse the body as JSON with
UseNumber()into a map. - Read the three
expanso_*(or renamed) keys from the root. - Delete all three keys from the map.
- Re-marshal the remaining map with sorted keys.
Resolve the public key
The kid is structured as node:<nodeID>#sha256:<thumbprint>, where the thumbprint is the RFC 7638 JWK thumbprint of the node's public Ed25519 key. To recompute it from a public key pub, hash the JWK template below — with no whitespace, in this exact field order — and base64url-encode the SHA-256 digest without padding:
{"crv":"Ed25519","kty":"OKP","x":"<base64url-nopad(pub)>"}
Consumers map the kid to a public key via whatever distribution channel suits the deployment (a registry endpoint, a static map, a JWKS document published by the orchestrator). The producing node's public key must be obtained out of band — the message itself does not carry it.
Verify
// Pseudocode — every language with an Ed25519 implementation works the same way.
ok := ed25519.Verify(publicKey, canonicalBytes, signatureBytes)
Encodings are deliberate and distinct:
signatureBytesis the base64-std decode (RFC 4648 §4, padded — produced by Go'sbase64.StdEncoding) of the header or body value.- The
kidthumbprint is base64url without padding (RFC 4648 §5). - The public-key
xvalue inside the JWK template is also base64url without padding.
The canonical bytes are exactly the bytes produced by the recovery step above.
The defaults (expanso_signature, expanso_keyid, expanso_signature_alg, _signature) are convention, not wire format. If you change metadata_*_key, body_key, or body_format on the producer, your verifier must be configured with the same values — otherwise it will fail to locate the signature, or fail to delete it before canonicalising, and verification will silently fail.
Limitations
- No built-in replay protection. A captured signed message can be re-delivered at the sink. Consumers requiring replay prevention should add a transport-level nonce or timestamp and check it independently of the signature.
- Canonicalisation must match exactly. Verifiers that sort keys differently, that lose
int64precision through afloat64conversion (the default forJSON.parsein JavaScript), that pretty-print output, or that mishandle non-JSON payloads will produce false negatives. - Key rotation is not handled by the processor. The node's keypair is loaded once at service start. When you rotate it, records signed under the old key can only be verified if the consumer still holds the old public key — your key-distribution channel needs to retain historical keys for the lifetime of any verifiable record.
- HTTP infrastructure may drop underscored header names. When propagating signatures via the
^expanso_regex, some servers (nginx default, AWS ALB, some CDNs) strip headers whose names contain underscores. Rename the keys to use hyphens (e.g.x-expanso-signature) if your transport goes through such infrastructure. - Edge agent only. The processor runs only on edge nodes; the orchestrator is not involved in signing.
Examples
- HTTP with header propagation
- Body-nested for file sinks
- Fail-closed
Sign every message and push it to an HTTP endpoint, propagating the three signature metadata keys as headers:
input:
generate:
interval: 1s
mapping: |
root.temp_c = 21.5
root.sensor_id = 42
root.marker = "sig-example"
pipeline:
processors:
- signature: {}
output:
http_client:
url: https://example.com/ingest
verb: POST
metadata:
include_patterns:
- "^expanso_"
The receiver sees the body as it was emitted, plus Expanso_signature, Expanso_keyid, and Expanso_signature_alg headers.
Sign into a self-contained JSON record so a downstream file consumer can verify without any out-of-band metadata:
pipeline:
processors:
- signature:
target: body
body_format: nested
body_key: _attestation
output:
file:
path: /var/log/signed-events.jsonl
codec: lines
Drop unsigned messages instead of letting them pass through silently. Combine with the pipeline error-handling chain to dead-letter the failures:
pipeline:
processors:
- signature:
on_signing_error: fail
For a complete pipeline that exercises both metadata and body modes through a switch output, see Sign Pipeline Messages.
Next steps
- Sign Pipeline Messages example — full worked pipeline with HTTP + file sinks.
metadataprocessor — pair withsignatureto attest enriched lineage events.- Bloblang guide — when you need to compute fields before signing.