Skip to content

Security Model

Every trust boundary Sparrow crosses — inbound API, outbound delivery, and data at rest — and what protects it. For envelope encryption at rest and outbound delivery signing, see Architecture: Encryption at Rest and Verifying Webhook Signatures. For TLS, SSO, and per-user login in front of Sparrow, see Securing Sparrow.

The master key (SPARROW_API_KEY) or a per-person access token gates every inbound /v1 request. Credentials travel in X-API-Key or Authorization: Bearer, never a query parameter — URLs get logged by proxies, kept in browser history, and leaked via Referer. Key and token comparison is constant-time, so a wrong guess takes the same time regardless of how many characters match, defeating timing side-channel attacks.

Empty SPARROW_API_KEY disables auth entirely — intentional for local dev. With ENVIRONMENT=production, the server refuses to boot without a key, so this can’t ship silently misconfigured.

Named, stored credentials that can be created, listed, and individually revoked. See Access: Tokens and Invites for the operational guide; this section covers the implementation.

  • Hashing. Only a SHA-256 hash of the secret is stored. The plaintext is returned once, in the creation response.
  • Prefixes. Sparrow tokens start with sparrow_tk_; invite secrets with sparrow_inv_. The prefixes make leaked secrets easy to spot in log scans and secret-scanning tools.
  • Scopes. A token is either tenant-wide (nil scope — same power as the master key) or pinned to one consumer (non-nil scope — portal API only, same allow-list as portal tokens). Consumer-scoped tokens get 403 on /v1; full-access tokens get 403 on /portal/api.
  • Cache. Successful lookups are cached for 30 seconds to avoid hitting the database on every request. Revocation clears the cache synchronously on the revoking instance; other instances honour it when their cache entry expires (within 30 seconds).
  • last_used_at. Updated at most once per minute per token to reduce write load.
  • 401 reasons. On failure, the JSON body includes a reason field: missing (no credential), invalid (unknown key or token), expired, or revoked.
  • 503 on DB outage. If the token store is unreachable, the server returns 503 Service Unavailable with Retry-After: 5, not 401. Browsers keep their credential and retry. The master key (a root key checked in memory) still works without the database.
  • Invites. A single-use, expiring secret redeemed at POST /invite/redeem (no auth required — the invite is the credential). Redeeming creates a token; unknown, expired, cancelled, and used invites all return 400 invalid_invite.

The one scoped exception to Sparrow’s instance-wide auth model: POST /v1/tokens with a consumer (admin key or tenant-wide token required) mints an expiring, revocable bearer token for one consumer — the credential behind the embeddable consumer portal, returned together with a ready-made portal_path. It is an ordinary access token (see above): 256 random bits, stored only as a SHA-256 hash, listed by GET /v1/tokens and revoked with DELETE /v1/tokens/{id}. With external_id (consumer tokens only), a repeat call returns the still-valid token for that consumer and external id instead of minting one, so the secret of such a token is also stored, envelope-encrypted with the keyring (and deleted on revocation). Consumer tokens default to a 7-day / maximum 30-day TTL, and travel in the Authorization: Bearer header — or the /portal#token=... URL fragment, which browsers never send to the server, so it stays out of access logs.

A portal token authorizes exactly: everything under its own consumer’s routes except pushing events and minting further tokens, plus the read-only helpers the portal UI needs (event-type catalog, template functions, template dry-run). The portal calls these through the single /portal/api/ gateway prefix — the consumer is carried by the token, not the URL, so cross-consumer access is structurally impossible and event injection, token minting, other consumers, global routes, and admin operations all return 401/403.

Sparrow makes outbound HTTP requests to user-supplied URLs — the classic server-side request forgery surface. Every URL is checked at three points:

  • Registration — only http/https schemes; well-known internal hostnames (localhost, *.internal, *.local, cloud metadata hosts) are rejected outright; everything else is resolved and every returned IP is checked against the blocklist, not just the literal host string.
  • Every redirect — each redirect target is re-validated before it is followed, so a webhook can’t register a public URL that 302s to an internal one. Redirect chains are capped at 10 hops.
  • Connection time — the resolved IP is checked again immediately before the TCP connection is opened, on every delivery. A DNS record that changes after registration (DNS rebinding) still can’t steer a delivery to a blocked address.

Blocked ranges: loopback (127.0.0.0/8, ::1), private (10/8, 172.16/12, 192.168/16), link-local (169.254.0.0/16 — covers the 169.254.169.254 cloud metadata endpoint), CGNAT (100.64.0.0/10, RFC 6598), and IETF special-use/multicast/reserved ranges (RFC 6890).

Deliveries verify the receiver’s TLS certificate. A webhook can opt out with http_config.verify_ssl: false, for trusted internal endpoints with self-signed certificates; it defaults to true, and the SSRF checks above still apply to opted-out webhooks. (Before v0.5.17 this flag was accepted but ignored, so certificates were always verified. Webhooks that explicitly set it to false now skip verification, as requested.)

Defense-in-depth headers on every API and UI response:

HeaderValueStops
X-Content-Type-OptionsnosniffBrowser MIME-sniffing a JSON response as HTML — reflected XSS
X-Frame-OptionsDENYClickjacking — the UI is never legitimately framed
Referrer-Policystrict-origin-when-cross-originLeaking consumer/webhook IDs in URLs to an external Referer
Permissions-Policyinterest-cohort=()FLoC / Topics API tracking
Content-Security-Policydefault-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'Loading any script, style, or image from a non-Sparrow origin other than the UI’s Google Fonts ('unsafe-inline' is required by the embedded UI’s runtime config and inline styles). Applies only when Sparrow serves the UI; a separately hosted UI gets whatever headers its static host sets

Two more request-level controls:

  • Body size cap: every request body is capped at SPARROW_MAX_BODY_BYTES (default 5 MiB) — bounds memory use against oversized payloads before they’re read. Values below 1 MiB are rejected at startup.
  • Per-webhook rate limiting: optional leaky-bucket rate_limit_rps per webhook throttles outbound delivery — protects the receiver from being hammered. (It does not protect Sparrow’s own inbound API; see the note under API Authentication.)

The plaintext webhook secret (whsec_...) is returned exactly once, in the webhook-creation response — copy it down then. Every later read (list, get, update) returns only its shape: the whsec_ prefix plus one • per secret character. The stored secret is decrypted server-side solely to compute that mask; the plaintext never appears in any response again. If a stored secret can’t be decrypted, the response degrades to a fixed •••••• instead of erroring or revealing which webhooks are affected.

Secret headers are stricter: their values never round-trip at all, masked or otherwise. Every configured header comes back as a flat •••••• regardless of the original value’s length — only the header name is real, so a reader can’t even infer secret length from the mask.

A webhook with no secret or secret headers simply returns empty fields — distinguishable from the fixed mask a decryption failure produces.

Webhook URLs often carry credentials outside the user:pass@ part: in the path (Slack’s /services/T…/B…/<secret>, Discord’s /api/webhooks/<id>/<token>) or the query string (?token=). Sparrow’s logs and trace spans therefore record only the scheme and host (https://hooks.slack.com/…); this includes the url.full attribute of each delivery’s HTTP span, so secrets never reach your OTLP backend. Delivery error messages (e.g. connection refused) have the URL reduced the same way. Use the webhook ID to find the endpoint; the full URL is still available through the API to callers with access.

Resources are namespaced by consumer — one integration’s webhooks, events, and deliveries are invisible to another’s in every list and lookup. The global list routes (GET /v1/webhooks, /v1/deliveries, etc.) can span consumers, but never cross the instance’s data boundary.

What Sparrow enforces before the server will boot, and what you should set:

VariableRequirement
SPARROW_ENCRYPTION_KEYSAlways required. Comma-separated <key-id>=<64-char-hex-key> entries.
SPARROW_ENCRYPTION_PRIMARY_KEY_IDAlways required. Selects the primary key for new encryption.
SPARROW_API_KEYRequired when ENVIRONMENT=production. Empty is fine for local dev only.
DATABASE_URLAlways required.
SPARROW_MAX_BODY_BYTESOptional, default 5 MiB. Values below 1 MiB are rejected at startup.
SPARROW_ALLOW_PRIVATE_NETWORKSLeave unset (false) unless your webhook targets live on your own private network — true disables all SSRF network checks.
SPARROW_EVENT_RETENTION_DAYSOptional, default 0 (keep forever). Purges events and their deliveries older than N days — event payloads are stored in plaintext, so pair a retention window with storage-layer encryption when payloads are sensitive. See Securing Sparrow.