SecuritySecurity posture

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

LayerTechnologyWhat it gives us
Frontend hostingVercel (Fluid Compute, Node 24)DDoS absorption, edge TLS, automatic patching
ApplicationNext.js 16 (App Router, TypeScript strict)Type-safe code, no implicit any, framework-provided XSS escaping
AuthSupabase Auth (GoTrue)JWT sessions, refresh-token rotation, TOTP MFA, AAL hierarchy
DatabaseSupabase Postgres 15Row-level security, role-based access, point-in-time recovery
Object storageSupabase Storage (S3-backed)Signed URLs, bucket-level policies, encryption at rest
Engine runtimeRust (pack-cli binary, child_process.spawn)Memory-safe compute path, no JS-side packing of customer geometry
EmailResendTransactional 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"
RoleWhat they can do
adminFull read/write across the org. Manages users, pricing, suppliers, materials.
managerAll inventory + analytics. Cannot manage users or pricing.
supervisorApproves cuts. Reads analytics. Cannot manage suppliers or pricing.
cutterOperates the cutting engine + creates cuts. Reads inventory.
memberDefault for new users. Read-only inventory access.
viewerRead-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:

HeaderValueWhat it does
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preload2-year HSTS, preload-list eligible — browsers refuse plain-HTTP on the apex and any subdomain.
X-Frame-OptionsDENYLegacy click-jacking defense. Sent alongside frame-ancestors 'none' for older browsers.
X-Content-Type-OptionsnosniffStops MIME-type sniffing — a .txt response stays a .txt response.
Referrer-Policystrict-origin-when-cross-originOutbound links never leak the path or query string.
Permissions-Policycamera=(), microphone=(), geolocation=(), browsing-topics=()Disables browser features the app does not use, locking down a compromised iframe or extension.
Content-Security-Policy-Report-Onlystrict allowlists per asset typeCurrently 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

WhatHowRPO
Postgres databaseSupabase point-in-time recovery, 7-day retention< 5 minutes
Object storageSupabase Storage replicates within us-east-1< 5 minutes
Cross-region failoverNot implemented todayn/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.
  • CookiesHttpOnly, Secure, SameSite=Lax for 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 audit as 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.