Credentials you give us
The GitHub integration needs a fine-grained personal access token scoped to a single repository with the Issues permission set to Read and write. That token cannot read your source, cannot push, and cannot reach any other repository. The Jira integration needs an Atlassian API token for the account whose email you configure.
Both are encrypted with AES-256-GCM before they reach the database, using a key held only in the server environment. The envelope is versioned so the key can be rotated without guessing how an existing row was encrypted.
The column holding the ciphertext has SELECT revoked from the authenticated database role, so it is not readable through the data API at all — not by you, not by an admin of your organization, not by a stolen session token. Only server-side code holding the service role can decrypt, and only at the moment it files an issue.
Tenancy
Every table has row level security enabled, and access is decided by organization membership inside the database rather than by application code. A missing filter in one query cannot leak another organization’s data because the rows are not returned in the first place.
The public 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.
The ingest path
Six checks, in order, before a row is written:
- Schema validation — every field typed, length-bounded, and shape-checked
- Project lookup — unknown and paused keys return an identical response, so keys cannot be enumerated
- Origin allowlist — the request must come from an origin the project owner listed
- Rate limiting — independent per-project and per-submitter windows, both enforced transactionally in Postgres
- Human verification — an invisible proof-of-work challenge the widget solves while the reporter types (signed, expiring, single-use), plus a hidden honeypot, a minimum time-on-form and a link-stuffing check. The last three are answered with the same response a real report gets, so automation learns nothing
- Custom-field bounds — keys must match the field-key pattern, values are type- and length-bounded, at most 24 per report
Screenshots
A screenshot exists only because a reporter pressed a button. Before the image is rendered, every input and textarea on the page is blanked and anything 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, another window, or anything else on the reporter’s machine.
The upload endpoint 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 storage bucket is private and carries no 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. Deleting a report deletes its image; deleting a project deletes every image under it.
Transport and headers
HTTPS only, with HSTS. A Content Security Policy without unsafe-eval, frame-ancestors 'none', object-src 'none', plus X-Content-Type-Options: nosniff and a strict Referrer-Policy.
The Permissions-Policy denies camera and geolocation outright — camera=(), geolocation=(), an empty allowlist, so nothing on this origin or in any frame it embeds can ask for either. The microphone is narrowed to this origin rather than denied: microphone=(self). BugLoop runs its own feedback widget on this site, and dictation on that widget needs the browser's speech API, which an empty allowlist would block. No embedded third-party frame inherits it.
That policy governs bugloop.ai. It says nothing about your site: w.js is served without the Content-Security-Policy and without the Permissions-Policy — it carries only X-Content-Type-Options: nosniff alongside its CORS and cache headers — precisely so that a policy written for our origin never travels to yours. Your own Permissions-Policy decides whether dictation can run there.
CORS on the ingest endpoints never uses a wildcard. The response echoes only an origin that is already on the project’s allowlist.
What we never store
No raw visitor IP addresses — 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. No audio: a dictated description reaches us as text and a one-word label, never as a recording.
The one third-party request
The widget makes no third-party requests, with a single exception that is yours to enable. Dictation puts a microphone button on the description field using the browser’s own speech API, and in Chrome that API streams the audio to Google for transcription.
So it is off unless you turn it on for a project, the widget shows the reporter a one-line notice the first time the button is pressed, and it is stated here and in the docs rather than left to be found in a network tab. Leave dictation off and the widget talks to nobody but BugLoop.
Reporting a vulnerability
Email security@bugloop.ai with enough detail to reproduce the issue. Please give us a reasonable window to remediate before disclosing publicly. We do not currently run a paid bounty programme.
Honest limitations
BugLoop is early. There is no SOC 2 report, no penetration test on file, and no signed DPA template yet. The controls above are real and implemented, and we would rather list what is missing than imply certifications we do not hold.