# Security and privacy

> What InfraInbox stores, what it encrypts, what leaves the server, and how sign-in is protected.

Web page: https://infrainbox.app/docs/self-hosting/security/

## What's encrypted at rest

Every secret InfraInbox has to read back later — destination credentials (a Telegram bot token, a Discord or Slack webhook URL, a signed webhook's signing secret), TOTP seeds, push keys — is encrypted with AES-256-GCM before it reaches the database. Each value gets its own randomly generated data key; that data key is itself wrapped under the server's master key, and the ciphertext is bound to the exact row and column it lives in, so a blob copied into another row, another field or another workspace fails to decrypt.

The master key never lives in the database. By default it's generated on first start and stored as a single file (`master.key`) inside the `infrainbox-data` volume, mode `0600`; you can supply your own instead (see [Configuration reference](https://infrainbox.app/docs/self-hosting/configuration.md#the-master-key)). A short HMAC "key-check value" stored in the database lets the server detect a wrong or missing key at start-up, without storing anything that would let someone recover the key itself. See [Backups and upgrades](https://infrainbox.app/docs/self-hosting/backup-and-upgrade.md#rotating-the-master-key) for rotating it.

This protects a stolen database dump or backup — someone with only the SQL dump learns nothing about your destination credentials. It doesn't protect against a compromised, running server: a process that already holds the master key in memory can decrypt what it needs to send notifications.

> **Note:** A database backup is only useful together with the `infrainbox-data` volume that holds the master key. See [Backups and upgrades](https://infrainbox.app/docs/self-hosting/backup-and-upgrade.md).

## What leaves the server

InfraInbox doesn't phone home. There's no telemetry, no analytics and no update check that reports anything about your instance anywhere. The only outbound traffic it ever makes is:

- to the destinations you configure yourself (Telegram, Slack, Discord, a webhook URL you set, and so on);
- to InfraInbox's push relay, if you use the mobile app and haven't disabled it — see [Push notifications](#push-notifications) below for what the relay can and can't see;
- to an OpenTelemetry tracing endpoint, only if you explicitly configure one.

## Push notifications

A push notification's payload is end-to-end encrypted with the Web Push standard (RFC 8291): it's addressed and encrypted for the paired device's own key before it ever leaves InfraInbox. Whichever path carries it there — InfraInbox's own relay (for the App Store build, which can't hold a direct connection to Apple's push service itself) or Apple's push service directly, for a self-built client — only sees encrypted bytes and the minimum routing information the platform requires. It cannot read the incident title, body or any other content.

## Outgoing connections and SSRF protection

Whenever InfraInbox makes an outbound request on your behalf — a webhook, a chat provider, a push endpoint, a "send test" — it goes through one guarded HTTP client that checks the resolved IP address before every connection, not just the hostname (so DNS rebinding and decimal/octal/hex address tricks don't bypass it):

| Destination | Self-hosted | Cloud |
|---|---|---|
| Public addresses | Always allowed | Ports 80/443 only |
| Private (RFC 1918) addresses | Always allowed | Never |
| Carrier-grade NAT (`100.64.0.0/10`), IPv6 unique local (`fc00::/7`) | Only if you opt in (`outbound.allow_cgnat` / `outbound.allow_ula`) | Never |
| Loopback, link-local, multicast, cloud metadata addresses (`169.254.169.254` and equivalents) | Never, under any setting | Never |

This is why a webhook or chat destination pointed at a private IP works out of the box on a self-hosted instance (many homelabs send notifications to something on the same LAN), while cloud metadata endpoints and the loopback interface stay blocked regardless of any setting. Redirects are never followed automatically, and every outbound request has its own timeout and a capped response size.

## Sessions

Signing in to the dashboard sets a single session cookie, `__Host-iib_session` — the `__Host-` prefix means the browser only ever sends it back to this exact server (no path, no subdomain can plant or read it), and it's `Secure`, `HttpOnly` and same-site strict. The session token itself is a random 256-bit value; only its SHA-256 hash is stored, so a stolen database row can't be turned back into a usable cookie. A session lasts up to 30 days from sign-in, or 7 days since it was last used, whichever comes first, and you can revoke individual sessions or sign out everywhere from the dashboard.

Every state-changing request that carries the session cookie is checked against the browser's own `Sec-Fetch-Site`/`Origin` headers before it's allowed to run, so a forged request from another site — even one on the same parent domain — is rejected before it can act on your account.

Signing in through an identity provider keeps its state server-side: the browser holds only a short-lived flow cookie naming the flow, deleted as soon as the flow ends. An OpenID Connect ID token is accepted only if its signature, issuer, audience, expiry and nonce all check out, and the account is found by the provider's subject, never by email. The session records which provider it came through, and so does the audit log's sign-in entry.

## Two-factor authentication

InfraInbox supports TOTP (RFC 6238, 30-second codes, the same kind of authenticator app used everywhere else). Confirming enrollment issues ten single-use recovery codes; only their hashes are stored, and each is invalidated the moment it's used. An administrator can require a second factor instance-wide, or just for workspace owners and admins — see `auth.require_mfa` in the [configuration reference](https://infrainbox.app/docs/self-hosting/configuration.md#authentication).

## Roles

Inside a workspace, a member has one of three roles — **owner**, **admin** or **member** — each with more write access than the last; every role can read the inbox, sources, incidents and rules. Changing workspace settings, routing rules, destinations or retrying a delivery needs the admin or owner role. Separately, an **instance administrator** is a server-wide account (created at first-run setup, or with `infrainbox admin create`) used for recovery and cross-workspace administration, distinct from any workspace's roles.

## Source and heartbeat keys

An ingest key (`iik_src_…`) or heartbeat key (`iik_hb_…`) is a single random value with about 190 bits of entropy. It's never stored in a form that could be read back — only its SHA-256 hash, in a database column looked up by that hash. There's deliberately no additional slow hashing (Argon2, bcrypt) on these: at that much entropy it adds no real protection, and hashing every single ingest request with a slow algorithm would turn each one into a CPU/memory cost an attacker could use to overload the server. A leaked key can be rotated from the source's or monitor's page without touching anything else.

## Tenant isolation

Every workspace-scoped table carries the workspace on the row, every query filters by it, and a linter fails the build on a query that does not. A cross-tenant reference cannot even be stored: the foreign keys are composite, on `(workspace, id)`. Under all of that, PostgreSQL's own row-level security is a backstop: the login the API runs as is not the tables' owner, each request's transaction declares the workspaces its credential reaches, and a policy on every one of those tables limits the rows it can see or write to that set — so a query that lost its filter returns nothing rather than somebody else's rows. Background work (sweeps, delivery, the retention purge) crosses workspaces by design, through its own role and policy; no role can bypass row-level security altogether.

## Deleting an account or a workspace

An owner can close a workspace, and anyone can close their own account, from **Settings**; both ask you to re-authenticate and to type the name back first. Closing takes effect with the answer: the memberships go, the workspace's ingest and heartbeat keys stop working, and nothing more is sent for it. The rows themselves are removed by the background purge — within 30 days at the outside, in practice within the hour — and backups age out within 35 days. There is no undo, so **export the workspace first** (Settings → the workspace's page) if you want the data: the export is one JSON file with the workspace's sources, monitors, rules, destinations, incidents and events, and no secrets in it.

A workspace that has other members refuses to go with a departing account: its last owner is told to hand it over or empty it first, so nobody loses their data to somebody else's leaving. The last instance administrator cannot close their account either.

## Secrets never in logs

Every credential type in InfraInbox — ingest keys, session tokens, destination secrets, the master key — is wrapped in a type that refuses to print or serialize its own value; an accidental `%v` or a JSON encoding of it comes out as a redacted placeholder, not the secret. Error messages that touch a key or a URL with embedded credentials are built without ever including the value itself. The bundled Caddy configurations (see [TLS and reverse proxies](https://infrainbox.app/docs/self-hosting/reverse-proxy.md)) mask the same secret shapes — the `/hooks/{integration}/{secret}` path and API key headers — in their own access logs, since a proxy in front of InfraInbox logs the request before InfraInbox ever sees it.

## Security headers and CSP

Every response carries `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and `Strict-Transport-Security` once you're on HTTPS. The dashboard itself is served under a strict Content-Security-Policy with no `unsafe-inline` and no `unsafe-eval`: scripts and styles are allowed only with a fresh per-request nonce, framing from another site is refused outright (`frame-ancestors 'none'`), and DOM-based script injection is additionally constrained by Trusted Types. There's no CORS header anywhere on the API — a browser on another origin can't even attempt a cross-origin call, cookie or not.

## Reporting a vulnerability

Found a security issue? Please report it privately to **[security contact]** rather than opening a public issue, so a fix can go out before the details do.
