---
title: Security
description: Data sensitivity, receiver authentication, secrets handling, and operational footguns
tags:
  - Security
---

# 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`](https://github.com/rknightion/tailscale2otel/blob/main/config.example.yaml):

- `cardinality.flow.source_port` / `cardinality.flow.destination_port` (both default `false`)
  - keep ports **off** flow *metrics*. Ports are always present on flow *logs* regardless of
  these settings.
- `cardinality.flow.collapse_external` (default `true`) - buckets unresolved IPs as
  `external`/`unknown` rather than emitting them as distinct series/labels.
- `cardinality.flow.node_dims` (default `true`) - set `false` to omit src/dst device names
  from flow metrics.

!!! warning "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.

!!! warning "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](../configuration.md#pii_filter-pii-identifier-redaction). 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](../configuration.md#snapshot-collectors). 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 (`> 0` means 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" > 0` means 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 by `srcPosture`.

See [Alerts](../alerts.md) 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` (default `true`) → `api.github.com/repos/rknightion/tailscale2otel/releases/latest`
- `version_checks.devices.enabled` (default `true`) → `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`](https://github.com/rknightion/tailscale2otel/blob/main/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.token` empty likewise **refuses** the status page and its JSON APIs with
  HTTP 403 on any non-loopback `admin.listen` (`/healthz` and `/readyz` stay open).
- Leaving `prometheus.auth.token` empty likewise serves `/metrics` only on a loopback bind. On a
  network-reachable bind it returns HTTP 403 unless
  `prometheus.auth.allow_unauthenticated: true` explicitly acknowledges the exposure.
- Every admin response carries `Content-Security-Policy`, `X-Content-Type-Options: nosniff`,
  `Referrer-Policy: no-referrer` and `Cache-Control: no-store`, applied at the mux so a route added
  later cannot be served bare. The CSP starts with `default-src 'none'` and permits no external origins.
  Same-origin fonts and status polling use `font-src 'self'` and `connect-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 in `html/template`, not the CSP, is what
  stands between tailnet data and the DOM. `Strict-Transport-Security` is sent **only** when
  `admin.tls` is configured: on a plaintext listener a browser that saw it once would refuse
  `http://` to that host and port and lock you out of your own page.

!!! warning "The in-memory flow view is not covered by `pii_filter`"
    [`/flows`](../flow-view.md) 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.

!!! danger "Persistent flow storage writes identities to disk"
    That "never leaves the process" reasoning holds only for the in-memory default. Setting
    [`flows.store.directory`](../configuration.md#flowsstore-opt-in-persistent-backend) 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.

!!! danger "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.

!!! warning "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

!!! danger "`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 `*Set` booleans (e.g.
  `webhook_secret_set`) and OTLP header key names, never the values themselves.
- Secrets are **never logged**.

!!! note "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](../configuration.md) for the full config reference and
[env-vars.md](../env-vars.md) for the complete list of `TS2OTEL_*` environment variables.
