Security

What we do, what we don't, and the threat models the system is built for.

Auth model

Three Bearer token types, each with a different scope:

Token Format Scope Used by
Project key pk_* One project, project-scoped MCP tools + /v1/* writes Widget snippet, scripts, CI
OAuth JWT RFC 7519 (HS256) One user + one team, project-scoped + user-scoped MCP tools, filtered by selected_tools Coding agents, integrations
Supabase session Supabase Auth cookies One user, full dashboard access via team membership Browser, dashboard pages

All Bearer comparisons use timingSafeEqual / constantTimeMatch. No string ==.

OAuth 2.1 + DCR

Dynamic Client Registration per RFC 7591. PKCE S256 required. No PATs; the OAuth flow IS the install path for agents.

  • Authorization codes: 10-minute TTL, single-use, deleted on success or failure.
  • Access tokens: 1h TTL, HS256 JWT signed with OAUTH_JWT_SIGNING_KEY (32 bytes hex). Each token carries a random jti so duplicate claims still produce unique tokens.
  • Refresh tokens: opaque 48-byte random, SHA-256 hashed at rest, rotated on every use.
  • Tool selection captured at consent (the Sentry pattern). The user picks which MCP tools the client may call. tools/list filters; tools/call refuses.

Encryption at rest

Data Mechanism
Integration credentials (integrations.credentials_encrypted) AES-256-GCM, INTEGRATION_ENCRYPTION_KEY (32 bytes hex). Versioned {v, alg, iv, tag, ct} JSON envelope.
OAuth client secrets SHA-256 hashed at rest. Never retrievable.
OAuth refresh tokens SHA-256 hashed.
Supabase Auth passwords / session tokens Managed by Supabase (bcrypt + JWT).

The integration cipher envelope is portable: when we move to Supabase Vault or AWS KMS in production, on-disk shape stays the same.

RLS

Postgres row-level security is enabled on teams, team_members, projects, feedback, integrations, oauth_*. Policies check auth.uid() against team_members.user_id.

The dashboard reads via Drizzle + service role for performance, with explicit per-call team scope checks (requireUserScope + assertProjectAccess). RLS stays enabled as defense-in-depth so a leaked anon key can't read another team's data.

Constant-time comparisons

  • Project key check in /v1/* and MCP: timingSafeEqual on equal-length buffers; if lengths differ, runs a dummy compare against itself to keep the timing oracle flat.
  • OAuth client secret check: same pattern via constantTimeMatch.

Data captured by the widget

Item When Where stored
Voice transcript Always (text or speech) feedback.voice_transcript
Audio recording Voice mode only Supabase Storage audio/<projectId>/sessions/<sessionId>.<ext>
Screenshot Voice or text mode Supabase Storage audio/<projectId>/screenshots/<sessionId>.png
DOM snapshot (rrweb) Always feedback.context.dom
Console entries Last 50 feedback.context.console
Network entries Last 10 feedback.context.network
Device + page metadata Always feedback.context.{device,page}
Identity (if identify() called) Always when called feedback.identity

Privacy controls

  • Masked inputs: rrweb's maskAllInputs: true is set by default. Form values aren't captured.
  • Element hiding: add class="earshot-block" to any element to keep it out of the DOM snapshot entirely.
  • Text masking: add class="earshot-mask" to keep an element's structure but replace its text with *.
  • No cross-origin reads: html2canvas + rrweb only see the host page.
  • Storage paths are private: the audio bucket is non-public; the dashboard signs URLs at view time with a 1-hour TTL.

What ships before public launch

Status
OAuth 2.1 DCR + PKCE
Encryption at rest for integration creds
Constant-time comparisons
RLS on every table
Per-project CORS origin allowlist Pending — Sprint 5
Rate limiting on /v1/* Pending — Sprint 5
Privacy Policy + Terms + DPA Pending — Sprint 6
Configurable IP / geo capture opt-out Pending — Sprint 6
Audio retention + delete endpoint Pending — Sprint 6
Sentry on dashboard + MCP + widget Pending — Sprint 6
SOC 2 work Post-launch with first paying customer

Reporting an issue

Please email security@earshot.dev. We respond within one business day. PGP key on the security page (TBD).