Configuring CORS for traceparent Headers

Adding traceparent to a cross-origin request makes it preflighted, so the server must answer the OPTIONS request with traceparent (plus tracestate and baggage if used) listed in Access-Control-Allow-Headers — otherwise the browser blocks the real request before it is ever sent.

Context and when it matters

This is the single most common way browser tracing “breaks the site”. The symptom is alarming and misleading: API calls that worked yesterday now fail with a CORS error, and the error message names your API rather than anything to do with tracing. Nothing about the API changed. What changed is that the front end started adding a header, and a request carrying a non-simple header is no longer a simple request.

The browser’s rule is mechanical. A cross-origin request qualifies as simple — sent directly, no preflight — only if its method and headers come from a small allowed set. traceparent is not in that set, so the browser first sends an OPTIONS request asking permission, and only proceeds if the response explicitly grants it.

The preflight sequence

Preflight granted versus preflight denied Two sequences. In the first, the browser sends an OPTIONS request asking to use the traceparent header, the server responds allowing it, and the real GET request proceeds carrying the header. In the second, the server's response omits traceparent from the allowed headers, so the browser blocks the request and the GET is never sent. The real request only happens if the preflight allows the header browser server allowed OPTIONS · Access-Control-Request-Headers: traceparent 204 · Access-Control-Allow-Headers: …, traceparent GET /api/dashboard · traceparent: 00-4bf9…-01 denied OPTIONS · Access-Control-Request-Headers: traceparent 204 · Access-Control-Allow-Headers: content-type blocked in the browser — the GET never leaves

Implementation

nginx

location /api/ {
    # Preflight: answer it here rather than passing OPTIONS to the backend.
    if ($request_method = OPTIONS) {
        add_header Access-Control-Allow-Origin      "https://app.example.com" always;
        add_header Access-Control-Allow-Methods     "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
        # traceparent and tracestate are the W3C pair; baggage only if you use it.
        add_header Access-Control-Allow-Headers     "content-type, authorization, traceparent, tracestate, baggage" always;
        add_header Access-Control-Allow-Credentials "true" always;
        # Without this, EVERY traced API call costs two round trips.
        add_header Access-Control-Max-Age           86400 always;
        add_header Content-Length 0;
        return 204;
    }

    # Actual request: the origin header must be present here too.
    add_header Access-Control-Allow-Origin      "https://app.example.com" always;
    add_header Access-Control-Allow-Credentials "true" always;
    proxy_pass http://api_backend;
    proxy_set_header traceparent $http_traceparent;
    proxy_set_header tracestate  $http_tracestate;
}

Express

const cors = require('cors');

app.use(cors({
  origin: 'https://app.example.com',
  credentials: true,
  // allowedHeaders replaces the default list entirely — omitting content-type
  // here is a classic way to break JSON POSTs while fixing tracing.
  allowedHeaders: ['Content-Type', 'Authorization', 'traceparent', 'tracestate', 'baggage'],
  maxAge: 86400,
}));

The Collector endpoint

The browser posts spans to the Collector cross-origin, so it needs its own CORS configuration. This is separate from the API’s, and forgetting it produces the symptom “backend traces are fine, browser spans never arrive”.

receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins:
            - https://app.example.com
            - https://*.example.com
          allowed_headers: [content-type, traceparent, tracestate]
          max_age: 7200

Envoy

routes:
  - match: { prefix: "/api/" }
    route: { cluster: api_backend }
    typed_per_filter_config:
      envoy.filters.http.cors:
        "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.CorsPolicy
        allow_origin_string_match:
          - exact: "https://app.example.com"
        allow_methods: "GET, POST, PUT, PATCH, DELETE, OPTIONS"
        allow_headers: "content-type,authorization,traceparent,tracestate,baggage"
        max_age: "86400"
        allow_credentials: true

The preflight-caching cost

Access-Control-Max-Age is not an optimisation to consider later; without it, enabling tracing measurably slows your application. Every traced cross-origin call becomes two round trips, and on a high-latency mobile connection that is the difference between a 200 ms API call and a 400 ms one.

What the preflight costs, cached and uncached Three bars for a 180 millisecond round trip. Without trace headers the call takes 180 milliseconds. With an uncached preflight it takes 360 milliseconds on every call. With a cached preflight the first call takes 360 milliseconds and every subsequent call takes 180 again. 180 ms round trip · mobile connection no trace headers 180 ms preflight, no max-age 360 ms · every call preflight, max-age 86400 360 ms · first call only 180 ms · every call after Browsers cap the cache themselves — Chromium at two hours, Safari lower — so the header sets an upper bound, not a guarantee.

Which headers, and on which surfaces

Three headers can be involved, and they are allowed independently. traceparent carries the trace and span IDs and is required for any continuity at all. tracestate carries vendor-specific state and is optional; omitting it from the allow-list silently drops vendor sampling hints while leaving basic continuity working, which produces a subtle and confusing partial failure. baggage carries application key/value pairs and is only needed if you propagate baggage from the browser — and doing so from an untrusted client deserves its own review, since a browser can set any baggage value it likes.

The surfaces that need configuring are easy to under-enumerate. Every API origin the page calls needs it, and if different routes are served by different gateways, each gateway needs it. The Collector endpoint needs its own configuration, because browser spans are posted cross-origin to a different host entirely. Any CDN or edge worker in front of either needs to forward the headers rather than stripping unknown ones — a default-deny header policy at the edge produces exactly the same symptom as a missing CORS configuration, and is harder to find because the origin’s configuration looks correct.

A useful discipline is to treat the allow-list as a single owned artefact rather than as a per-service setting. Keep one list — content type, authorization, the three trace headers, and whatever your application genuinely needs — and apply it identically everywhere. Drift between services is the usual reason one endpoint works and another does not, and diagnosing that from the browser console is unpleasant because the error message is identical in both cases.

Debugging a failing preflight

The browser console names the header that was rejected, and that message is the fastest path to the fix: it tells you exactly which value is missing from Access-Control-Allow-Headers. If the console instead reports that the origin is not allowed, the problem is Access-Control-Allow-Origin and has nothing to do with tracing — a useful distinction, because it means reverting the tracing change will not help.

When the console is ambiguous, reproduce the preflight with curl as shown below and read the response headers directly. A preflight that returns a 200 with no CORS headers at all usually means the OPTIONS request reached the application rather than being answered by the proxy, and the application has no handler for it.

Verification

# Simulate the browser's preflight exactly
curl -i -X OPTIONS "https://api.example.com/dashboard" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" \
  -H "Access-Control-Request-Headers: traceparent"

# Required in the response:
#   HTTP/2 204
#   access-control-allow-origin: https://app.example.com
#   access-control-allow-headers: content-type, authorization, traceparent, ...
#   access-control-max-age: 86400

Check the same against the Collector’s /v1/traces path, and check one route per origin rather than assuming a shared configuration — path-specific CORS rules are common and easy to miss.

Surfaces that each need the headers allowed API origin Access-Control-Allow-Headers *most obvious collector endpoint receiver cors config frequently missed CDN or edge worker forward unknown headers *silently strips second API origin its own config drift between them Surfaces that each need the headers allowed surface setting note API origin Access-Control-Allow-Headers most obvious collector endpoint receiver cors config frequently missed CDN or edge worker forward unknown headers silently strips second API origin its own config drift between them Keep one canonical allow-list and apply it identically everywhere — drift is the usual cause.

Common pitfalls

  • Configuring only the API, not the Collector. Backend traces work, browser spans never arrive, and the console error is easy to overlook among third-party noise.
  • Replacing rather than extending allowedHeaders. Adding traceparent while dropping content-type breaks every JSON request — a worse outage than the one you were fixing.
  • Omitting the CORS headers on the actual response. The preflight passes and the real request still fails, which reads as an intermittent problem.
  • Using a wildcard origin with credentials. Access-Control-Allow-Origin: * is invalid with Allow-Credentials: true; browsers reject the combination.
  • Adding baggage to the client without the server allow-list. Baggage is a separate header with the same preflight consequence — see baggage size limits and header constraints.
  • Assuming a CDN forwards the headers. Many strip unknown request headers by default; check the edge configuration, not just the origin.

One more surface: WebSocket and EventSource

Neither WebSocket handshakes nor EventSource connections can carry a custom header from the browser, which means traceparent cannot be injected on them at all. That is a protocol limitation rather than a CORS problem, and no server configuration fixes it.

The workable substitutes are to pass the context as a query parameter on the connection URL and extract it server-side, or to send it as the first message on the channel and start the server-side span when that message arrives. Both are slightly awkward and both work. What does not work is assuming the connection inherits context from the page that opened it — it does not, and a real-time feature instrumented on that assumption produces spans that are permanently disconnected from the session that created them.

A final operational note: CORS configuration is easy to break during unrelated infrastructure work, because it lives in proxy and edge configuration that other teams also edit. A synthetic check that issues a preflight against one traced route and asserts the allowed-headers response catches that regression the same day it happens, rather than the next time someone opens the browser console.


Related

↑ Back to Browser-to-Backend Trace Continuity