Security
What we actually ship
A plain list of the security and tenancy controls FirmWorks runs in production — no marketing-grade certifications we haven’t earned, no vague promises.
Data residency & storage
- Application and primary database hosted in Singapore (ap-southeast-1) on Vercel and Neon. All transit is over TLS 1.2+.
- Postgres is encrypted at rest by Neon. Vercel Blob (file uploads) sits in the same region.
- Daily backups taken by Neon, retained for 30 days (encrypted), with point-in-time recovery available within the retention window.
Authentication
- Passwords stored as salted hashes via Better Auth — we never see plaintext.
- Database-mode rate limiter on credential endpoints — 5 attempts / 15 min on
/sign-in,/sign-up, and/reset-password; 3 / 15 min on/forget-password. - Vercel BotIDwired on every auth endpoint — blocks credential-stuffing, sign-up spam, and password-reset spam without a captcha gauntlet for real users.
- Optional magic-link sign-in for invited members, scoped to their email.
Tenancy & isolation
- Every database query is filtered by your organization id server-side. No shared-database permission tricks.
- Cross-tenant isolation is locked in by an integration test in
tests/cross-tenant.test.ts— it round-trips real rows through Postgres in two orgs and asserts a query in org A cannot see org B’s data. - Optional email-domain allowlist per org, so invitations can only go to addresses on your approved domains.
- Per-org audit logof administrative actions — captures actor, IP address, user agent, and entity touched. Visible to org owners and admins.
Observability
- OpenTelemetry instrumentation via
@vercel/otel, auto-collected on Vercel deploys. For self-host or Datadog/Honeycomb forwarding, setOTEL_EXPORTER_OTLP_ENDPOINTand the SDK forwards traces to it. - Errors and security-relevant events surface to the audit log; org owners can read them at
/app/settings/audit.
Privacy posture
- No third-party analytics or advertising cookies on marketing surfaces.
- We do not use workspace data to train AI models.
- One first-party session cookie (
firmworks.session_token) is the only auth state stored on a browser.
What we don’t claim
We don’t hold a SOC 2 Type II report. We don’t hold ISO/IEC 27001. If your procurement process requires either, ask us — we’ll be honest about whether the trade-off makes sense for your size and ours, and what would unlock starting the journey.
We don’t run a paid bug-bounty program yet. Vulnerability disclosures go through the responsible-disclosure flow below.
For procurement teams: our Data Processing Addendum covers GDPR Art. 28 / Singapore PDPA / Thailand PDPA obligations — sub-processor list, 72-hour breach notification, data-subject rights flow, and audit rights are all documented there. Counter-signed PDF is available on request from legal@firmworks.com.
In flight
Security and tenancy work the team is actively shipping. None are blocked on a customer ask — if any of these gates a procurement decision, email security@firmworks.com and we’ll prioritise.
- Drizzle migrations checkpoint. Today we sync schema with
db:push; switching to checked-in migrations underlib/db/migrations/before the first paying customer ships. Adds reproducible dev/staging/prod schema history. - Audit-log retention extensions. Today the audit log retains 1 year per the privacy policy. Optional longer retention windows (3 / 7 years) for Business-plan customers with compliance obligations are being scoped.
- Per-org API keys. Schema, plaintext issuance + revoke flow, and a verified-test path are live; the public-API entry path is being wired so customers can build automations without re-using their session cookie.
Recently shipped
Defence-in-depth on the billing + upload paths landed in the last week. Each item is live in production today; no environment toggle, no opt-in.
- Stripe webhook idempotency. New
stripe_event(id text PK)table de-duplicates Stripe’s at-least-once delivery contract: a retry of the sameevent.idshort-circuits with a 200 +{ deduplicated: true }before the event handler runs, so half-applied side-effects can’t compound on retry. - Cancellation revokes paid features. The
customer.subscription.deletedbranch now writesplan = 'trial'on cancel — churned customers can’t keep paid-tier access after their Stripe subscription ends. - First-paid-checkout creates the row. The
checkout.session.completedbranch is now anINSERT … ON CONFLICT (orgId) DO UPDATEinstead of a bare update — previously the first paid checkout could silently no-op when no subscription row existed yet, leaving the customer paid-but-unseated. - Hardened file uploads.
/api/uploadnow enforces a MIME whitelist (PNG, JPEG, WEBP, PDF), a 25 MB size cap (trusts the larger offile.sizeandContent-Length), an RBACauthorize(role, 'docs', 'attach')check (viewers get 403), and a UUID path so two uploads in the same millisecond can’t collide. Closes the prior gap where a member could upload an SVG with inline JS to a public Blob URL.
Responsible disclosure
We don’t run a paid bug-bounty programme today, but we welcome responsible disclosure and will respond within 5 business days of receipt.
- Email security@firmworks.com with reproduction steps, affected URL/endpoint, and the impact you observed.
- A PGP keyfor encrypted reports is available on request from the same address — reply with “PGP please” and we’ll send the public key.
- Please don’t open public GitHub issues, post on Twitter, or run intrusive scans against the production environment until the issue is resolved — we’ll credit researchers who report responsibly when the fix ships.
- Out of scope: missing security headers on marketing pages, denial-of-service via volumetric attacks, social engineering of staff, and findings that require root access on a customer’s own device.
Need to dig deeper?
Our privacy policy covers sub-processors, retention, and rights under PDPA / GDPR. The Data Processing Addendum covers our processor obligations. For security questionnaires, email security@firmworks.com.