Security model

How BugLoop protects your issue tracker, your credentials, and your visitors — layer by layer.

The threat we care most about

BugLoop can create issues in your repository. That makes it a write path into your engineering workflow, and the widget end of it is public by construction. Everything below exists to make that path narrow.

Layers on the ingest path

A submission has to pass all of these before a row is written:

  • Schema validation — every field is typed, length-bounded, and shape-checked before anything else runs
  • Project lookup — an unknown or paused key returns the same 403 as a disallowed origin, so keys cannot be enumerated
  • Origin allowlist — the request must come from an origin you listed
  • Rate limiting — independent per-project and per-submitter windows, both enforced in the database
  • Human verification — an invisible, signed, single-use proof-of-work challenge the widget solves while the reporter types, plus a hidden honeypot, a minimum time-on-form and a link-stuffing check. The last three are answered with the same 201 a real report gets, so automation learns nothing
  • Custom field bounds — keys must match the field-key pattern, values are type- and length-bounded, and a report carries at most 24

Credentials at rest

Your GitHub and Jira tokens are encrypted with AES-256-GCM before they touch the database, using a key that lives only in the server environment. The envelope is versioned so the key can be rotated later.

The ciphertext column has SELECT revoked from the authenticated database role, so it is not readable through the data API at all — not by you, not by a member of your organization, not by a leaked session. Only server-side code with the service role can decrypt.

Tenancy

Every table has row level security enabled, and access is decided by organization membership inside the database rather than by application code. A bug in a query cannot leak another organization's data, because the database will not return it.

The widget never talks to the data API. It posts to a server route that authorizes the request first. The anonymous database role has no access to any application table.

Screenshots

A screenshot exists only because a reporter pressed a button. Before the image is rendered, every input and textarea on the page is bulleted and everything carrying data-bugloop-mask is left undrawn — on the page, so no unmasked copy is ever produced. The widget asks for no screen-sharing permission, so it cannot reach another tab or anything else on the reporter's machine.

The upload route repeats the ingest gates in the same order — project key, origin allowlist, rate limit — and adds two of its own: the file type is read from the leading bytes rather than trusted from the request header, and anything over 5 MB is refused.

The bucket is private and carries no storage policies at all, which denies every browser role by construction. Objects are written server-side with the service role and read as time-limited signed URLs. An upload becomes part of a report through a one-shot token, redeemed by a single conditional UPDATE — so a replayed token loses the race inside Postgres rather than in application code.

What we never store

No raw IP address — only a salted SHA-256 hash used for rate limiting. No cookies set on your visitors, and nothing written to their localStorage or sessionStorage. No session recordings, click streams, scroll depth, mouse movement, or heatmaps.

The widget contacts no third party of its own accord. Two things involve one, both only on a deliberate press: dictation hands the audio to the browser's own speech service, which in Chrome means Google; and attaching a screenshot re-reads the images, stylesheets and fonts your page is already showing so they can be drawn into the picture, including any served from another host. Neither sends anything about the visitor anywhere new — the screenshot reads carry no cookies and no referrer — but both are covered in full under Privacy and data.