ObservabilityLXXXI · Securing TempoSecureTempo
Tempo Access Control
What you'll learn
- Explain the difference between tenant identification (X-Scope-OrgID) and user authentication in Tempo
- Configure auth_enabled, distributor.limits, and the auth_context block on a production Tempo cluster
- Front Tempo with an authenticating reverse proxy and explain what that proxy owns versus what Tempo owns
- Audit the access path with TraceQL searches and tempo_distributor_ingester metrics
- Diagnose the four most common ACL failure shapes — auth disabled, missing header, anonymous search, and per-tenant budget exhaustion
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
A platform team runs Tempo with auth_enabled: false “for now”
while the Grafana data source is wired up. A new on-call
engineer adds a debugger route that calls the Tempo querier
directly from a CI runner. The CI runner is on a subnetwork
with no egress filtering. The querier accepts the request and
returns every trace in the bucket. A competitor’s pentest team
discovers the open querier endpoint from a public-IP block
list crawl. Within four hours, every trace in the bucket has
been enumerated.
Tempo’s “access control” is two layers: a tenancy layer that limits what an application is allowed to write, and an authentication layer that decides who is allowed to read. The two are not the same. The lesson below is about keeping the two explicit.
What Tempo ACL means
Tempo implements tenancy, not user authentication. The model:
- Tenant. A string identifier that distinguishes one logical
customer’s data from another. Tempo stores blocks under
prefix,operator,tenantin the bucket path. Two tenants’ traces are physically separate on disk in object storage. - Tenant identification. The
X-Scope-OrgIDheader on every OTLP request, Jaeger request, and querier request. The distributor extracts this header (whenauth_enabled: true) and uses it to attribute the data to a tenant. - Per-tenant rate limits. The
distributor.limits.*block caps ingestion rate, burst, trace count, and trace size per tenant. A misbehaving service cannot exhaust the head block budget of the rest of the fleet. - Search scope. TraceQL searches are always scoped to a
single tenant. A request without an
X-Scope-OrgIDheader is rejected whenauth_enabled: true; withauth_enabled: falseit lands in the anonymous tenant and sees all data.
What Tempo does not implement:
- User authentication. Tempo has no users, no roles, no per-user permissions. Whoever reaches the HTTP endpoint with a valid tenant header reads that tenant’s traces.
- User-level rate limits. The per-tenant cap is the only cap. A single tenant with one user and a thousand users consume the same budget.
- Audit log per user. Tempo logs at the tenant level.
The standard shape is therefore: Tempo is in the trusted internal network; user authentication happens at an upstream proxy (Grafana, an OIDC-aware reverse proxy, a service mesh).
Why a sysadmin cares
Three operational pains drive the ACL model.
- Tenant isolation in a multi-team platform. A platform serving many product teams cannot let one team’s traffic starve another’s. The per-tenant rate limit is the lever.
- Search exposure. The querier endpoint
/api/searchand/api/traces/{id}return every trace in a tenant. A misconfigured proxy that omits theX-Scope-OrgIDheader on a request lands in the anonymous tenant. - Credential blast radius. A Tempo querier or API without user authentication in front of it is accessible to any attacker who reaches the network. The Tempo querier is no less sensitive than the application database.
How it works — the request path
Application / OTel Collector
|
v
+------------------------+
| Reverse Proxy | (nginx, Envoy, Grafana)
| - TLS termination |
| - user auth (OIDC) |
| - injects X-Scope-OrgID from session
+------------------------+
|
v
+------------------------+
| Tempo distributor | - extracts X-Scope-OrgID
| | - applies per-tenant limit
| | - rate-limits or drops
+------------------------+
|
v
+------------------------+
| Tempo ingester ring | - per-tenant head block
+------------------------+
|
v
+------------------------+
| Object storage | <prefix>/<tenant>/<block-id>/
+------------------------+
^
|
+------------------------+
| Tempo querier | - reads only the
| | requesting tenant's blocks
+------------------------+
Three things happen at the proxy that Tempo does not do:
- The proxy holds the user identity (the OIDC token, the LDAP principal, the SAML assertion).
- The proxy decides which
X-Scope-OrgIDto inject based on that identity. - The proxy logs the user-to-tenant mapping for the audit trail. Tempo itself logs only the tenant.
The result is that Tempo holds the data side of the ACL (who can write what, who can read what is scoped to the request’s tenant) and the proxy holds the identity side (which user is mapped to which tenant).
How to configure it
Tempo: enable tenancy and per-tenant limits
# /etc/tempo/tempo.yaml
auth_enabled: true # enforces X-Scope-OrgID on every request
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 127.0.0.1:4317 # only the proxy reaches it
http:
endpoint: 127.0.0.1:4318
# The auth context extractor.
auth_context:
extractors:
- name: org-id
from: header
key: X-Scope-OrgID
# Per-tenant limits.
limits:
ingestion_rate_limit_bytes: 10485760 # 10 MiB/sec/tenant
ingestion_burst_size_bytes: 20971520 # 20 MiB burst/tenant
max_traces_per_user: 10000 # default is 10k
max_bytes_per_trace: 5242880 # 5 MiB/trace
Severity: CONFIGURATION. Restart the distributor to apply.
Tempo: search gate
The querier is gated by the same auth_enabled flag. With
auth_enabled: true, a request that omits X-Scope-OrgID
returns 401 Unauthorized. With auth_enabled: false, every
request is treated as the anonymous tenant and returns every
trace in the bucket.
There is no additional search_enabled flag. Search is allowed
when the request is authenticated. There is no per-tenant
search rate limit inside Tempo. The size of a search result is
bounded by max_bytes_per_tag_values and the query-frontend’s
parallelism settings.
Fronting proxy: nginx with OIDC for user auth
# /etc/nginx/sites-available/tempo.conf
upstream tempo_upstream {
server 127.0.0.1:3200 max_fails=3 fail_timeout=10s;
keepalive 32;
}
server {
listen 443 ssl;
server_name tempo.example.com;
ssl_certificate /etc/letsencrypt/live/tempo.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tempo.example.com/privkey.pem;
# Subrequest to the auth service; X-Scope-OrgID is injected from
# the response.
auth_request /auth-verify;
auth_request_set $tenant $upstream_http_x_tenant;
location / {
proxy_set_header X-Scope-OrgID $tenant;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://tempo_upstream;
}
location = /auth-verify {
internal;
proxy_pass http://auth-svc/oauth2/verify;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
The auth-verify subrequest consults an OIDC introspection
endpoint. The X-Tenant response header carries the tenant
mapping; nginx injects it as X-Scope-OrgID on the way to
Tempo.
Grafana: Tempo data source with a tenant header
In Grafana 11.x, the Tempo data source can be configured with a custom HTTP header. The Grafana user-to-tenant mapping is a separate piece:
# /etc/grafana/grafana.ini
[experimental_feature_annotations]
# (no relevant setting here; the Tempo data source uses the
# custom HTTP header under "URL and authentication" in the UI.)
In the Grafana UI: Tempo data source → Custom HTTP Headers →
add X-Scope-OrgID. The value is templated per-datasource;
typically, one data source per tenant. For a single-tenant
platform, leave the value as the literal tenant ID.
How to validate it
Severity: READ-ONLY.
# 1. Confirm Tempo is enforcing tenancy.
curl -fsS http://tempo:3200/api/traces/00000000000000000000000000000000
# {"errors":[{"code":"...","message":"..."}]}
# Without X-Scope-OrgID and auth_enabled: true, a non-existent
# trace returns 404; a malformed request without the header
# returns 401.
# 2. Confirm a request without X-Scope-OrgID is rejected.
curl -si http://tempo:3200/querier/api/search?limit=1 | head -1
# HTTP/1.1 401 Unauthorized
# 3. Confirm a request with X-Scope-OrgID succeeds.
curl -fsS -H 'X-Scope-OrgID: tenant-a' \
"http://tempo:3200/api/search?limit=1" | jq '.traces | length'
# 0 (no traces, but the request authenticated)
# 4. Confirm a different tenant gets a different result set.
curl -fsS -H 'X-Scope-OrgID: tenant-a' \
"http://tempo:3200/api/search?q={ resource.service.name = 'checkout' }" \
| jq '.traces | length'
curl -fsS -H 'X-Scope-OrgID: tenant-b' \
"http://tempo:3200/api/search?q={ resource.service.name = 'checkout' }" \
| jq '.traces | length'
# Two different counts (or both zero); the search is per-tenant.
# 5. Check the per-tenant rate limit counters.
curl -s http://tempo:3200/metrics | \
grep -E 'tempo_distributor_ingester_(spans_received_total|requests_in_flight)'
# tempo_distributor_ingester_spans_received_total{orgId="tenant-a"} 1943201
# tempo_distributor_ingester_spans_received_total{orgId="tenant-b"} 812113
# The orgId label differentiates per-tenant counts.
A clean validation: an unheaded request is rejected with 401,
tenanted requests succeed and return only that tenant’s data,
the metric labels include the orgId for per-tenant accounting.
How it can fail
Four recurring shapes from real audits.
auth_enabled: falsereaches production. A copy-paste of a development config into the Helm chart; the flag stays off. Symptom: every request, including requests from unmapped networks, returns data. Fix: enforce the flag in CI; reject a config that lacksauth_enabled: true.X-Scope-OrgIDis missing because the proxy is broken. The auth-verify upstream returns a non-200; nginx falls through to a default. Symptom: Tempo logs rate-limit counters with the empty tenant; data from many users lands in the same folder. Fix: the auth-verify endpoint returns401for anonymous users; the data side sees only authenticated tenants.- Anonymous search via Grafana’s unauthenticated UI page.
Grafana has its own auth, but a data source without a tenant
header sends nothing. Symptom: a Grafana viewer sees
“no traces” because Tempo rejects the empty-tenant request,
but the silence is a UX bug rather than a security
control. Fix: the Grafana data source always sets
X-Scope-OrgID. - A tenant exhausts the per-tenant budget. A misbehaving
service emits at 100 MiB/sec; the rate limit caps the
tenant at 10 MiB/sec; the service’s data is throttled.
Symptom:
tempo_distributor_dropped_spans_totalrises for that tenant; the service’s own metrics show 90% of spans not landing in Tempo. Fix: raise the limit deliberately, or fix the service’s emitter.
How to troubleshoot it
The diagnostic order for “auth is failing”:
- Is
auth_enabled: true?curl -s http://tempo:3200/status | jq .confirms the service is up. The flag is in the config; the read order is the distributor’s startup log. - Does the request carry
X-Scope-OrgID?tcpdumpon the Tempo port, or the proxy’s access log with$sent_http_x_scope_orgidprinted. A missing header points at the proxy. - Does the proxy authenticate the user? Hit
auth-verifydirectly with a known token. A non-200 there is an upstream auth issue, not a Tempo issue. - Are tenants correctly attributed? Compare
tempo_distributor_ingester_spans_received_totalbyorgIdlabel across tenants. A flat distribution across thousands of “tenants” indicates a header injection bug. - Is a tenant throttling?
tempo_distributor_dropped_spans_totalis the signal; the audit grep against the service’s own metrics confirms it.
Security implications
- Authentication vs authorisation. Tempo authenticates the tenant. It does not authorise the user. The two live in the proxy; the proxy owns the user identity, Tempo owns the data boundary.
auth_enabled: trueis the floor. A production Tempo without it is a regulatory event waiting to happen.- Per-tenant limits prevent starvation. A noisy neighbour cannot starve the rest of the fleet; the ingester ring stays healthy.
- TLS belongs at the proxy. Tempo loopback-binds the OTLP ports; the proxy is the only thing that needs a certificate.
- Audit is at the proxy. Tempo logs at the tenant level. A Grafana-side audit log records who clicked which dashboard. The two together answer “what did this user see?”
Performance implications
- Tenant extraction cost. The distributor parses one header per request; this is microsecond cost. Negligible.
- Per-tenant limits. The rate limit decision is a single atomic increment in the per-tenant bucket. Sub-microsecond.
- Querier per-tenant scan. The querier lists blocks only under the tenant folder; a multi-tenant deployment does not pay for a global scan even when one tenant requests a search.
- gRPC vs HTTP/1. Per-request overhead is identical; both
receive the same
X-Scope-OrgIDextraction.
Production guidance
- Set
auth_enabled: true. No exception for production. CI rejects configs that lack it. - Front Tempo with an OIDC-aware proxy. Grafana data sources or a standalone reverse proxy. The proxy enforces user auth and injects the tenant header.
- Set per-tenant limits deliberately. A default of 10k in-flight traces per tenant is reasonable for many services and small for high-rate producers. Right-size per tenant.
- Audit the proxy. The audit log is at the proxy, not in Tempo; capture both sides for incident review.
Verification
You should now be able to answer:
- What is the difference between tenant identification and user authentication in Tempo?
- Which block in
tempo.yamlenforces the per-tenant rate limit, and what does it cap? - Why does the proxy need to be OIDC-aware in addition to
Tempo enforcing
auth_enabled? - What does a request without
X-Scope-OrgIDreturn whenauth_enabled: true? - Which Tempo metric exposes per-tenant span counts?
Quiz
Knowledge check · 8 questions
Q1. What does Tempo authenticate at the distributor boundary?
Q2. Setting auth_enabled: false in a production Tempo cluster is acceptable so long as Tempo is on a private network.
Q3. Which per-tenant limits does the Tempo distributor enforce? (select all that apply)
Q4. A TraceQL request reaches Tempo without an X-Scope-OrgID header and auth_enabled is true. What does Tempo return?
Q5. Name the Tempo config flag that enforces tenant identification on every request.
Q6. A noisy neighbour consumes the entire per-tenant budget. What is the user-visible symptom?
Q7. Tempo has a built-in role-based access control model with users and roles.
Q8. Which layers are responsible for which access decision in a production Tempo deployment? (select all that apply)
Passing score: 75%. Answers are checked in this browser.