Security¶
tailscale2otel handles network metadata and credentials. This page describes what leaves the process, what stays in local storage, and how to restrict access.
Telemetry payload sensitivity¶
tailscale2otel exports network metadata about your tailnet. Flow logs and audit logs carry, among other things:
- source and destination IP addresses and ports,
- device names and hostnames,
- user identities (e.g. the actor on an audit event).
Enabled OTLP signals go to the configured backend; Prometheus exposes metrics and stdout prints telemetry locally. PII categories default to retention. Set a category to false to redact it. Treat the OTLP backend as a trusted data sink - anyone with read access to it can see this metadata. Scope backend credentials accordingly.
Levers to reduce what leaves the tailnet (all under the cardinality: block in config.example.yaml:
cardinality.flow.source_port/cardinality.flow.destination_port(both defaultfalse)- keep ports off flow metrics. Ports are always present on flow logs regardless of these settings.
cardinality.flow.collapse_external(defaulttrue) - buckets unresolved IPs asexternal/unknownrather than emitting them as distinct series/labels.cardinality.flow.node_dims(defaulttrue) - setfalseto omit src/dst device names from flow metrics.
Disabling devices does not remove IPs
Disabling the devices collector does not remove IP addresses from the exported payload - it only degrades IP→name enrichment, so flow/audit records fall back to unknown/external for names while the raw addresses are still exported.
Traces export a third, unaggregated stream
When tracing.enabled: true, spans are also exported to the OTLP backend. Unlike metrics, spans are unaggregated: useful identifiers such as the tailnet name and device IDs appear on the url.full attribute of tailscale.api spans by design. Auth headers, tokens, and response bodies are never attached, and per-record source/destination IPs are not placed on receiver spans. Account for this extra stream when scoping data residency or export policies; tracing is off by default.
How pii_filter decides - the guarantees¶
The per-category toggles are documented in configuration.md. Three properties of the redactor matter when you are reasoning about what can escape once a category is turned off. None of them change anything in the default (all-enabled) configuration, which stays byte-identical.
Unclassifiable IP values fail closed¶
The three IP categories (tailscale_ips, internal_ips, external_ips) are chosen by range-classifying the address itself, not by trusting the attribute name. Before classification a value is normalized: surrounding whitespace is trimmed, a host:port or [host]:port form is reduced to its address, and an IPv4-mapped IPv6 address is unmapped - so 100.64.0.1, ::ffff:100.64.0.1, ::ffff:6440:1 and [::ffff:100.64.0.1]:41641 are all the same CGNAT address and are all gated by tailscale_ips. A disabled category cannot be bypassed by rewriting the address's textual representation.
For an attribute whose value is only ever an IP (source.address, destination.address, tailscale.dns.resolver.address), a non-empty value that will not parse as an address has an unknown category. While any IP category is disabled such a value is dropped, not emitted: the filter fails closed rather than guessing. The rejected text is never logged or echoed anywhere - only the drop decision leaves the redactor. Mixed attributes that legitimately hold a name (tailscale.node, tailscale.exit_node, tailscale.src.node, tailscale.dst.node) are unaffected: a non-IP value there is a hostname by design and stays governed by hostnames.
Span status descriptions follow the free-text policy¶
A span's status description is free text written by whatever failed - a collector error string, a recovered panic value, an upstream HTTP error. It is governed by free_text_details, the same category as the exception.message that usually carries the identical text. With free_text_details: false, a status description is replaced with [redacted] unless it is a bounded, code-defined class - a receiver's fixed reject reason (method not allowed, corrupt batch, bad_signature, …) or an HTTP status text (Too Many Requests) - which carries no free text and is kept so failures stay distinguishable.
The span's status code stays Error, so a failed span is still visibly failed, and the scrape span additionally carries a bounded error.type attribute (panic | timeout | error) naming the failure class. With free_text_details: true (the default) descriptions are unchanged, except that an identifier from some other disabled category is still scrubbed out of them - e.g. endpoint_paths: false removes the API URL from the description and from error events.
Body redaction is deterministic¶
When a disabled-category value is removed from a log body or a status description, the replacement is a single left-to-right pass that always takes the longest matching value first. Overlapping values therefore cannot leave a partial remnant (removing a short value first must not strip a prefix off a longer one and leave its tail behind), output does not depend on Go map iteration order, and the [redacted] marker itself is never re-matched.
ACL policy hygiene¶
The acl collector scores the tailnet policy for structural risk on each tick (no extra API call: it reuses the policy it already fetches). The risk gauges emit bounded structural counts with enum labels. Separately, collectors.acl.snapshot_enabled opts into raw policy snapshots and diffs. Those bodies bypass pii_filter and may contain identities or policy details; see the snapshot configuration. The risk gauges are:
tailscale.acl.wildcard_rules- rules with a*source or destination (by section/position).tailscale.acl.unrestricted_rules- any-to-any non-deny rules (> 0means at least one rule matches any source to any destination).tailscale.acl.autoapprovers- auto-approver depth by kind (routes/exit_node/services);autoapprover_kind="exit_node" > 0means exit-node auto-approval is configured.tailscale.acl.ssh_wildcard- Tailscale SSH rules with a wildcard source or destination.tailscale.acl.posture_gated_rules- rules gated bysrcPosture.
See Alerts for rules using these signals.
Audit change tracking¶
In addition to the raw tailscale.config.audit.events counter (action + origin), a curated tailscale.config.audit.changes counter emits only security- and lifecycle-relevant events, categorized by the tailscale.audit.change attribute (Prometheus label tailscale_audit_change; values include acl, key_expiry, network_flow_logging, exit_flow_logging, machine_approval, user_approval, key_duration, file_sharing, https, scim, webhook_subscription, security_contact, tailnet_lock, user_role, auth_provider, secret, device, api_key, posture_integration, magic_dns, and dns_config) and actor.type. It is purpose-built for alerting on high-value change categories (and device churn) without the noise of the full audit stream.
Outbound version checks¶
By default tailscale2otel makes two unauthenticated outbound HTTPS GET requests to power its update-available and version-skew signals:
version_checks.self.enabled(defaulttrue) →api.github.com/repos/rknightion/tailscale2otel/releases/latestversion_checks.devices.enabled(defaulttrue) →pkgs.tailscale.com/stable/?mode=json
No tailnet data is sent in either request. Set both to false in air-gapped or egress-controlled environments.
Receiver authentication footguns¶
The optional streaming (Splunk-HEC) and webhook receivers accept inbound POSTs, and the optional Prometheus listener serves GET /metrics. Authentication is enabled by a configured secret; an empty credential is accepted only on loopback (these behaviours are also noted in config.example.yaml:
- A network-reachable webhook listener requires a secret on every configured route. Missing credentials fail startup validation. A loopback listener may omit the secret and skips HMAC verification, so any local process can submit events.
A network-reachable HEC listener likewise requires a token on every route and fails startup validation without one. The receiver handlers also reject unauthenticated network requests with HTTP 403 if constructed outside the normal validated startup path.
Leaving
admin.auth.tokenempty likewise refuses the status page and its JSON APIs with HTTP 403 on any non-loopbackadmin.listen(/healthzand/readyzstay open).- Leaving
prometheus.auth.tokenempty likewise serves/metricsonly on a loopback bind. On a network-reachable bind it returns HTTP 403 unlessprometheus.auth.allow_unauthenticated: trueexplicitly acknowledges the exposure. - Every admin response carries
Content-Security-Policy,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrerandCache-Control: no-store, applied at the mux so a route added later cannot be served bare. The CSP starts withdefault-src 'none'and permits no external origins. Same-origin fonts and status polling usefont-src 'self'andconnect-src 'self'.frame-ancestors 'none'keeps the mutating rDNS purge out of a frame. Scripts and styles are'unsafe-inline'- both pages are one inline bundle and use inline event handlers, which a nonce does not cover - so contextual escaping inhtml/template, not the CSP, is what stands between tailnet data and the DOM.Strict-Transport-Securityis sent only whenadmin.tlsis configured: on a plaintext listener a browser that saw it once would refusehttp://to that host and port and lock you out of your own page.
The in-memory flow view is not covered by pii_filter
/flows shows device names, addresses and users in full. pii_filter governs the telemetry this process exports, not what an authenticated administrator may look at locally, and by default the flow store is in memory and never leaves the process. The admin token is the only thing protecting that data - if the set of people holding it is wider than the set who may see your users' email addresses, narrow the token rather than the filter.
Persistent flow storage writes identities to disk
That "never leaves the process" reasoning holds only for the in-memory default. Setting flows.store.directory writes flow rows to a SQLite database that survives restarts and lands in whatever backs up that volume, so the admin token stops being the only control - filesystem and backup access now read the same data.
Because that data does leave the process, the persistent path does apply pii_filter before writing, unlike the in-memory ring. A category you have turned off never reaches the file. What you leave enabled is retained on disk for up to flows.store.retention (default 30 days). Scope access to that directory and its backups accordingly.
Always set credentials before exposing a receiver
Always set these when exposing a receiver, especially on a wildcard/all-interfaces bind or without TLS. Tailscale requires HTTPS for public streaming sinks and webhook destinations. Use the paired streaming.tls.* or webhook.tls.* files for native TLS, or leave them empty behind an HTTPS reverse proxy. A tailscale cert works for private tailnet endpoints.
Check credential settings after an environment-variable warning
An unknown TS2OTEL_* name produces a startup warning and leaves the intended key unchanged. Missing stream or webhook credentials on network binds fail validation in v5. Admin and Prometheus listeners keep their request-time authentication checks; a typo can leave them returning 403. Check the effective configuration before exposing a listener.
streaming.auto_configure footgun¶
auto_configure overwrites your existing log-streaming sink
When streaming.auto_configure: true, the service registers this receiver as the tailnet's log-streaming sink on startup, and overwrites any existing sink configured for the tailnet. Never enable it against a tailnet whose streaming configuration you do not intend to replace. It is off by default.
Secrets handling¶
- Keep secrets in
TS2OTEL_*environment variables (the env layer overrides the file), never as literal values in YAML.config.local.yaml,.env.local, and.secrets/are gitignored for this reason. - The admin status page redacts secret values - it emits only
*Setbooleans (e.g.webhook_secret_set) and OTLP header key names, never the values themselves. - Secrets are never logged.
pprof requires admin auth
Enabling profiling.pprof.enabled requires both admin.enabled and admin.auth.token, because heap and goroutine dumps can expose in-memory secrets.
See configuration.md for the full config reference and env-vars.md for the complete list of TS2OTEL_* environment variables.