Security
This document describes Compressr's threat model, trust boundaries, the auth and entitlement mechanisms, permissions, storage, and known gaps. It is a factual description of what the code does today, not a security certification. The backend it describes is Supabase (ADR-009); the Firebase backend this document used to describe was removed and no longer exists.
Threat model
Compressr is a browser extension. The extension is an untrusted client from the server's point of view: anyone can unpack, patch, or re-sign a copy of it, intercept its network calls, or run a modified build against the real backend. The design goal is therefore:
- Protect server-side data (billing state, other users' entitlements, install records) from any client, honest or hostile.
- Make casual sharing/tampering of Pro features detectable and revocable, not cryptographically impossible.
- Never rely on the client to self-report payment status for a paid feature.
Accepted residual risk: a determined local attacker who controls their own machine can patch
the extension binary to bypass client-side gating entirely (e.g. hard-code plan: 'pro_plus' in
memory). This is explicitly accepted (ARCHITECTURE.md §9) — the design targets casual sharing
and server-side revocability, not DRM-grade protection against a fully compromised client. No
mitigation in this document changes that.
The complementary promise is audio privacy: tab/page audio, PCM, video frames, DOM/page
content, and browsing history never leave the device under any code path (see PRIVACY.md).
That promise does not depend on trusting the client with secrets — there simply is no code path
that transmits audio, so there is nothing for a hostile client to exfiltrate on that axis.
Trust boundaries
popup / options (React, UI only)
| runtime messaging (src/adapters/runtime)
v
background (service worker / event page) <-- holds a DEVICE CREDENTIAL, entitlement cache, desired state
| |
| chrome.tabCapture / offscreen | HTTPS (redeem-link-code, mint-entitlement)
v v
offscreen / content script (DSP graph) Supabase Edge Functions <-- service-role key, Postgres
|
v
autolevel-worklet.js (AudioWorkletProcessor, bundled classic script, no DOM)
hosted pages (/link, /buy, /account — static, separate origin) -- hold the USER session
The extension holds no user session at all (ADR-009). It carries an opaque random string the server issued to that one installation, and it does not learn the account's email address or user id. Identity lives on the website. This is the single most load-bearing property in this document: a stolen extension profile yields a revocable per-install credential, not an account.
- Popup/options ↔ background: popup and options never hold a credential themselves; they
ask background for state and issue commands over
src/adapters/runtime/messaging.ts. UI code cannot independently decide it is "Pro." - Background ↔ offscreen/content: background owns desired state and the verified entitlement
token; offscreen (Chromium) and the content script (page-media browsers) own the live
AudioContext/worklet graph and receive gating decisions from background, not from their own guesses. - Background ↔ Edge Functions: the only network trust boundary that mints or reads privileged
data.
redeem-link-codeandmint-entitlementrun server-side with the service-role key; row-level security denies every client direct access to the tables they read. The device credential travels in its ownx-autolevel-device-tokenheader and is never sent as anAuthorizationbearer token, so it can never be mistaken for a user JWT by any layer. - Hosted pages: a separate, unbundled static origin (
hosted/) that never imports extension APIs./linkreceives the opaqueinstall_idand nothing else about the extension. It signs the user in against Supabase over plain REST — no SDK, no CDN — and hands back a short-lived code./buyand/accounthold the user session for checkout and billing, which is why those actions open a website tab instead of running inside the extension.
Runtime-message sender validation (SEC-06)
Privileged Cmd and AuthMsg requests are accepted only when sender.id equals this extension's
runtime ID and sender.url has the extension origin returned by browser.runtime.getURL('/').
This intentionally does not depend on the presence of sender.tab: an extension document can
be the action popup without a tab, or popup.html#/pricing, a success route, or options opened in
an ordinary browser tab. Content scripts remain limited to PM_* page-media events and must also
carry this extension's runtime ID; their web-page sender.url cannot satisfy the privileged
extension-page check. Other senders receive no response, and the background logs the rejected
boundary once per worker lifetime.
Auth handoff (ADR-001)
The extension background never runs its own OAuth flow and never imports chrome.identity.
Flow (ARCHITECTURE.md §8, AUTH_AND_BILLING.md §1.1):
- Popup opens a tab:
{HOSTED_LOGIN_URL}/link?challenge=<S256(verifier)>&install_id=<install_id>— PKCE with the S256 method (SHA-256 of a locally generated verifier, base64url-encoded). - The hosted page signs the user in against Supabase Auth over plain
fetch. - Hosted page calls
issue-link-codewith the user's JWT (verify_jwt = true, so the gateway rejects anonymous callers before the function runs). It writeslink_codes={ code, user_id, install_id, challenge_s256, expires_at }. The code is 24 characters, Crockford base32 alphabet, 60 second TTL, and is bound to theinstall_idand PKCE challenge presented at mint. - Extension background calls
redeem-link-codewith{ code, verifier, installId }(verify_jwt = false— the code plus verifier is the credential; the background holds no session). The function burns the code first, with an atomicupdate … where code = $1 and used_at is null … returning, so only the first caller gets a row back. It then checks expiry,install_idequality, andbase64url(SHA-256(verifier)) === challenge_s256using a constant-time compare. Because the burn happens before the proof, a failed proof still consumes the code — verifier guessing gets exactly one attempt, not unlimited attempts against a live code. - On success the function issues an opaque random device credential, stores only its SHA-256
hash in
device_credentials, and returns the plaintext once. Re-linking the same install revokes the previous credential (enforced by a unique partial index, not by application logic). - Background stores that credential and sends it in
x-autolevel-device-tokenon everymint-entitlementcall. It never receives a user JWT, a refresh token, or an email address.
The hosted page never sees an extension identifier beyond the opaque install_id string passed in
the URL, and the extension never runs code from the hosted page's origin. An unknown or
already-used code returns exactly the same 404 invalid-code as a wrong one, so redemption
failures do not tell an attacker which codes exist.
Entitlement token contract
The client (src/core/entitlements/{types,verify,policy}.ts) and the server
(supabase/functions/_shared/entitlement.ts) must agree on this byte-for-byte; a mismatch fails
silently as malformed/invalid-signature, not a crash.
- Algorithm: ES256 (ECDSA P-256 / SHA-256).
- Signature encoding: raw P1363
r‖s(WebCrypto's native ECDSA output), not ASN.1 DER. The Edge Function signs withcrypto.subtle.signfor this exact reason (Node's legacynode:cryptoECDSA API defaults to DER and must not be used here). - Header — exactly three fields:
{ "alg": "ES256", "typ": "JWT", "kid": "2026-09" }. The client'sparseHeaderrejects any otheralg(includingnone,HS256) ortyp, and requireskidto be a non-empty string. - Payload — exactly nine fields:
sub,plan(free|pro|pro_plus, closed set — an unrecognized value is rejected),features(closed allow-list, unrecognized entries are dropped rather than rejected for forward-compat),install_id,iat,exp,grace_until,jti,kid. All time fields are seconds since epoch, not milliseconds. - Key distribution: the extension pins a
kid → JWKmap incore/entitlements/keys.tsshipping two keys (current + next) at all times, so rotation never has to wait on a store review cycle. A signed JWKS document at{ENTITLEMENT_JWKS_URL}may add keys but must never remove the pinned ones within a release. - Expiry / refresh:
expis 24 hours from mint. Client refresh cadence (independent ofexp): 12h alarm, on SW start, on popup open if cached age > 1h, and on a 403. - Grace period is plan-specific: Pro (one-time/lifetime purchase) gets 30 days of soft
grace past
exp; Pro+ (subscription) gets 3 days. After grace, the client falls back to Free with a "reconnect to restore Pro" badge; a live session is never interrupted mid-stream. - Clock-rollback protection: the client checks both the server-issued
iathigh-water mark and a locally persisted wall-clock high-water mark. If the device clock is more than five minutes earlier than either trusted mark, the token is treated as untrusted — this defeats "roll the clock back to extend a lapsed token" tampering. - Install rate limiting: enforced server-side only, by the
register_installSQL function thatmint-entitlementcalls: a previously-seeninstall_idcan always re-mint; a newinstall_idis allowed only while fewer than 5 new installs were first seen for thatuidin the trailing 30 days. This makes copying a token/install across many devices detectable and boundable without blocking a legitimate device re-verifying. The check and the insert run under a per-user advisory lock inside one transaction, so two simultaneous first-time installs cannot both read "4 so far" and both be admitted. - Cache:
storage.local, with the token signature re-verified via WebCrypto on every read; separately mutable cached claims are never used as the authorization source.
Pro gating enforcement point
Pro features are gated in background/runtime code, not in the UI. The client's hasAccess
computed in the UI layer is a hint only (to render an upgrade prompt, etc.); background
applies the verified entitlement gate before it sends DSP controls, and offscreen/content
validate and clamp those controls before applying them. A patched popup that always renders
"Pro" does not, by itself, unlock Pro DSP behavior — the token still has to verify. A locally
patched runtime remains the explicitly accepted residual risk documented below.
Row-level security (summary)
Every table has RLS enabled and no policy is the default, which in Postgres means deny-all to
anon and authenticated alike. A table appears in this list only to say what, if anything, was
deliberately opened. The Edge Functions reach these tables with the service-role key, which
bypasses RLS — that is precisely why the client-facing grants are this narrow.
| Table | Client access | Notes |
|---|---|---|
profiles |
select own | Populated by a trigger on auth.users. |
installs |
none | Rate-limit bookkeeping; service-role only. |
device_credentials |
none | Stores only sha256(token). The plaintext exists once, in the redeem response. |
link_codes |
none | 60 s, single-use, bound to install_id + PKCE challenge. |
customers |
none | The Stripe customer id is never readable by the user it belongs to. |
subscriptions |
select own | The account page reads status and period end to show a plan. |
purchases |
select own | Same, for one-time purchases. |
stripe_events |
none | Idempotency ledger: the webhook inserts here first, so a replayed event fails on the primary key before any state changes. |
Checkout and billing-portal sessions are created by Edge Functions from the user's JWT. The client never names a price id or an amount — it names a tier, and the server resolves what that costs. A client that can state its own price is a client that can state its own price of zero.
Content Security Policy
wxt.config.ts builds the manifest CSP per browser/manifest-version:
- No remote code:
script-src 'self'only (MV2) /extension_pagesscript-src'self'(MV3). Noeval, no injected remote<script>, no CDN-hosted scripts anywhere in the extension pages. object-src 'none'(MV2 form) removes plugin content entirely.connect-srcallow-list: exactly one host, the configured Supabase project origin, plus a dev-onlyws://{devServerHost}in development mode. It is derived fromVITE_SUPABASE_URL/src/data/info.tsrather than written out, because the two must never disagree: this list outlived the Firebase removal still naming three Google hosts and omitting Supabase, which would have had the browser refuse every entitlement mint on every user's machine while the build, the install and the manifest all looked fine.test/manifest/manifest.test.tsnow asserts the built manifests for all three browsers.- The worklet is a bundled classic script, not loaded as a WXT entrypoint/ESM module:
scripts/build-worklet.mjsemitspublic/worklets/autolevel-worklet.jsas an IIFE with noimportstatements, and nowindow/document/self.references (enforced by a static post-build scan that throws the build if any forbidden token is found — seeDEVELOPMENT.md). This keeps the AudioWorkletProcessor's DOM-free scope contract mechanically auditable rather than merely documented. - The hosted pages (
hosted/) are not covered bywxt.config.tsand carry their own, tighter policy:script-src 'self'with no CDN (possible because they use plainfetchrather than an SDK),connect-srclimited to the one Supabase project,object-src 'none',base-uri 'none', andform-actionnarrowed to the specific Stripe host each page actually navigates to. - The hosted pages' policy is served as an HTTP header, generated into Cloudflare Pages'
_headersbyscripts/copy-hosted-link-page.mjsfrom the<meta>tag that remains the source of truth. This is not cosmetic:frame-ancestorsis ignored in a meta-delivered policy, so the anti-clickjacking directive on the sign-in, checkout and account pages was inert from the day it was written. It is enforced now, andsite-v2/test/links.test.mjsfails the build if a page's framing protection ever goes back to being meta-only.
Permissions, per browser, and why
wxt.config.ts's manifest() function computes permissions per target browser — nothing is
requested "just in case":
| Permission | Chrome (+ Chromium family) | Firefox / Safari | Why |
|---|---|---|---|
storage |
yes | yes | Entitlement cache, settings, presets, site/device profiles — all local. |
alarms |
yes | yes | Periodic entitlement refresh (12h) without keeping the service worker alive continuously. |
activeTab |
yes | yes | START is a user action from the extension's own UI. This grants that one tab's URL for a single site-rule lookup, instead of the broad tabs permission or a host permission. |
scripting |
yes | no | Registers the page-media content script on Chromium only when the user has granted the optional <all_urls> permission. Without it, Compatibility Mode cannot be offered at all on Chromium. |
tabCapture |
yes | no | Full-tab audio capture via chrome.tabCapture.getMediaStreamId. No documented Firefox/Safari equivalent (FIREFOX.md, SAFARI.md). |
offscreen |
yes | no | Hosts the persistent AudioContext/worklet graph outside the ephemeral service worker (Chromium-only API). |
<all_urls> content script |
not in the manifest — Chromium registers one at runtime via scripting, and only after the user grants the optional host permission |
yes, declared in the manifest, for Compatibility Mode | Firefox/Safari have no full-tab capture API, so the content script must be able to discover <audio>/<video> elements on any page to offer PageMediaCapture at all. This is page-media mode — labeled "Compatibility Mode" in the UI, never "Full Tab Processing." |
No oauth2 manifest key and no chrome.identity permission are used anywhere — the PKCE hosted
handoff (above) replaces them on every browser (ADR-001).
Storage inventory
| What | Where | Notes |
|---|---|---|
| Device credential | storage.local (background only) |
Opaque random string, bound to this install, revocable server-side. Not a user session: it carries no email address, user id, or refresh token. |
| Entitlement token (cached) | storage.local |
Verified ES256 JWT; refreshed per the cadence above. |
| Settings, presets, per-site/per-device profiles | storage.local / storage.sync as appropriate |
User-authored data only; never derived from browsing history (PRIVACY.md). |
Rate-limit bookkeeping (public.installs) |
Postgres, server-side | RLS deny-all; service-role only. |
Device credential hashes (public.device_credentials) |
Postgres, server-side | SHA-256 only. A database dump does not yield a usable credential. |
One-time link codes (public.link_codes) |
Postgres, server-side | 60s TTL, single-use, burned by an atomic compare-and-swap; never exposed to the client. |
| Telemetry (opt-in only) | Nowhere. In-memory buffer inside the extension, discarded on browser close | There is no telemetry transport in the shipping build; the sink takes an injected one and none is wired. See PRIVACY.md. |
Nothing above is audio. No storage location, client- or server-side, ever holds captured PCM,
tab audio, microphone input, or video frames — see PRIVACY.md for the full never-transmitted
list, which is a structural invariant (src/core/dsp cannot import browser/network APIs), not a
configuration toggle.
Known gaps
These remaining limitations are explicit rather than silently accepted:
S2 — The Edge Function must sign ES256 using raw P1363
r‖sencoding (NodedsaEncoding: 'ieee-p1363'if usingnode:crypto, orcrypto.subtleas currently implemented) with the header exactly{alg:'ES256', typ:'JWT', kid}. This is implemented insupabase/functions/_shared/entitlement.tsvia WebCrypto (which natively produces P1363); flagged here as a contract that must not regress to DER if the signing implementation ever changes. Now covered.test/unit/core/entitlements.cross-contract.test.tsimports the realsignEs256JwtandderiveClaimsfrom_shared/entitlement.ts, signs a token with a freshly generated P-256 key, and verifies it with the production client verifier. It asserts the signature is exactly 64 bytes and does not begin0x30, and that the header is exactly{alg, typ, kid}. Proven to catch the failure it exists for: injecting a DER encoding into the signer fails two of its four cases. Before this, ADR-009 §2 and this document both claimed such a test existed and neither was true — only the PKCE hash had one.S3 (resolved, with a caveat) —
src/core/entitlements/keys.tspins real production public keys for2026-09and2026-12; the placeholders are gone. The caveat is custody: the private keys are held as a Supabase secret and as local files outside the repo, not in a KMS. Two things are true and worth separating, because a single "not in a KMS" hides both: Extraction. A KMS keeps the private key inside its boundary and signs on request, so compromising the Edge Function never yields the key. Ours is loaded into the function, so it could be. A password manager does not change this — it hands over the material — so moving to one would not address extraction, and claiming otherwise would be worse than the current note. Custody. Rotation depends on a human still having the files, today one copy on one disk. This is the live risk at zero customers, and it is the one a password manager does fix.scripts/sync-entitlement-key.mjsis the path: 1Password (or any secret store) as the system of record, the Supabase secret as the runtime source, and a scripted sync between them. It deliberately does not fetch per request — that would put the store's availability in front of every mint and need a token in Supabase secrets to read a secret from the store. Before setting anything it derives the public key from the private one and refuses unless it matches the pinned map, so a wrong key cannot be installed; signing with an unpinned key would mint tokens every installed extension rejects, with no error anywhere. Both current keys verify. Live as of 2026-09-17.mint-entitlementsigns with the KMS; the private key is no longer loaded into the Edge Function at all on this path, so compromising the function can no longer yield it. Verified by minting a real entitlement through the deployed function: header{"alg":"ES256","typ":"JWT","kid":"2027-03"}, a 64-byte P1363 signature, verifying against the pinned public key. The throwaway account used was deleted and the tables confirmed empty after. Rollback isENTITLEMENT_USE_KMS=false:ENTITLEMENT_SIGNING_KEYand kid2026-09are still configured, so signing reverts to the local key without a deploy. That is deliberate — it is the escape hatch if Infisical is unreachable — and it is also the reason the extraction risk is reduced rather than eliminated: the old key still exists as a secret. An Infisical KMS signing key —entitlement-2027-03, ECC_NIST_P256, Sign/Verify, with export permanently disabled at creation, so its private half exists nowhere outside the KMS and cannot be extracted even by an account holder. Its public key is pinned inkeys.tsas kid2027-03, ahead of anything signing with it:resolveKeyrefuses unknown kids, so signing before installed extensions trust the kid would mint tokens every one of them rejects, silently. Ship the pin, then switch.derToP1363insupabase/functions/_shared/entitlement.tsconverts the ASN.1 DER a KMS returns into the raw P1363r‖sthat JWS requires, bounded by the S2 contract above. Infisical was confirmed to return DER empirically rather than assumed: it signed a known string, the 72-byte DER converted to 64 bytes, the converted signature verified against the pinned coordinates, and the unconverted DER did not. Outstanding, and it is the custody half. The2026-09and2026-12private keys are still one copy on one disk. They remain the rollback path, so losing them means losing the ability to revert — and they are still the keys any already-issued token verifies against. Until they are in a password manager (scripts/sync-entitlement-key.mjs), a disk failure is still the live risk. Also outstanding: the Infisical free plan has no audit logs, so there is no record of who asked the KMS to sign what, and its Universal Auth access token TTL is 30 days.SEC-08 residual phishing risk — the hosted page makes the user explicitly acknowledge the device-bound, never-share warning before a code is issued. A determined victim can still be socially engineered into completing that confirmation and revealing a code; the code is bound to the initiating install and PKCE verifier, which prevents its use on an attacker's install.
SEC-09 residual local-patching risk — offscreen/content runtimes validate and clamp DSP input before applying it, but do not independently verify an entitlement token. A locally patched extension can bypass client-side feature gates; this remains the documented hostile- client risk and is not a backend authorization bypass.
SEC-10 (resolved) — a copied device credential plus its install id could re-mint indefinitely until someone noticed. Revocation always worked —
mint-entitlementfilters.is('revoked_at', null), so stamping that column cuts a device off — but nothing let a customer see what was linked or act on it, so revocation meant emailing support. The account page now lists every linked device with when it was linked and last used, with a per-device Sign out and a Sign out every device control, served by thelist-devicesandrevoke-deviceEdge Functions (bothverify_jwt = true). Verified end to end against the live backend: listing, revoking one, revoking all, and a foreign credential id answering 404 rather than confirming it belongs to another account. Two properties worth stating. The list never returnstoken_sha256and truncates the install id — the browser has no use for either, and only the hash is stored anyway. And revocation is a column write rather than a delete, so the record of when a credential existed and when it was withdrawn survives, which is exactly what you want after one turns out to have been copied. Residual: a revoked device keeps working until its current entitlement expires, because the extension holds a signed token and re-mints on a schedule rather than on every use. Revocation guarantees the next mint fails, not instant cut-off. This is the same lagREFUNDS.mddescribes for refunds, for the same reason, and the account page says so in those words.SEC-13 — CI now gates high/critical dependency findings with
npm audit --audit-level=high. Actions remain pinned to maintained major tags rather than immutable commit SHAs; SHA pinning needs a repository-wide action-update process.SEC-14 (resolved) — expired
link_codesrows were never redeemable, but nothing deleted them either, so the table grew without bound while holding a user id and an install id per row. Migration0004schedulespurge_expired_link_codes()hourly via pg_cron, which became available when the organization moved to the Pro plan. Verified active incron.job.SEC-16 (resolved) — the hosted pages no longer load any CDN SDK. They speak to Supabase over plain
fetch, which is what allowsscript-src 'self'with no third-party origin at all.SEC-19 — Cloudflare injects scripts into pages this document calls "own scripts only." The edge rewrites every HTML response on the site: Web Analytics adds a beacon from
static.cloudflareinsights.com, and Email Obfuscation adds a same-origin decoder script under/cdn-cgi/. On/link,/buyand/accountthe beacon is blocked by the page's own CSP — correctly, and it is the reason those pages carry a CSP at all — but the same-origin decoder satisfiesscript-src 'self'and does run. Nothing here is known-malicious and the beacon is disclosed inPRIVACY.md, but a script the application did not author executes on the page where users type their password, and neither the repository nor the CSP can prevent it. The fix is a dashboard change, not a code change: scope Web Analytics and Email Obfuscation away from/account/*,/link/*and/buy/*with a Cloudflare configuration rule. Until that is done, treat "no third-party code on the hosted pages" as true of what this repository ships and not of what the edge ultimately serves.SEC-20 — password policy is weaker than it looks.
hosted/account.jsrejects a password under 8 characters, but that is a client-side check on a page that talks to a public REST API: anyone can call/auth/v1/userdirectly and skip it. The server-side minimum length and required character classes are a Supabase dashboard setting and must be set there to mean anything. Separately, leaked-password protection — Supabase Auth checking a candidate password against HaveIBeenPwned — cannot be enabled on this project: it is gated to the Pro plan and the Compressr organization is on Free. Until that changes, a customer can choose a password that is known to be in a breach corpus, and nothing will stop them.SEC-17 — telemetry is opt-in and has a closed event-name allow-list plus URL-shaped prop sanitization. The substring sanitizer should eventually become a strict value allow-list before adding dynamic telemetry properties.
SEC-18 — Firefox/Safari Compatibility Mode exposes the worklet as a web-accessible resource for page-media
AudioWorklet.addModule; this permits extension fingerprinting on those targets.Cross-browser compatibility, capture survivability, and CSP-in-production claims are largely untested pending the E2/E4/E6/E7/E8 experiments — see
BROWSER_COMPATIBILITY.md. A security review of the auth/entitlement code path by an independent reviewer (AGY) was planned but blocked on API quota at the time of this document; treat that review as not yet performed.
Dependency policy
CI runs npm audit --audit-level=high after npm ci; high and critical findings fail the build.
It is a dependency gate, not a guarantee that every transitive issue has a safe automatic upgrade.
Reporting a vulnerability
Email support@compressr.io. It is a monitored address, not a
placeholder. There is no bug bounty and no formal disclosure timeline; what is offered is that a
report will be read by the person who can fix it. A dedicated security@ alias and a published
disclosure policy remain outstanding.
Dependency audit status (2026-09-07)
Production dependencies: npm audit --omit=dev --audit-level=high → 0 vulnerabilities (blocking in CI). Full audit still reports 4 high, all dev-only and all rooted in web-ext's addons-linter → image-size (DoS in ICNS/JXL/HEIF parsers); web-ext is only used for npm run lint:firefox on trusted local files and never ships. Tracked as an accepted dev-only exception; re-check on each web-ext release. The full audit runs in CI as non-blocking.