Security overview
One contractor cannot read another's bids, and the database is what stops them.
Every claim on this page is checkable against a named file in the codebase. Where something could not be verified, it says so rather than being phrased around. The section on what is missing is as long as the ones on what is here, because you were going to find out either way and it is better that you find out now.
Last reviewed against the code — 2 August 2026
Tenant isolation is enforced by Postgres, not by our code.
Row-level security is enabled and forced on every tenant table, and the request path runs as a role with no bypass.
drizzle/migrations/0001_rls_policies.sql
A forgotten WHERE clause cannot leak another company’s bids.
The suite runs an unfiltered select as one tenant and asserts the other tenant’s row is absent.
src/db/__tests__/isolation.test.ts
We never see your card details.
Checkout is hosted by Stripe and the server redirects to it. No publishable key is shipped to the browser.
src/app/actions/billing.ts
Your files are private, and download links expire in ten minutes.
A private bucket keyed by organization id, four storage policies gated on membership, signed URLs minted per request.
drizzle/migrations/0005_storage_and_notes.sql
The shape of the system
A Next.js application deployed on Vercel, talking to one Postgres database hosted by Supabase. Files live in a private Supabase Storage bucket. Payments run through Stripe's hosted checkout. Transactional email — invitations to bid, proposals, team invites, password resets — goes out through Resend.
The database and the file storage are in the us-west-2 region, in the United States. That one is a property of how the project is provisioned rather than something the source code states, which is the distinction this page keeps throughout.
That is the whole list of parties. There is no analytics vendor, no advertising pixel, no session-recording tool and no third-party script of any kind in the application.
It is a single shared database rather than one per customer, which makes the isolation model the most important thing on this page. It is treated that way below.
Tenant isolation
Every row that belongs to a customer carries an org_id. Separation is a Postgres row-level security policy on the table — not a WHERE clause the application remembers to add.
Three mechanisms do the work together, and each one closes a hole the others leave open:
- A dedicated database role, app_tenant, created NOLOGIN. It is not a superuser and it does not own any table. That matters because Postgres exempts superusers and table owners from row-level security — so the request path deliberately drops into a role that has no exemption available to it.
- Both enable row level security and force row level security on every tenant table. Enabling alone still exempts the table’s owner; forcing removes that exemption too, so the policy applies even to the role that created the table.
- One policy per table, applied to every command: using (org_id = app.current_org()) with check (org_id = app.current_org()).
app.current_org() reads a per-transaction setting and returns NULL when it is absent. NULL is never equal to anything, so a query that arrives with no tenant context returns no rows rather than all of them. The default is closed, which is the only default worth having here.
At runtime every feature query goes through one helper, withOrg(). It opens a transaction, sets the organization and user ids as transaction-local settings, then issues set local role app_tenant. Transaction-local is the load-bearing detail: the values cannot survive onto the next request that reuses the same pooled connection, and the role reverts at commit for the same reason. The organization id itself comes from the memberships table by way of a server-verified session — never from anything the browser sent.
The consequence is worth stating precisely. select * from projects, with no filter at all, is executed by Postgres as though it read where org_id = app.current_org(). Application code cannot forget the filter, because application code is not the thing adding it. The same expression appears in with check, so an insert or update that tries to stamp a row with a different organization's id is rejected outright rather than quietly succeeding.
Twenty-two tables are covered. Eighteen carry the standard policy above — projects, vendors, vendor contacts, the trade map, solicitations, proposals, notes and note assignees, attachments, library files, the audit log, invites, quote adjustments, addenda and acknowledgements, scope templates and their items, and subscriptions. Three have bespoke policies because their access rules genuinely differ: an organization is visible only to itself, a membership is readable by its own user or within the active organization, and a user row is readable by that user and by teammates who share an organization, writable only by the user themselves.
The twenty-second is the exception that shows the model works. The Stripe webhook ledger has row-level security enabled and forced with no policy at all, and every privilege on it is revoked from app_tenant. A webhook arrives before any tenant context exists, so tenant code has no business reaching that table and is denied it outright rather than by a rule that could be mis-written.
There is a second database client, serviceDb(), which bypasses row-level security. It is legitimate only where no tenant context can exist, and its use is confined to exactly that: creating an organization, mirroring a new user record, claiming an invite, the three functions behind the public proposal page, the Stripe webhook, and a health check that runs select 1. Nothing that serves a signed-in user reads tenant data through it — every one of those paths goes through withOrg(), and that is checkable with a single grep.
The test that proves it
An isolation guarantee nobody tests is a comment. This one is exercised against a live Postgres database — not a mock — on every change. The suite seeds two organizations with one project each and then does everything as the first one.
- An unfiltered select of every project returns the first organization’s row and does not contain the second’s. This is the direct proof that a missing WHERE clause cannot leak.
- Reading the other organization’s project by its primary key returns zero rows. Knowing another tenant’s id grants nothing.
- Updating that row by primary key affects zero rows — and a separate privileged read confirms the row itself is untouched, rather than trusting the reported count.
- Deleting it by primary key affects zero rows.
- Inserting a row stamped with the other organization’s id throws. The test walks the whole error-cause chain, asserts the message is a row-level security violation, and then confirms no such row exists anywhere in the table.
- A positive control: the acting organization can insert and read back its own row. Without this one, all five negatives would also pass if the database connection were simply broken.
A sibling suite extends the same method to the money tables: one organization cannot see what another pays, cannot rewrite or delete another's subscription, is denied the webhook ledger outright rather than by policy, and a Stripe event id is recorded exactly once however many times it arrives. It also asserts that a write carrying a version somebody else has already moved is refused.
Two honest limits. The suite needs a database: with no DATABASE_URL it skips with a warning rather than failing, so a continuous integration setup that forgets to point it at a disposable database proves nothing. And it exercises the mechanism on projects, subscriptions and the webhook ledger specifically — it does not iterate all twenty-two tables one at a time. The mechanism it tests is table-independent, which is a claim about the mechanism, not an enumeration of the schema.
Authentication, sessions and roles
Authentication is Supabase Auth, with email and password. Email confirmation is required at sign-up, and password reset is by emailed link.
Passwords are never stored in this application's database. Supabase holds them, hashed, in a schema the application does not query — there is no password column anywhere in the product schema to leak.
Sessions are JWT cookies. The proxy layer refreshes them on each matched request, and is explicit in its own comment that it is not the authorization boundary: the redirects it performs exist only to avoid rendering a page that would immediately bounce. The real boundary is server-side, and it is re-derived on every request. Identity is resolved by asking Supabase to verify the token rather than by decoding a cookie, and the active organization and role are read back out of the memberships table each time. Nothing about who you are or what you may do is taken from the client.
There are three roles — viewer, estimator and admin — ranked in that order, so a check for estimator also admits an admin. Every server action that touches tenant data calls a guard before it does anything else, and the authorization is re-derived inside the action rather than inherited from the page that rendered the form.
Two sets of functions do not, for reasons that are visible in what they are: the ones that establish or recover a session in the first place — signing up, signing in, requesting a password reset — and the three that serve the public proposal page. Each of those three is gated instead on a strict thirty-two-hex-character token format.
Those proposal tokens are sixteen bytes from the platform cryptographic random generator, rendered as thirty-two hex characters: 128 bits. The token is the entire authorization for that page, which is why the page is excluded in robots.txt and deliberately never appears in a sitemap — a crawler that reached one through a referrer header would publish a customer's live bid material.
A failed sign-in returns one generic message whatever went wrong, so the form does not disclose whether an account exists for a given address.
What is not here: there is no multi-factor authentication, no account lockout and no login throttling implemented in this application, and the password rule is a minimum of eight characters. Any rate limiting in front of sign-in is whatever Supabase Auth does by default; this codebase does not configure it and makes no claim about it.
Files and the ten-minute link
Drawings, quotes, certificates of insurance and W-9s go into a Supabase Storage bucket that is private — not a public bucket with obscure names. Every object is keyed <org_id>/projects/<project_id>/<uuid>-<filename>, with a 50 MB per-file limit.
Four storage policies — read, write, update and delete — each check that the caller belongs to the organization named in the first path segment of the key. The membership check is a deliberately narrow function that answers one boolean about the calling user and nothing else, with a pinned search path and execution granted only to authenticated roles. It has to be written that way because the memberships table is itself under forced row-level security keyed on a setting Supabase does not populate while evaluating a storage policy.
Downloads are signed URLs, minted per request and valid for ten minutes. They are never stored, so there is no durable link to leak out of a database, an email thread or a browser history months later.
Payments
Card details never reach Wire-In. Checkout is hosted by Stripe: the server creates a session and redirects the browser to Stripe, so the card number is entered on Stripe's infrastructure and returns to us as nothing more than an identifier.
- No publishable Stripe key is shipped to the browser. There is no NEXT_PUBLIC_STRIPE_ variable in the configuration and the documented instruction is that none should be added.
- The webhook verifies Stripe’s signature itself and checks that live and test mode agree before acting on an event.
- The webhook route is excluded from the proxy matcher on purpose, so nothing touches the raw request body that the signature is computed over.
- Each Stripe event id is recorded exactly once, so a replayed or retried event does not apply twice. There is a test for that specific behavior.
What is stored on our side: a Stripe customer id, a subscription id, the tier, the declared annual construction volume, which modules were bought, the status and the period end. No card number, no expiry, no security code, at any point.
What the product actually holds
The most sensitive material in this system is not personal data. It is commercial: project values, submitted totals, outcomes, who was invited to bid, what they quoted, which adjustments were applied and whether the job was won. For a general contractor that is more valuable to a competitor than any name or email address in the database, and the isolation model above exists mainly to protect it.
Vendor records hold a company name, trades, divisions, service areas, status, rating, website, category and a contractor license number. Vendor contacts hold a name, an email address, a phone number and a role.
Documents are stored as files and classified by kind — bid summary, proposal, quote, certificate of insurance, W-9, or other. A W-9 contains a taxpayer identification number, and this is where precision matters: the product stores the document you uploaded, and records only its filename, storage key, content type, size, who uploaded it and its kind. Nothing extracts, indexes, searches or displays the identifiers inside it. There is no taxpayer id, employer id, social security number, bank account or routing number column anywhere in the schema.
User records hold an id, an email address and a full name. Proposals hold the recipient email addresses they were sent to and their delivery token.
Concurrency is resolved by the database
Two estimators editing the same bid on bid day is not a hypothetical. Mutable rows carry a version, and an update is conditional on the version the writer last read. A write that arrives against a version somebody else has already moved affects zero rows and is reported as a conflict.
There is no client-side merge code anywhere in the product, deliberately. Merging in the browser is how one estimator's number silently overwrites another's, and on bid day that is a wrong bid rather than a lost keystroke.
The audit log, and what it is not
Bid lifecycle transitions, invitation-to-bid sends and billing events write rows recording the organization, the actor, the action, the target and structured detail.
The log is readable inside the product by an administrator. It is restricted to that role deliberately: it is a record of what every person in the organization did, and billing entries carry the company's tier, declared construction volume and price.
One qualification matters more than the feature. The log is append-only by convention in the application, not by a database constraint — the table carries the ordinary tenant policy and the tenant role holds update and delete grants on it. So it should not be described as immutable or tamper-evident, and it is not described that way here. If you need an audit trail that survives an insider with a database connection, this is not yet that.
What we do not have
This list is deliberately complete as of the review date above. If you are comparing vendors, the useful question is not whether a list like this exists but whether the one you were shown was written by someone willing to put the whole of it on a public page.
- No SOC 2, no ISO 27001, no third-party penetration test and no external audit of any kind. None is in progress. What exists in their place is on this page and in the repository.
- No multi-factor authentication, no account lockout and no login throttling implemented here. The password rule is a minimum of eight characters.
- No security response headers. HSTS, content security policy, X-Content-Type-Options, Referrer-Policy and X-Frame-Options are all unset — the Next.js configuration adds none. This is cheap to fix and has not been fixed.
- No application-level or field-level encryption. No column is encrypted by the application. Encryption at rest is a property of the hosting provider rather than a control Wire-In implements or can evidence.
- TLS on the database connection is not configured in this repository. Whether it is negotiated depends on the connection string in the deployed environment, so it is not claimed here either way.
- No published uptime commitment, and no documented backup or restore schedule. Backups are whatever the hosting plan provides, which is not something this codebase can attest to.
- Development and production currently share one database, so a leaked development credential reaches live data. Separating them is an open item and is the most material gap on this list.
- The audit log is append-only by application convention rather than by a database constraint, so it is not tamper-evident.
- Error tracking is not enabled. A crash in production is not reported anywhere automatically.
- The isolation suite skips, loudly, when no database URL is present. It has to be pointed at a disposable database to prove anything, and that is an operational responsibility rather than a code guarantee.
How to check any of this yourself
None of the above is a claim you have to take on trust if you are far enough along to be reviewing source under an agreement. The relevant files are short and are meant to be read:
- drizzle/migrations/0001_rls_policies.sql — the app_tenant role, the enable and force lines, and the isolation policy on every tenant table.
- src/db/index.ts — withOrg(), which sets the transaction-local tenant context and drops into app_tenant before any feature query runs.
- drizzle/migrations/0005_storage_and_notes.sql — the private bucket and the four storage policies.
- drizzle/migrations/0009_billing.sql — the subscription policy, and the webhook ledger with its privileges revoked.
- src/db/__tests__/isolation.test.ts and src/db/__tests__/billing-isolation.test.ts — the adversarial suites. Point DATABASE_URL at a disposable database and run npm test.
Reporting a problem
If you find something, write to hello@wire-in.network. There is no bug bounty and no formal disclosure program, and saying so is more useful than implying one exists.
One request: do not test against another customer's data. If you want to probe the isolation model, the test suite above does exactly that against a database you control.