Observability · Tracing
The baggage Header: Limits, Trust Boundaries, and Misuse
How W3C Baggage carries arbitrary key-value pairs alongside a trace, the member and byte limits that force dropping, and why every entry crossing a trust boundary is untrusted input.
Trace context carries exactly what the tracing system needs to reconstruct a tree: two identifiers and a sampled flag. It deliberately carries nothing else, so any information a service wants to attach to a request for downstream use has no home in traceparent. The baggage header is that home, and its design is a study in what happens when a propagation format has to be general-purpose.
The syntax is deliberately boring
The header value is a comma-separated list of members, each of the form key=value, with optional semicolon-delimited properties after each member. Keys are tokens, limited to a restricted character set that excludes whitespace and the delimiters. Values are percent-encoded, so a value containing a comma, a semicolon, an equals sign, or a non-printable byte can be carried without breaking the parse.
tenant=acme-corp,synthetic=true;proto=v2
The percent-encoding is the part that surprises people. A value that looks like a plain string in one service arrives percent-encoded or percent-decoded depending on the library, and a value containing a percent sign must itself be encoded as %25. A round trip through a service that decodes on read and does not re-encode on write corrupts any value containing a reserved character. Since the encoding rules are the same as those for URI components, the safest implementation is to reuse the language’s URI encoding function rather than write one.
Properties are the extension mechanism. A property is a semicolon followed by a key, optionally =value. The specification defines no properties with semantics of its own; they exist so that implementations can annotate members without inventing a new header. A property applies to the member it follows, and a member with no properties is terminated by the next comma.
The specification sets two limits that a platform must be able to handle: it must propagate a baggage list of up to 64 members, and a serialised header of up to 8192 bytes. Beyond either figure a platform may drop members, and which members it drops and in what order is left to the implementation. Those numbers are minimum requirements rather than hard caps, so an implementation may accept more; the binding constraint in practice is the HTTP server’s own header budget, which is often about 8 KB for the entire request header block rather than this one header. A baggage list that grows past a few kilobytes competes with cookies and authorization headers for the same budget, and exceeding the server limit produces a 431 response rather than a tracing problem.
What belongs in it, and what does not
The legitimate uses share a property: the value must be small, must be needed by more than one service, and must not be derivable from anything else in the request. A tenant identifier used for per-tenant routing and quota is a good fit. A synthetic marker that says a request originated from a load test is a good fit, because it lets every service exclude that traffic from its own metrics without each one re-deriving the fact. A sampling rate or a sampling priority is a good fit, since the decision was made upstream and the downstream services need to know it.
The illegitimate uses are more numerous. Putting a user identifier in baggage so that every service can log it is a data protection problem, not a tracing feature: the value travels to every service in the call graph, including third parties, and appears in every proxy log along the way. Putting a request body or a serialised object in baggage exceeds the size budget immediately. Putting a credential in baggage is worse than putting it in a header, because baggage is more likely to be forwarded by default and more likely to be logged.
There is also a category of value that is technically fine and practically harmful: high-cardinality identifiers that end up as span attributes. If every request carries a distinct identifier and every service copies it into a span attribute, the attribute has as many distinct values as there are requests, and any index on it becomes useless. The cardinality problem described in span anatomy applies to baggage-derived attributes as much as to any other.
The trust boundary
This is the part that matters most and is most often skipped.
Every baggage entry arrives from outside the service. The sender may be a legitimate upstream service, a client SDK, a third-party integration, or an attacker. The specification’s security section frames this as information exposure, requiring application owners either to keep sensitive values out of baggage or to ensure the header is absent from requests that cross trust boundaries. Neither of those conditions is about validating what arrives; both are about what is put in.
Consider the common pattern of using baggage to pass an authorization decision: the gateway validates a token and writes authorized=true into baggage, and downstream services trust it. Any client that can reach a downstream service directly, bypassing the gateway, can set that entry itself. The header is not signed, has no integrity protection, and is trivially forgeable by anyone who can make an HTTP request. The same applies to a tenant identifier used for data isolation: if a service reads the tenant from baggage without checking that the caller was entitled to assert it, cross-tenant access follows.
The mitigation is not cryptographic, because there is no key distribution and no chain of custody. The mitigation is architectural: services that act on a baggage value must either re-derive it from an authenticated source or validate it against one. A tenant identifier is safe to use for routing only if the gateway also enforces that the authenticated principal belongs to that tenant. A synthetic-traffic marker is safe only if the marker can only be set by an internal caller, which means the service must be able to tell internal from external callers by something other than the header itself.
A second trust issue is size. A parser that allocates based on the declared length of a header before validating it is vulnerable to a sender that declares a huge value. Bounding the accepted header size, rejecting rather than truncating on overflow, and refusing to parse beyond the limit are the defences. Truncation is the dangerous choice because a truncated list may parse as a shorter valid list, silently dropping the entries at the end, and the receiving service has no way to know.
Propagation and stripping
Baggage is forwarded by the same mechanism as traceparent, which means it inherits the same failure modes described in propagating context across boundaries: an intermediary that forwards a fixed header allowlist drops it, and a service that does not re-inject it on an outgoing call terminates it for everything downstream.
There is an additional requirement that does not apply to traceparent: baggage must be filtered at egress. A service that calls a third-party API should not forward the internal tenant identifier, the internal request identifier, or anything else that reveals internal structure or user data. The default behaviour of most instrumentation libraries is to propagate everything, so the filter has to be configured explicitly, and it has to be configured per destination rather than globally, since the set of values safe to send to one partner differs from the set safe to send to another.
The member cap interacts with filtering in a way worth planning for. A long call chain in which each service adds an entry eventually crosses the 64-member or 8192-byte line, and the specification permits the platform to drop members until the value fits again, without saying which ones. A service that adds an entry and then relies on it being present several hops later is relying on there being room and on the dropping policy being favourable. Keeping the total well below the cap, by removing entries that are no longer needed at each hop, avoids the situation entirely.
Baggage and the trace lifecycle
There is a structural mismatch between baggage and sampling that causes real confusion. Baggage travels with the request regardless of whether the trace is sampled. A service that reads a baggage value and uses it to make a decision is using it whether or not any spans are recorded, so baggage is not a trace-only feature. That is often desirable, since the value may drive routing, and it is also a trap, because an operator may believe a value is only present in sampled traces and be surprised to find it used on every request.
The opposite mismatch occurs at the storage boundary. Baggage entries are not automatically recorded as span attributes. A service that wants a baggage value to appear in the stored trace must copy it explicitly, and the decision to copy it should be made per entry, because copying a high-cardinality value defeats the point of sampling. The trace state header, by contrast, is designed to carry per-trace metadata written by the tracing system itself, and it has its own ordering and size rules that make it the better place for sampling decisions and vendor-specific trace annotations.
The division of labour is therefore: traceparent for identity, tracestate for trace-system metadata that must follow the trace, baggage for application-level values that must follow the request. Mixing the last two produces either a trace that cannot be interpreted after a rate change or an application that depends on a header the tracing system may strip. Keeping the boundary sharp costs a little duplication and saves a class of incidents in which a service silently stops receiving a value it needs.
References
- Propagation format for distributed context: Baggage — W3C
- Trace Context — W3C Recommendation
- RFC 9110: HTTP Semantics — IETF