Security
Claims you can check.
You are about to hand a piece of software your entire customer list, your shop phone number, your books, and — if you turn call recording on — audio of your customers talking. That deserves more than a padlock icon and the word “enterprise-grade”. So every claim below names the file that implements it, and the last section lists what we do not have.
Report a vulnerability: [email protected]
Architecture
One shop cannot read another shop, and the rule lives in the database
where clause can defeat — it is a row-level security policy on the table itself, evaluated by Postgres on every read and every write, including requests that bypass our application code and hit the data API directly.Every one of the 100 application tables created across the migration history has row-level security enabled. Ninety-nine of them additionally have it FORCEd, which means the policy binds the table owner too, not just ordinary roles.
The one exception is help_article_feedback, a thumbs-up/down on help articles, which is enabled but not forced. We would rather name it than round up to “every table”.
supabase/migrations/**: 100 tables with ENABLE ROW LEVEL SECURITY; 99 of those also FORCE it
Tenant policies read shop_id = any (current_user_shop_ids()). That helper resolves the caller’s auth.uid() to the shops where they hold an active, non-deleted membership. Revoke a membership and the rows stop being visible on the next query — there is no cache to expire.
supabase/migrations/0004_helpers.sql:12-30
Quotes, quote line items, invoices, payments, customers and per-job margin are gated in the database on a back-office permission predicate — not merely hidden in the technician UI. A bare technician account reads zero rows from those tables even by calling the data API directly.
Per-job margin used to live on jobs, which technicians legitimately read. It was moved to a gated companion table and the column dropped, because a column on a readable table is a leak no policy can close.
supabase/migrations/0090_worker_read_hardening.sql; supabase/tests/worker_rls_adversarial.sh
The test boots a real throwaway Postgres cluster, applies the actual production migration unchanged, seeds a manager and a bare technician, then attacks as the technician: counts and value projections on every pricing table, contact columns on customers, the dropped margin column, and a join from jobs into invoices to try to launder the read.
It also asserts the manager still reads everything, so the gate cannot pass by breaking the product.
supabase/tests/worker_rls_adversarial.sh — last run 2026-07-26: PASS, 0 failures
Reading your shop and writing your shop are separate policies: any member may read shops, only a holder of shop.manage may write it. The same split closed a hole where a staff member could PATCH their own shop_memberships row to a manager role.
supabase/migrations/0066_rls_privilege_hardening.sql
The booking storefront and share links are limited by a sliding window stored in Postgres, so the budget holds across serverless replicas instead of resetting on every cold start. The counter table is deny-all under RLS and EXECUTE on the RPC is revoked from public, anon and authenticated — otherwise anyone could burn a victim’s budget through the data API.
supabase/migrations/0018_public_rate_limits.sql:88-94
The honest caveat about row-level security
Supabase issues a privileged service credential that has BYPASSRLS. Row-level security — forced or not — does not constrain it. Our own webhook handlers, background jobs and platform-administration code use it, because they run with no user session. Every such call site is written to carry an explicit shop_id rather than infer one, and that credential is server-only and never reaches a browser. But it is a real trust boundary and we would rather you know where it is than discover it.
Encryption
In transit, at rest, and one extra layer over the credentials that matter most
The application and the data API are reached over HTTPS/TLS. TLS termination and certificate management are handled by our hosting provider; we do not run our own edge.
We do not currently set a Content-Security-Policy or other custom security response headers of our own — see the gaps section.
Host-terminated TLS (Vercel); Supabase data API is HTTPS-only
Database and object storage are encrypted at rest by the infrastructure provider. We do not offer customer-managed encryption keys.
Supabase-managed Postgres + Supabase Storage encryption at rest
An OAuth refresh token for a shop’s QuickBooks or Google account is the keys to that account. Those are encrypted by the application before they are written, so a database read alone yields nothing usable.
AES-256-GCM. 32-byte key, random 96-bit nonce per encryption, 128-bit authentication tag, wire format iv‖tag‖ciphertext base64. Authenticated encryption, so a tampered ciphertext fails to decrypt rather than yielding garbage.
src/lib/integrations/crypto.ts:26-79
Secrets and tokens
Where every credential in the system actually lives
The AES key lives only in the server environment variable INTEGRATIONS_ENC_KEY, never in Postgres. It is validated to decode to exactly 32 bytes at use time. If it is absent, the connect flow refuses to store tokens it cannot protect rather than falling back to plaintext.
What it covers: connector OAuth access and refresh tokens in shop_integration_secrets, and each shop’s Quo phone-system API key. What it does not cover: platform secrets such as the Stripe and Twilio keys, which live in the server environment.
src/lib/integrations/crypto.ts:31-45; src/lib/env.ts:187
Authentication is delegated to Supabase Auth, by password or by Sign in with Google. There is no password column anywhere in the schema and nothing in our code writes one.
To be exact rather than flattering: a password you type does pass through our server on its way to Supabase — that is how it gets set — and while it is in memory it is used for two things and no others. It is handed to Supabase Auth, and it is hashed so that the first five characters of that hash can be sent to the breach check described above. It is never written to disk, never logged, and never put in a database.
No password column exists in supabase/migrations/**; auth is Supabase Auth; src/lib/auth/password-breach.ts
Every password anyone sets — at signup, when accepting a team invite, through a reset link, or from Settings → Security — passes one validator: a length floor, an entropy estimate, an embedded list of the passwords attackers try first, and a refusal of anything containing your email address or your shop’s name.
It is then checked against the public breach corpus without sending us or anyone else your password. We SHA-1 it locally and put only the first five hex characters of that digest on the wire; the service returns every breached suffix sharing that prefix and the match happens on our server. Neither the password nor its full hash ever leaves the process.
Two limits, stated rather than implied: the breach lookup fails open — if the third party is down or slow, you can still finish signing up, and only the offline rules apply. And an existing password is never re-checked or force-rotated; only a new one is screened.
src/lib/auth/password.ts; src/lib/auth/password-breach.ts
Hashed: public API keys are stored as SHA-256 hex only — shown once at mint, never recoverable. Platform impersonation tokens, likewise.
Not hashed: the tokens in customer share links (the “view your quote” URL) are stored in plaintext, because the public accessor looks the row up by token. They carry a mandatory expiry and can be revoked. We would rather tell you that than let you infer “all tokens are hashed”.
src/lib/api/keys.ts:50; supabase/migrations/0077_api_keys.sql:25-37; supabase/migrations/0017_share_links.sql:33-51
The documents bucket is private — there are no anonymous object URLs. Every read is a short-lived signed URL: one hour for in-dashboard previews, fifteen minutes for customer-facing portal links.
The object path <shop_id>/<uuid>/<file> is itself the tenant boundary: paths are authored server-side from the authenticated shop context, and mutating helpers refuse any path outside the caller’s prefix.
src/lib/documents/storage.ts:1-47
Every server secret is declared in one zod schema with an explicit browser/server split. Importing the server env from client code throws by construction rather than shipping a secret into the bundle.
src/lib/env.ts:87-335
Optional vendors are swappable adapters. With the key unset the adapter is a no-op: no outbound call, no silent fallback to an insecure path. That is the pattern used for analytics, error monitoring, image generation and every OAuth connector.
src/lib/integrations/connect-flow.ts; src/lib/ai/openai-client.ts:28-34
Webhook integrity
Nothing acts on an unverified inbound payload
Scroll the table sideways for the rest of the columns
| Endpoint | Verification | On failure |
|---|---|---|
| Twilio — SMS inbound, delivery status, and nine voice callbacks | X-Twilio-Signature validated by Twilio’s own validateRequest against the account auth token | 403 — and fails closed if the token or header is missing |
| Stripe | stripe-signature via stripe.webhooks.constructEvent over the raw body, tried against both the platform and connected-account signing secrets | 400 |
| Meta Lead Ads | X-Hub-Signature-256 — HMAC-SHA256 over the raw body, timing-safe compared | 403 |
| Quo (phone system) | openphone-signature — HMAC-SHA256 over `${timestamp}.${rawBody}`, timing-safe, verified against the exact raw bytes | 403 |
| Google Pub/Sub review push | Not a signature. A shared secret in the query string, compared timing-safe over SHA-256 digests. The endpoint returns 404 entirely until that secret is configured. | 401 / 404 |
| Inngest (background jobs) | Handled by the Inngest SDK’s own serve() handler using INNGEST_SIGNING_KEY. We add no check of our own, and the key is optional in our env schema. | SDK-defined |
src/app/api/webhooks/**/route.ts; src/lib/twilio/signature.ts:22-32; src/lib/stripe/webhook.ts:31-53; src/lib/quo/webhook.ts:19-40; src/app/api/webhooks/google-business/pubsub/route.ts:42-79
We sign what we send, too
Outbound webhooks to your own endpoints carry X-ServiceVIN-Signature: t=<unix>,v1=<hex> where the digest is HMAC-SHA256 over timestamp + "." + rawBody using a per-endpoint secret. Verify it on your side and you can reject anything that did not come from us — including replays, using the timestamp.
src/lib/webhooks/deliver.ts:9,54,100
Auditability
Append-only ledgers — and precisely how far that goes
event_log (the domain event stream), roll_consumption (film and material cost of goods), messages, ai_credit_ledger (AI metering), pay_ledger_entries (technician accrued earnings) and platform_audit_log.
0014_rls_policies.sql:308-342; 0041_ai_calling.sql:150-155; 0070_time_tracking.sql:243-266; 0085_platform_hq.sql:82-85
Each of those tables has a SELECT policy and — where a client legitimately writes — an INSERT policy, and no UPDATE or DELETE policy anywhere in the migration history. Under row-level security an operation with no permitting policy is denied, so no client of the data API can rewrite a ledger row, whatever the application code does.
Two go further. ai_credit_ledger has a read policy and no write policy at all — AI metering is written only server-side. platform_audit_log has no policies whatsoever: it is unreadable and unwritable to every tenant role.
RLS policy shape: SELECT + INSERT policies only, no UPDATE or DELETE policy, with FORCE RLS
Append-only here is enforced by policy shape, not by immutability triggers and not by revoked table grants — we checked, and there are none. It therefore binds every role that row-level security applies to, which is all client traffic. It does not bind the BYPASSRLS service credential our own background workers hold.
So the accurate claim is: history cannot be rewritten through the API, by anyone, ever — and rewriting it would require a deliberate change to server-side code holding a credential that never leaves the server.
No BEFORE UPDATE/DELETE immutability triggers and no revoked table grants exist in supabase/migrations/**
At most one live invoice per quote (a partial unique index, so a double-click cannot double-convert). amount_paid bounded to [0, total] by a check constraint. A row-locking trigger serialises concurrent payments on one invoice so operator-entered payments cannot over-collect under a race.
supabase/migrations/0027_invoice_payment_guards.sql
Every HQ mutation module — suspension, billing, shop settings, support, announcements, impersonation, copilot — calls the audit writer. Rows carry the acting admin, a dotted action key, the target, the affected shop and a before/after payload.
Honest detail: the audit write is best-effort and is swallowed on failure, so that a database blip cannot roll back the operation it was recording.
src/lib/platform/audit.ts:42; 18 logPlatformAction() call sites — every one of the 7 modules in src/lib/platform/actions/, plus the support copilot
platform_audit_log.admin_user_id is ON DELETE SET NULL, not CASCADE. Deleting an administrator account leaves the record of what they did intact.
supabase/migrations/0085_platform_hq.sql:55-84
Access control
Who can see what — including us
Access is permission-keyed (shop.manage, jobs.work, invoices.access, and so on) with custom roles composed from permission sets. The keys are evaluated inside RLS policies, so a permission is not merely a UI toggle.
supabase/migrations/0087_custom_roles.sql; 0086_worker_admin_role.sql; 0090_worker_read_hardening.sql
Our internal console is gated on a single flag, users.is_platform_admin. The gate itself reads the caller’s own row through the RLS-enforced client — the authorisation check never bypasses row-level security to decide whether it may bypass row-level security.
src/lib/platform/dal.ts:34-51
A BEFORE UPDATE trigger on users raises 42501 if is_platform_admin changes and the caller is not the service role. This is one of the very few places in the schema where we use a trigger for immutability — because the consequence of getting it wrong is total.
supabase/migrations/0066_rls_privilege_hardening.sql:88-107
When we open your shop to reproduce a bug, a session is minted whose token is stored only as a SHA-256 hash, expires in about two hours, records a stated reason, and writes an audit row. The raw token rides in an httpOnly cookie and is shown to nobody.
There is no mode in which a platform administrator reads your data without an audit record being written.
src/lib/platform/impersonation.ts:24-31; supabase/migrations/0085_platform_hq.sql:95-116
Quote, invoice, receipt and document links use opaque random tokens that are not derived from the record id, so they cannot be guessed from a quote number. Expiry is mandatory (NOT NULL) and links can be revoked. There is no anonymous RLS policy: the public read path runs server-side and is rate-limited.
supabase/migrations/0017_share_links.sql:15-51
Shop-scoped keys, shown once at mint, stored only as a SHA-256 hash, listed with a display prefix, and soft-revoked so the row survives for the audit trail while the key stops working.
supabase/migrations/0077_api_keys.sql:25-43; src/lib/api/keys.ts:50
AI and data handling
What leaves for a model, what never does, and who trains on it
Every request-time model call that touches your data goes to Anthropic — message drafting, call-transcript analysis, the follow-up agents, the support copilot, the setup assistant. Models are pinned by environment variable rather than floating. (One route that touches no customer data — text-to-speech for our own public blog — calls Google’s Gemini TTS. It is listed in the subprocessor annex for completeness.)
What is sent: the transcript or thread in question, the customer’s display name and vehicle description, the shop’s own profile, voice guidance and knowledge-base snippets. Transcripts are truncated before transmission.
src/lib/ai/client.ts:12-18; package.json @anthropic-ai/sdk; src/lib/env.ts:92-97
OpenAI is used only to generate service illustration images (the image studio, when Google Imagen is not configured) and nothing else. It is optional: with OPENAI_API_KEY unset the factory returns null, callers branch on it, and no request is made. Customer messages and call transcripts are never sent to OpenAI.
src/lib/ai/openai-client.ts — the only `openai` import in src/**
Speech-to-text on recorded human calls is Twilio Voice Intelligence. The resulting text transcript is what Anthropic sees. Audio is never uploaded to Anthropic or OpenAI.
src/inngest/functions/call-intelligence.ts:81-140
We do not permit any model provider to train on your data, and we do not train models of our own on it. Anthropic’s and OpenAI’s commercial API terms state that inputs submitted through their APIs are not used to train their models.
The caveat we owe you: that is a contractual assurance from the vendor, not something we can enforce in code. We have not separately negotiated a zero-data-retention amendment with either vendor, so each vendor’s standard API retention window applies. If you need zero retention, tell us before you sign.
Vendor commercial terms — not a code-level control; stated as such
The rules that matter — the SMS opt-out line, the opt-out keyword check, quiet hours, the length ceiling — are enforced in code after the model responds, not trusted to the model.
To be precise about autonomy, because it varies by agent: once you enable them, the missed-call text-back, lead responder, win-back and campaign agents do draft and send without a human tapping send. Accepting a quote and booking a job always require a person. Every agent is off until you turn it on, and each one has its own send window.
src/lib/ai/agent-draft.ts (finalizeBody); src/lib/agents/messaging.ts:425-445; src/lib/agents/missed-call.ts:107-146
Both are off unless a key is set, and both are first-party: the browser talks only to our own origin, with no vendor script tag and no vendor SDK, so there is no third-party cookie and no DOM autocapture of form values. Separately from these, our hosting provider’s own Web Analytics and Speed Insights do run a vendor-written script, served from our own domain. They are cookieless, they are configured so that the only address they report is a redacted route template, and they are not loaded at all for a visitor sending Do Not Track or Global Privacy Control. Separately again, our ChatGPT advertising pixel does set a cookie and does load a vendor script — on our public marketing pages and nowhere else. It has its own claim below, because a sentence about analytics should not be read as a claim about advertising.
Error events are scrubbed before they leave: no cookies, no Authorization header, no request bodies, and URLs reduced to their path so a share token in a query string cannot reach the vendor.
src/lib/analytics/config.ts:1-32; src/lib/monitoring/sentry.ts:1-45; src/components/analytics/vercel-analytics.tsx:1-73
We advertise on ChatGPT, and OpenAI’s measurement pixel is the one vendor script in this product whose destination is an advertising network. Unlike the analytics beacons above, its SDK reports the page address it reads off the browser itself and gives us no hook to redact it — so the address is fenced on the way in instead: the pixel is loaded only when the host is one of our own marketing origins, the path is a public marketing route that our redaction function leaves unchanged, and the query string carries no identity-shaped parameter.
In practice that means it is never present on the signed-in application, on a shop’s booking storefront, landing pages or lead forms, or on the /p/… pages where a customer opens their own quote, invoice or job — the pages where the address isthe access token. It sends no name, email, phone number or hashed identifier of any kind: the vendor’s advanced-matching feature is deliberately unused, and a build check fails if anyone adds it. It is not loaded at all for a visitor sending Do Not Track or Global Privacy Control, and with no pixel configured it is absent from the build rather than inert.
src/lib/ads/openai-surface.ts; src/components/ads/openai-ads-pixel.tsx; scripts/check-ads-pixel.mjs
Payments, calls and recordings
The three data types owners worry about most
Card payment runs on a Stripe-hosted Checkout page. There is no Stripe Elements or client-side card SDK in our browser bundle, no card field in our forms, and no column in our schema for a card number, expiry or CVC. That keeps your shop out of PCI scope, not just us.
The card payment method you can pick in the dashboard is a label for an in-person terminal charge you keyed elsewhere. It records that the money arrived; it stores no card data.
src/lib/stripe/checkout.ts:4-6; no PAN/CVV column exists anywhere in supabase/migrations/**
We store a pointer — the recording SID and URL — not the audio. Playback goes through an authenticated proxy that first resolves the call through the RLS-scoped client for your shop, then fetches the media server-side with account credentials and streams it through. The raw Twilio media URL is never handed to a browser.
Gap, stated here rather than buried: there is no automated retention or deletion schedule for recordings.
src/lib/calls/log.ts:168-186; src/app/api/voice/recording/[callId]/route.ts
Private bucket, no public object URLs, short-lived signed reads, shop-prefixed object paths enforced server-side, and a nightly sweep that removes never-finalised uploads so abandoned objects do not accumulate.
src/lib/documents/storage.ts:1-47; supabase/migrations/0026_document_portal.sql
Contracts
Sub-processors and the DPA
Honesty
What we do not have yet
- No SOC 2, no ISO 27001, no third-party attestation
We hold no security certification of any kind. There is no report to send you. What we have instead is this page and a codebase we will walk a security reviewer through on a call.
- No independent penetration test
We have not commissioned one. The closest thing is an internal adversarial test suite for the tenant-isolation boundary (
supabase/tests/worker_rls_adversarial.sh), which is real but is not a substitute for an outside firm. - No passkeys, and no way to require 2FA of your team
Two-factor by authenticator app (TOTP) exists and can be switched on per account in Settings → Security; platform administrators must have it before HQ will open. What we do not have: passkeys or WebAuthn, SMS/email second factors, and any setting that lets a shop owner requiretwo-factor of everyone on their team — turning it on is each person’s own choice. If your team signs in with Google instead, whatever MFA your Google Workspace enforces applies.
- Your data is hosted in the United States, not Canada
The production database and object storage run in
AWS us-east-1(Northern Virginia), and serverless functions run in our host’s default US East region. We are a Canadian company, but the data is not held in Canada, and we cannot currently offer a contractual data-residency guarantee — Canadian or otherwise. If Canadian residency is a requirement for you, say so; it is a migration, not a setting. - No custom security response headers
The application sets no Content-Security-Policy, and no X-Frame-Options or Referrer-Policy of its own. We rely on framework defaults and our host’s TLS configuration.
- No documented backup and restore commitment
Backups are whatever our database provider’s plan includes. We have not published a retention period or an RPO/RTO, and we have not rehearsed a restore. Do not treat Service VIN as your only copy of your books.
- No retention schedule for call recordings
Recordings persist at our telephony provider until deleted by hand. There is no automated expiry job.
- One table has RLS enabled but not forced
help_article_feedback(help-article thumbs up/down) is enabled-but-not-forced, unlike the other 99 application tables. Low sensitivity, but it is an inconsistency and we are not going to round it away. - Share-link tokens are stored in plaintext
API keys and impersonation tokens are hashed; customer share-link tokens are not, because the public accessor looks the row up by token. They expire and can be revoked, but a database read would expose live links.
- No customer-managed encryption keys, no BYOK
Encryption keys are managed by us and by our infrastructure providers. There is no key-custody option for customers.
- No published status page or uptime SLA
We do not currently operate a status page, and the Agreement contains no uptime service-level commitment.
- The privileged service credential is a real trust boundary
Our own webhook handlers and background jobs use a credential that bypasses row-level security, because they run without a user session. It is server-only and every call site carries an explicit shop id, but no database mechanism constrains it. Named here for completeness rather than left for you to discover.
Responsible disclosure
Found something? Tell us.
- We aim to acknowledge within two business days and to give you a triage decision within ten business days.
- We will not pursue legal action over good-faith research that stays within the boundaries below.
- Please test only against your own account or a trial account. Do not access, modify or exfiltrate another shop’s data, do not run automated scans that degrade service, and do not use social engineering or physical attacks.
- Give us a reasonable window to fix an issue before publishing it.
We do not currently run a paid bug bounty. We do credit researchers who ask to be credited.
Questions from a security reviewer are welcome and are answered by a person who wrote the code. Related reading: Privacy Policy · Terms of Service