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.
API Authentication
Section titled “API Authentication”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.
Access tokens
Section titled “Access tokens”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 withsparrow_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
reasonfield:missing(no credential),invalid(unknown key or token),expired, orrevoked. - 503 on DB outage. If the token store is unreachable, the server returns
503 Service UnavailablewithRetry-After: 5, not401. 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 return400 invalid_invite.
Portal tokens
Section titled “Portal tokens”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.
SSRF Protection
Section titled “SSRF Protection”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/httpsschemes; 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).
TLS verification
Section titled “TLS verification”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.)
HTTP Hardening
Section titled “HTTP Hardening”Defense-in-depth headers on every API and UI response:
| Header | Value | Stops |
|---|---|---|
X-Content-Type-Options | nosniff | Browser MIME-sniffing a JSON response as HTML — reflected XSS |
X-Frame-Options | DENY | Clickjacking — the UI is never legitimately framed |
Referrer-Policy | strict-origin-when-cross-origin | Leaking consumer/webhook IDs in URLs to an external Referer |
Permissions-Policy | interest-cohort=() | FLoC / Topics API tracking |
Content-Security-Policy | default-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_rpsper 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.)
Secret Masking in Responses
Section titled “Secret Masking in Responses”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 in Logs and Traces
Section titled “Webhook URLs in Logs and Traces”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.
Consumer Isolation
Section titled “Consumer Isolation”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.
Production Config Checklist
Section titled “Production Config Checklist”What Sparrow enforces before the server will boot, and what you should set:
| Variable | Requirement |
|---|---|
SPARROW_ENCRYPTION_KEYS | Always required. Comma-separated <key-id>=<64-char-hex-key> entries. |
SPARROW_ENCRYPTION_PRIMARY_KEY_ID | Always required. Selects the primary key for new encryption. |
SPARROW_API_KEY | Required when ENVIRONMENT=production. Empty is fine for local dev only. |
DATABASE_URL | Always required. |
SPARROW_MAX_BODY_BYTES | Optional, default 5 MiB. Values below 1 MiB are rejected at startup. |
SPARROW_ALLOW_PRIVATE_NETWORKS | Leave unset (false) unless your webhook targets live on your own private network — true disables all SSRF network checks. |
SPARROW_EVENT_RETENTION_DAYS | Optional, 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. |