Security model
What is protected, the trust boundaries, the mitigations in place and the residual risks.
This page states what Open Health Sync protects, from whom, how, and where it stops. It
describes the code as it is on main; file paths point at the mechanism. For the operator
checklist see hardening.md; to report a problem see
SECURITY.md.
What is protected
| Asset | Where it lives | Protection in the application |
|---|---|---|
| Health records (samples, sleep, workouts, daily summaries) | Postgres, schema ohs_data | Every query filters on tenant_id and user_id. Stored in clear. |
| Raw provider and device payloads | Raw store: a folder or an S3 bucket | Keyed by raw/<tenant>/<user>/..., written once. Gzip, not encrypted. |
| Provider OAuth access and refresh tokens | ohs_sync connections | AES-256-GCM under OHS_ENCRYPTION_KEY, bound to the row |
Tenant API keys (ohs_sk_) | ohs_core.api_keys | SHA-256 hash only |
User tokens (ohs_ut_) | ohs_core.user_tokens | SHA-256 hash only, scoped, expiring |
Webhook signing secrets (whsec_) | ohs_core.webhook_endpoints | AES-256-GCM under OHS_ENCRYPTION_KEY, bound to the endpoint |
OAuth state values | ohs_sync.oauth_states | SHA-256 hash only, single use, 10 minute lifetime |
OHS_ENCRYPTION_KEY and provider client secrets | The process environment | Not stored by the application; the operator's job |
Actors and trust boundaries
tenant operator ──(CLI on the host)──┐
tenant backend ──(ohs_sk_ key)──────┐│ ┌──> webhook receivers (tenant URLs)
end-user device ─(ohs_ut_ token)───┐││ │
providers ──(OAuth callback, │││ ┌─────┴─────┐ ┌──────────┐
inbound notifications)┴┴┴─>│ ohs │─────>│ Postgres │
│ │─────>│ raw store│
└─────┬─────┘ └──────────┘
└──> provider APIs (pulls, revocation)
instance operator: runs the host, holds the environment, the database and the raw store- Instance operator. Runs the containers and holds
OHS_ENCRYPTION_KEY, the database and the raw store. Fully trusted: nothing in the application protects data from the operator. - Tenant operator. A customer of the instance, created with
ohs admin tenant create. Holds one or more tenant API keys. Trusted with its own users' data only. - Tenant backend. A server holding an
ohs_sk_key. Can create users, mint user tokens, start provider connections, read and export data, manage webhooks and delete users, all within its tenant. - End-user device. An app holding an
ohs_ut_token for one user, with some of the scopesupload,connectandread. - Providers. Call the unauthenticated routes
GET /oauth/{provider}/callbackandPOST /inbound/{provider}. Anyone on the internet can call them too. - Webhook receivers. URLs a tenant registers;
ohsposts to them from theetlrole. - Database and raw store. Trusted storage (see limitations).
Threats and mitigations by boundary
Callers of the API (tenant backends, devices)
- Stolen or guessed credentials. Keys and tokens are 32 random bytes; only their SHA-256
hash is stored, so a database read does not yield a usable credential
(
internal/auth/keys.go). A fast hash is adequate for secrets of that entropy. Revoked keys, expired tokens and tokens of a user being deleted are refused (internal/tenant/store.go,LookupKey). - A credential used beyond its purpose. Each route declares what it takes: a tenant key, or
a user token holding a given scope; anything else is 401 or 403 (
internal/auth/middleware.go, routes ininternal/api/). User tokens live one hour by default, at most 24 hours, or 30 days whenreadis the only scope (internal/api/handlers_tokens.go). The MCP server is read-only. - Reading another tenant's data. Every query on user data filters on
tenant_idanduser_idtaken from the authenticated principal, never from the request body. A user or object of another tenant answers 404, not 403, so ids cannot be probed (internal/tenant/store.go,internal/api/read/). - Oversized requests. JSON bodies and inbound notifications are size capped, and device
uploads are bounded by
OHS_INGEST_CONCURRENCY(503 withRetry-Afterabove it). - Tracing an incident. Every response carries
X-Request-Id, and every log line written for the request carries the samerequest_id(internal/reqid/reqid.go,internal/api/router.go). Request logs record the route pattern and status, never the query string or a header.
Providers and the public routes
- Forged OAuth callback / login CSRF.
StartConnectionmints a 32 byte random state, stores only its hash with the tenant, user, provider and an expiry of 10 minutes. The callback consumes it with a singleDELETE ... RETURNING, so a state works once, for one provider, and only before it expires. The tenant'sredirect_urimust be https (http only for localhost). Providers that require PKCE get it (internal/collect/oauth.go). - Forged notifications. Each connector verifies what its provider offers before anything is
queued: HMAC signatures over the body (and a timestamp where the provider sends one, refused
outside a tolerance window), a shared secret compared in constant time, or a signed callback
URL for providers that sign nothing (
connectors/*/inbound.go). A rejected request is 401 and enqueues nothing. An accepted event only schedules a pull with the user's own token for a user who already has an active connection. Only deletion notices are stored from the event itself: from Oura and WHOOP, which sign them, and from Strava only whenOHS_STRAVA_SUBSCRIPTION_IDis set. - Slow or large bodies. Inbound bodies are capped at 1 MiB and must arrive within 30 seconds
(
internal/collect/inbound.go). - Request floods. The notification and callback routes take no credential, and the
first-run setup takes only its token, so each sits behind an in-memory token bucket per
client address, plus one per provider for notifications. Over a limit the answer is 429 with
Retry-Afterbefore any signature check, database read or queued pull, counted inohs_rate_limited_total.X-Forwarded-Foris read only from the proxies listed inOHS_TRUSTED_PROXIES(internal/api/ratelimit.go, see self-hosting.md).
Stored provider tokens
- Database theft. Access and refresh tokens are sealed with AES-256-GCM under
OHS_ENCRYPTION_KEYwith a fresh random nonce. The additional authenticated data istenant_id|user_id|provider, so a ciphertext copied to another row does not open (internal/crypto/box.go,internal/collect/connections.go). Webhook secrets are sealed the same way, bound to their endpoint id (internal/webhook/endpoints.go). - A leaked or retired key. Sealed values carry the id of the key that sealed them.
OHS_PREVIOUS_ENCRYPTION_KEYSkeeps the replaced keys able to open, never to seal, andohs admin rekeyre-seals every stored secret under the active key while the instance runs;ohs doctorsays when no secret needs a previous key any more (see key-rotation.md).
Webhook receivers
- Server-side request forgery. Without
OHS_WEBHOOK_ALLOW_PRIVATE, endpoint URLs must be https, carry no credentials, and not name localhost or a private, loopback, link-local (cloud metadata), CGNAT, multicast or unspecified address. The same check runs again in the dialer on the address actually connected to, so a name that resolves or rebinds to a private address is refused. Redirects are not followed (internal/webhook/guard.go,internal/webhook/worker.go). - Forged or replayed deliveries. Requests follow Standard Webhooks:
webhook-id,webhook-timestampand an HMAC-SHA256 signature overid.timestamp.body, with a 5 minute tolerance on the receiver side (internal/webhook/signature.go). Secrets can be rotated withPOST /v1/webhooks/{id}/rotate-secret. - Data leaking through webhooks. Payloads say what changed, for whom and over which days, never a health value; the receiver reads data through the API with its own key.
Logs
- Logs never contain keys, tokens, OAuth codes or health values. Provider error text has URL
query strings stripped before it is logged (
internal/collect/connections.go,safeErr). Logs do contain tenant, user and connection ids, provider names and route patterns.
Metrics
- Instance information on
/metrics. The Prometheus counters are instance wide (requests by route, ingest load, queues, sync state by provider), never per-tenant data, but they are closed by default. On the API listener a scrape must send theOHS_METRICS_TOKENbearer token (16 characters or more, compared in constant time; 401 otherwise), and with no token set every scrape is refused with 403.OHS_METRICS_LISTENmoves/metricsto an address of its own, for example127.0.0.1:9464, where whoever reaches the address can scrape it unless the token is also set.OHS_METRICS=falseremoves the route (internal/api/handlers_ops.go,internal/app/app.go).
Erasure
DELETE /v1/users/{id}marks the user deleting in one transaction (their tokens stop working at once), then a worker in theetlrole revokes the provider grants, deletes every raw file underraw/<tenant>/<user>/, and deletes the user's rows from every table ofohs_syncandohs_datathat has auser_idcolumn (found from the catalog, so a new table is covered), their tokens, idempotency records, unsent webhook deliveries and the user row (internal/deletion/). It survives restarts and retries with backoff.
Accountability, consent and portability
- Who did what. Every request authenticated with a tenant key or a user token writes one
event to
ohs_core.audit_events, whatever its outcome, with the credential, the user it touched, the action and the status;ohs admincommands and erasures are recorded too. A database trigger refusesUPDATE,DELETEandTRUNCATEexcept for the retention job (OHS_AUDIT_RETENTION_DAYS). Tenants read it withGET /v1/audit(internal/audit/, audit-log.md). - Data collected beyond consent. A user's consent is kept per source and per data category.
The ETL drops the records of a category not granted before anything is written, and a
revocation queues a purge of what was already stored.
OHS_CONSENT_REQUIRED=truestores nothing from a source with no consent recorded. Every change is kept with the key that made it and announced by theconsent.grantedandconsent.revokedwebhooks (internal/consent/, consent.md). - Portability.
GET /v1/users/{id}/archivestreams everything held about one user as one zip file, never a token or a sealed value; a failure after the first byte aborts the connection so a truncated archive never looks complete (internal/api/handlers_archive.go, exports.md).
Known limitations and residual risks
- Health data is not encrypted by the application. Records in Postgres and payloads in the raw store are in clear (raw files are gzip). Encryption at rest is the operator's job: disk or volume encryption, and server-side encryption on the bucket. Raw files are created with mode 0600 and folders 0750 by the filesystem driver; the S3 driver sets no encryption header.
- The database and the raw store are trusted. Anyone who can read them reads every tenant's health data. Anyone who can also read the environment can open the OAuth tokens. Tenant isolation is enforced in application queries, not by Postgres row-level security.
- Losing the encryption key is final. Rotation needs the old key listed in
OHS_PREVIOUS_ENCRYPTION_KEYSuntilohs admin rekeyhas run. Without it, the stored OAuth tokens and webhook secrets do not open: users must reconnect and webhook secrets must be rotated. A leaked key exposes the tokens of a stolen database until they are re-sealed. - Rate limits are per instance and in memory. Each replica counts on its own and a restart empties the buckets. Only the provider-facing routes and the first-run setup are limited: authenticated API routes are not, and floods larger than one instance can absorb belong at the proxy.
- OAuth state is not bound to a browser session. Whoever completes a consent link connects the account they sign in with to the user it was minted for. Tenants must hand an authorize URL only to its user.
- Some providers sign nothing. Without
OHS_STRAVA_SUBSCRIPTION_ID(Strava) orOHS_POLAR_WEBHOOK_SECRET(Polar), anyone can post an event that triggers a pull of a connected user's recent window. The pull uses that user's own token, so no data is injected. Strava deletion events are acted on only with the subscription id set, and that id is a shared value, not a signature: someone who learns it can forge the deletion of a connected athlete's activity. - User tokens cannot be revoked through the API. They expire, stop working when the user is
deleted, or the instance operator revokes one with
ohs admin key revoke --key ohs_ut_.... A user token minted with a key stays valid after that key is revoked, until it expires. - Provider revocation on deletion is best effort. If a provider is unreachable, or the process carrying out the deletion lacks that provider's keys, the data is still deleted but the grant may stay valid at the provider.
- Deletion does not reach copies. Backups, bucket object versions, webhook receivers and anything a tenant already read keep what they hold.
- The bootstrap key is logged.
OHS_BOOTSTRAP_TENANTlogs the new tenant's API key once, at first start. Treat that log line as a secret, or create tenants withohs admin tenant create. - Transport security is external.
ohsserves plain HTTP; TLS terminates at a reverse proxy. The bundled compose file connects to Postgres withsslmode=disableon the compose network.
Out of scope
- The security of provider APIs and of the accounts users sign in with there.
- The host, container runtime, reverse proxy, database server, S3 service and backups.
- Clients built by tenants: how they store keys and tokens, and what they do with the data.
- Medical correctness of the data; the application validates plausibility only.
- An instance operator acting against tenants or users.