Security posture
We are not SOC 2 certified. We run a strong technical posture, our data lives in SOC 2-compliant infrastructure (Supabase / AWS), and the engineering controls below are in production code today. We have not yet engaged a SOC 2 auditor and do not have a Type I report. We’re being explicit about this rather than implying otherwise — see the SOC 2 roadmap for the path.
This page is the unvarnished list of what’s running in production code today. Every claim points to either a file path in the repository, a Postgres construct, or a third-party guarantee from Supabase / AWS / Vercel. We treat the docs as a contract — if something on this page isn’t true tomorrow, it gets edited tomorrow.
The stack at a glance
| Layer | Technology | What it gives us |
|---|---|---|
| Frontend hosting | Vercel (Fluid Compute, Node 24) | DDoS absorption, edge TLS, automatic patching |
| Application | Next.js 16 (App Router, TypeScript strict) | Type-safe code, no implicit any, framework-provided XSS escaping |
| Auth | Supabase Auth (GoTrue) | JWT sessions, refresh-token rotation, TOTP MFA, AAL hierarchy |
| Database | Supabase Postgres 15 | Row-level security, role-based access, point-in-time recovery |
| Object storage | Supabase Storage (S3-backed) | Signed URLs, bucket-level policies, encryption at rest |
| Engine runtime | Rust (pack-cli binary, child_process.spawn) | Memory-safe compute path, no JS-side packing of customer geometry |
| Resend | Transactional only (magic links, password reset, MFA recovery) |
The app is deployed in AWS us-east-1 via Supabase. No customer data leaves the U.S. region.
Authentication
Supabase Auth + JWT
Every dashboard request carries a Supabase-issued JWT in an HTTP-only, Secure, SameSite=Lax cookie. Tokens carry standard claims (sub, aud, exp) plus an app_metadata.role claim populated from the profiles.role column at sign-in. Refresh tokens rotate on every refresh — a stolen access token is good for at most 15 minutes; a stolen refresh token invalidates the session as soon as the legitimate user signs in again.
Where it’s wired: src/infrastructure/services/authService.ts wraps supabase.auth.signIn, signOut, and refreshSession. The Next.js middleware at proxy.ts reads the cookie, validates the JWT against Supabase’s public key, and attaches the decoded user to request.headers so downstream routes don’t re-validate.
MFA — TOTP enrollment + challenge
TOTP (RFC 6238) is the supported second factor. Users enroll from Settings → Profile → Multi-factor authentication; enrollment generates a Supabase-managed factor with a QR code and stays unverified until the user submits a valid 6-digit code. Verified factors upgrade the session to AAL2 (Assurance Level 2).
Sign-in flow: When signIn succeeds and the account has a verified factor, the auth service returns { mfaRequired: true, factorId }. The login form (app/(auth)/_components/login-form.tsx) swaps to an inline 6-digit input — no second page, no redirect. completeMfaChallenge(factorId, code) calls supabase.auth.mfa.challengeAndVerify() and, on success, the session is upgraded to AAL2 and the user is redirected to redirectTo.
Where it’s wired: src/infrastructure/services/authMfaService.ts (factor management); app/(auth)/_components/login-form.tsx (inline challenge UI); app/(auth)/mfa-challenge/page.tsx (standalone challenge page for re-auth flows); app/dashboard/settings/profile/_components/mfa-card.tsx + mfa-enroll-dialog.tsx (enrollment).
MFA is opt-in today. Any user who enrolls a factor is challenged on every sign-in. Hard-enforcing enrollment for admin and manager roles (so a user with one of those roles cannot complete sign-in without an enrolled factor) is on the Q1 SOC 2 roadmap. It is not enforced today.
Email-based recovery
Password reset and email verification flow through Supabase + Resend with single-use, time-limited tokens. The redirect URL allow-list is configured per-environment so a forged reset link cannot redirect to an attacker-controlled domain.
Authorization
Six-role RBAC
Roles are defined as a TypeScript discriminated union — there is no string-typing of role names anywhere in the app:
// src/application/permissions.ts
export type AppRole = "admin" | "cutter" | "manager" | "member" | "supervisor" | "viewer"| Role | What they can do |
|---|---|
admin | Full read/write across the org. Manages users, pricing, suppliers, materials. |
manager | All inventory + analytics. Cannot manage users or pricing. |
supervisor | Approves cuts. Reads analytics. Cannot manage suppliers or pricing. |
cutter | Operates the cutting engine + creates cuts. Reads inventory. |
member | Default for new users. Read-only inventory access. |
viewer | Read-only across whatever they’re explicitly granted. |
The full matrix lives in src/application/permissions.ts and is rendered as docs at Roles & RBAC → Permission matrix.
Enforced twice — at the edge and at the data layer
Defense in depth: a request must pass both layers to read or write data.
Layer 1 — Edge (Next.js middleware). proxy.ts reads the JWT, resolves the user’s role from app_metadata, and matches the requested path against the ROUTE_PERMISSIONS map in src/application/permissions.ts. A cutter who hits /dashboard/inventory/materials/pricing is redirected to /dashboard before any code in that route runs. The canAccessPath() helper at app/lib/rbac.ts is used by the sidebar and the middleware so the navigation cannot show a route the user cannot reach.
Layer 2 — Database (Postgres RLS). Every customer-facing table has Row Level Security enabled and at least one policy that gates access by auth.jwt()->'app_metadata'->>'role'. If the middleware were bypassed (it can’t, but assume the worst), the database would still refuse to return rows the JWT cannot see. There are 26 ENABLE ROW LEVEL SECURITY statements and 157 CREATE POLICY statements across the migration set in src/infrastructure/supabase/migrations/.
The tables under RLS: profiles, organizations, rolls, material_types, material_suppliers, suppliers, price_history, tax_rates, reorder_rules, purchase_orders, purchase_order_items, cuts, cut_history, cut_history_favorites, jobs, job_parts, job_assignees, job_roll_allocations, notifications, notification_preferences, notification_queue, plus the admin schema (admin.admin_activity_log, admin.alerts, admin.deployment_log, admin.health_checks, admin.health_endpoints).
Data layer
Postgres RLS — the database is the last line of defense
Policies are written so that a JWT must explicitly belong to the row’s organization (or be admin) before any row is returned. Service-role keys are never shipped to the browser — they’re scoped to server-side code that has already passed the edge auth check.
Advisory locks during pack solves
When the cutting engine solves a layout, the chosen rolls are claimed under Postgres advisory locks keyed by roll_id. Two concurrent operators pressing Pack at the same instant cannot both consume the same roll: the second solve waits, sees the roll is gone, and re-solves with an alternate. This is a hard concurrency guarantee, not an “eventually consistent” UI hint.
Where it’s wired: the lock is acquired in the Rust pack-cli binary’s per-request flow before the layout is committed; the Postgres-side counter on rolls.remaining_length decrements inside the same transaction. The behavior is documented under Cutting engine → Auto Nest.
No customer geometry in the JS heap
Production packing runs in a Rust binary (engine/crates/pack-cli, MIT-licensed) over JSON stdin/stdout. The Next.js process spawns it via child_process.spawn and forwards the request body. There is no TypeScript fallback packer — the production solve always lands on Rust. This means customer part geometry never lives in a long-running Node heap; the binary exits when the response is written.
Network + transport
Security headers (every response carries them)
Implemented in src/middleware/security-headers.ts and applied by the proxy middleware to every dashboard response:
| Header | Value | What it does |
|---|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | 2-year HSTS, preload-list eligible — browsers refuse plain-HTTP on the apex and any subdomain. |
X-Frame-Options | DENY | Legacy click-jacking defense. Sent alongside frame-ancestors 'none' for older browsers. |
X-Content-Type-Options | nosniff | Stops MIME-type sniffing — a .txt response stays a .txt response. |
Referrer-Policy | strict-origin-when-cross-origin | Outbound links never leak the path or query string. |
Permissions-Policy | camera=(), microphone=(), geolocation=(), browsing-topics=() | Disables browser features the app does not use, locking down a compromised iframe or extension. |
Content-Security-Policy-Report-Only | strict allowlists per asset type | Currently in report-only mode — see below. |
CSP is currently Report-Only
The CSP is shipped as Content-Security-Policy-Report-Only rather than Content-Security-Policy. It defines strict allowlists (default-src 'self', frame-ancestors 'none', object-src 'none', scoped connect-src to Supabase) but violations are not blocked — they’re observable in browser devtools and reportable to a wired endpoint. Promoting from report-only to enforcing requires per-request nonces in the layout, which is the next step in this work and is tracked on the SOC 2 roadmap.
TLS 1.3 in transit
Vercel terminates TLS 1.3 at the edge. Origin → Supabase is TLS 1.3 over the AWS internal network. There is no plaintext hop anywhere in the request path.
Audit + observability
Admin activity log
Sensitive admin actions write to admin.admin_activity_log. The schema captures the actor (actor_id + actor_email), the action string, the affected resource (resource_type + resource_id), action-specific context as metadata JSONB, plus ip_address INET and user_agent TEXT for request fingerprinting. The table is RLS-protected — only the admin role can read it (admin_activity_log: admins select policy gates on admin.is_admin()).
Resource types are constrained at the schema level via a CHECK constraint to: health_endpoint, health_check, deployment, alert, org, user, system. The audit log is intentionally scoped to admin-tier actions — it is not a general change-tracking table for every inventory edit.
The current schema captures action context as a single metadata JSONB blob. Splitting that into explicit before_state / after_state columns and back-filling ip_address / user_agent on every call site (the columns exist but some inserts leave them NULL) is on the SOC 2 roadmap under Q2.
Where it’s wired: migration 20260418000001_create_admin_tables.sql creates the table; src/infrastructure/services/alertManagementService.ts and admin-tier use-cases append to it.
Cut history is a permanent record
Every committed cut writes a row to cut_history with the full layout JSON, the rolls consumed, the operator, the timestamp, and a yield percentage. The row is immutable from the application — there is no UPDATE cut_history route, and the RLS policy blocks updates from non-admin roles. Replaying a cut means reading the JSON, never re-running the solve.
No third-party telemetry on the dashboard
The marketing site (get.rubberfit.app) uses Vercel Analytics (cookieless, privacy-preserving). The dashboard application sends zero telemetry to third-party trackers. No Mixpanel, no Segment, no Datadog RUM. Internal logs go to Vercel + Supabase log views and are scoped to operator-debug needs.
Document sharing
Customer-facing job PDFs use signed URLs
When a customer-facing job PDF is generated (the receipt-style document customers actually see — order summary, dimensions, due date, support contact), it’s written to a non-public Supabase Storage bucket. A short-lived signed URL is issued via supabase.storage.from(...).createSignedUrl(...) and embedded in the email or share link. After the TTL expires, the URL stops resolving — the file remains stored, but you cannot fetch it without re-signing.
Where it’s wired: src/infrastructure/services/jobPdfShareService.ts. Signing is server-side only; the client never holds the service-role key.
What’s in the customer PDF: order summary, dimensions, quantity, total, due date, customer barcode for tracking. What’s not: operator notes, cut-history layouts, yield percentages, internal pricing, audit-log entries, nesting diagrams. This is enforced at PDF-generation time, not at presentation time.
Cut-history PDFs use public URLs (today)
Cut-history layout PDFs — the internal artifact a manager downloads to inspect a past cut — are written to a Supabase Storage bucket and currently served via getPublicUrl, not a signed URL. The bucket lives under a UUID-based path that is non-enumerable, but a leaked URL is good until rotated. Migrating these to signed URLs is on the SOC 2 roadmap under Q3.
Where it’s wired: src/infrastructure/services/cutPdfStorageService.ts.
Code quality + supply chain
TypeScript strict, ESLint zero-warnings, Prettier
The codebase is strict: true with noImplicitAny, noUncheckedIndexedAccess, and the rest of the strict family. There are zero any casts in production code; unknown + narrowing is the standard. ESLint runs on every PR (the lint status check is required for merge); Prettier formatting is checked the same way. Pre-commit hook chain: typecheck + lint + format:check + fmt:rust + clippy:rust + test.
Why this is a security control: type errors and lint warnings are how XSS, prototype pollution, and accidental eval-equivalents creep in. Treating them as build-breakers is the cheap version of static analysis.
Clean architecture — only infrastructure talks to Supabase
The codebase enforces a layer rule: only files under src/infrastructure/ may call supabase.* directly. Application code (Zustand stores, Next.js routes, components) calls service interfaces from src/application/, which have a single concrete implementation in infrastructure. A grep for supabase.from( outside src/infrastructure/ returns zero hits.
Why this matters: SQL injection, RLS-bypass attempts, and “I’ll just use the service-role key here” hacks have nowhere to live. The blast radius of any data-access bug is one file.
Dependency posture
npm audit and cargo audit are not yet wired as fail-on-high CI gates. They run locally and on demand. Wiring them as required CI checks is the Q1 SOC 2 roadmap item — it is not in production today, and pretending otherwise would be dishonest.
Secrets
All secrets live in Vercel + Supabase environment variables. The repository contains zero .env files, zero hard-coded API keys, and zero service-role tokens in JS. .gitleaks would find nothing actionable.
Backups + disaster recovery
| What | How | RPO |
|---|---|---|
| Postgres database | Supabase point-in-time recovery, 7-day retention | < 5 minutes |
| Object storage | Supabase Storage replicates within us-east-1 | < 5 minutes |
| Cross-region failover | Not implemented today | n/a |
Recovery objective for a regional AWS outage is 24 hours. Cross-region replication is not on the immediate roadmap — Supabase itself runs multi-AZ within the region, which covers the realistic failure modes (single-AZ outage) without our involvement.
Encryption
- At rest — Supabase runs on AWS with per-volume EBS encryption. Object storage uses S3 SSE. This is a Supabase platform guarantee, not something the application configures or holds keys for.
- In transit — TLS 1.3 client → edge, TLS 1.3 edge → origin, TLS 1.3 origin → Supabase. No plaintext hop.
- Cookies —
HttpOnly,Secure,SameSite=Laxfor session and refresh tokens.
Not yet in production
Honest list of what would still earn a “no” on a security questionnaire:
- Forced MFA enrollment for admin and manager roles — supported, not yet hard-enforced
- CSP in enforcing mode — currently report-only; promotion blocked on per-request nonces
- Before/after JSONB snapshots + IP/user-agent capture in the audit log — current schema is metadata-only
- Signed URLs for cut-history PDFs — currently public-by-bucket; signing pending
npm audit/cargo auditas CI gates — runs locally, not yet build-breaking- External SIEM / log aggregation with 1-year retention — Vercel + Supabase logs only
- Formal security policy + risk register — drafted, not yet codified
- Third-party penetration test — will be commissioned as part of SOC 2 prep
- Formal incident-response plan — drafted, not yet exercised
- Field-level encryption beyond Supabase default — not yet
- SOC 2 Type I or Type II certification — no auditor engaged yet
Each of these has a quarter on the SOC 2 roadmap.
Got a security questionnaire?
Email security@rubberfit.app with your questionnaire. We answer questionnaires honestly, even when the honest answer is “not yet — here’s the timeline.” Most questions can be answered from this page plus Data handling plus the SOC 2 roadmap.