Skip to content

Information Security — Milestone Proposal

Status: In Progress · April 2026

Defines the security baseline required before the platform can handle real-money transactions at scale. Covers data protection, access control, secrets management, and threat modelling.

IS-4, IS-5, IS-6, IS-7, IS-9, IS-10, IS-11, IS-12 implemented. IS-1, IS-2, IS-3, IS-8 remain. IS-9/IS-11 transition gaps closed (resolveActor, campaigns public_id, session-submissions guard) — strict enforcement requires deploying REQUIRE_SESSION_AUTH=true + SESSION_SECRET.


Problem

The platform manages USDC escrow, streamer KYC documents, wallet addresses, and operator API credentials. Current security posture is functional but informal — adequate for a closed beta, insufficient for open production.

Key gaps identified:

  • No formal threat model
  • Secrets stored in .env files with no rotation policy
  • No audit log for sensitive operations (KYC status changes, deal funding, payout confirms)
  • No rate-limiting on auth or sensitive endpoints
  • KYC documents stored in Cloudflare R2 with no access expiry on presigned URLs
  • No security headers policy (CSP, HSTS, referrer policy)
  • No penetration test or external security review
  • All routes lack server-side auth middleware — actor_id is trusted from the request body
  • Sequential integer IDs in URLs enable resource enumeration and cross-operator probing
  • Write endpoints lack row-level ownership checks
  • Admin-only routes have no server-enforced gate

Proposed Milestone — M-IS

A dedicated security milestone to be completed before opening the platform beyond the current invite-only cohort.

IS-1 · Threat Model

  • Document assets, trust boundaries, and attack surfaces
  • Cover: operator portal, streamer portal, API, smart contract, R2 storage, Pusher channel auth
  • Output: threat model doc + risk register with severity ratings

IS-2 · Secrets & Credentials

  • Migrate all secrets from .env files to a secrets manager (Doppler or Cloudflare secrets)
  • Define rotation schedule for: R2 keys, Pusher secrets, Resend API key, operator API keys
  • Audit all places secrets are logged or emitted in error responses

IS-3 · Audit Log

  • Add an audit_log table: (id, actor_type, actor_id, action, target_type, target_id, metadata JSONB, created_at)
  • Log: KYC status changes, deal funding, payout confirms, allocation creation, operator account provisioning
  • Expose read-only audit trail in the admin panel

IS-4 · Rate Limiting & Brute-Force Protection done

  • POST /api/auth/send-pin — 3 requests / 10 min per email
  • POST /api/auth/verify-pin — 5 attempts / 15 min per email
  • Global API rate limit per IP for unauthenticated endpoints (pending)

IS-5 · R2 Document Security done

  • ✅ KYC documents are API-proxied (never presigned) — access is gated per-request
  • Cache-Control: private, no-store on download responses — no browser caching
  • R2 bucket public-access policy audit (pending)

IS-6 · Security Headers done

  • hono/secure-headers middleware applied globally on the API (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
  • Frontend (Cloudflare Pages) security headers pending _headers config

IS-7 · Dependency Audit & SBOM done

  • npm audit --audit-level=high added to deploy-api.yml (advisory — non-blocking until baseline is clean)
  • SBOM generation for Docker image (pending)

IS-8 · External Penetration Test

  • Engage an external security firm for a scoped pentest covering the API and smart contract
  • Resolve all critical and high findings before public launch
  • Smart contract formal verification or third-party audit (separate from Forge test suite)

IS-9 · Authentication Middleware done (transition mode)

Problem. Every route handler today receives actor_id from the request body and trusts it without server-side verification. Any caller can impersonate any operator or streamer.

Approach.

  1. On successful PIN verification (POST /api/auth/verify-pin), issue a signed session token (HMAC-SHA256 over { actor_type, actor_id, iat, exp } using a SESSION_SECRET env var). Return it as a Set-Cookie: session=<token>; HttpOnly; Secure; SameSite=Strict header and as a JSON field for mobile/SPA usage.
  2. Add a verifySession(req, env) helper that reads the cookie (or Authorization: Bearer header), verifies the HMAC, and returns { actor_type, actor_id } or null.
  3. Add a Hono middleware that runs before all non-public routes, calls verifySession, and returns 401 on failure.
  4. Remove all actor_id / operator_id / streamer_id from request bodies on protected routes — derive them exclusively from the verified session.

Public routes (exempt from middleware): POST /api/auth/*, GET /api/campaigns, GET /api/campaigns/:id, GET /api/streamers (directory), GET /api/streamers/:u (public profile fields only), POST /api/webhooks/*, GET /api/oauth/*.

Migration path. Can be deployed feature-flagged: new clients send the session cookie, old clients continue to pass actor_id in body during a transition window. Once all clients updated, remove the body fallback.

Implementation status (April 2026):

  • session.tsissueSession, verifySession, sessionCookieHeader, resolveActor
  • ✅ Session cookie issued on PIN verify, kick-token, and impersonate/redeem auth flows
  • ✅ Global Hono middleware enforces session when REQUIRE_SESSION_AUTH=true
  • SESSION_SECRET absent → no-op (transition mode active)
  • resolveActor() helper — session wins; body fallback in transition mode; null in strict mode
  • ✅ All write handlers use resolveActor (offers PATCH, deals PATCH + POST /deal, negotiations force-agree, campaigns POST, applications POST apply)
  • Remaining: set REQUIRE_SESSION_AUTH=true + SESSION_SECRET in production env once frontend is confirmed sending session cookies on all write requests

IS-10 · Opaque Public Identifiers done (core tables)

Problem. All resource IDs are sequential integers. An attacker can enumerate every deal, offer, and submission by incrementing the URL integer, discover total platform activity, and cross-reference with missing auth guards to act on foreign resources.

Design. Add a public_id TEXT UNIQUE NOT NULL column to each affected table. Generate on insert via a PostgreSQL function using the Crockford base-32 alphabet (0123456789ABCDEFGHJKMNPQRSTVWXYZ — excludes I, L, O, U to prevent visual confusion). 256 mod 32 = 0 gives perfectly uniform distribution with no rejection sampling. The internal INTEGER primary key is retained for all foreign keys and joins — never exposed in URLs.

TablePrefixExampleStatus
offersOFF-OFF-A3BF9K✅ done
negotiationsNEG-NEG-2WBTZ6✅ done
dealsD-D-K9X3F2✅ done
session_submissionsSS-SS-P1KV9C✅ done
campaignsC-C-4NHJX8✅ done (migration 0094)

Implementation status (April 2026):

  • generate_public_id(prefix TEXT) PostgreSQL function (migration 0093)
  • public_id columns added, backfilled, NOT NULL + UNIQUE indexed, DEFAULT set on all five tables
  • publicId.tsresolvePublicId() resolver; all route regexes updated
  • ✅ Frontend id types changed number → string for Offer, Negotiation, Deal, SessionSubmission, CampaignApplication, Campaign
  • ✅ Migration 0094_campaign_public_id.sqlC- prefix on campaigns table
TablePrefixExampleStatus
offersOFF-OFF-A3BF9K✅ done
negotiationsNEG-NEG-2WBTZ6✅ done
dealsD-D-K9X3F2✅ done
session_submissionsSS-SS-P1KV9C✅ done
campaignsC-C-4NHJX8✅ done (migration 0094)

IS-11 · Row-Level Ownership Guards done (transition mode)

Problem. Even where actor_id is checked, the check trusts the client-supplied value. Once IS-9 is in place (session-derived actor), ownership checks need to be added where missing.

Affected write routes and their required check:

RouteCheckStatus
PUT /api/operators/:slugSession actor is that operator
PUT /api/streamers/:uSession actor is that streamer
POST /api/streamers/:u/addressesSession is that streamer
DELETE /api/streamers/:u/addresses/:idSession is that streamer
DELETE /api/streamers/:u/channels/:idSession is that streamer
POST /api/streamers/:u/videosSession is that streamer
DELETE /api/streamers/:u/videos/:idSession is that streamer
POST /api/campaignsSession is operator (resolveActor)
PUT /api/campaigns/:idSession operator owns campaign
DELETE /api/campaigns/:idSession operator owns campaign
POST /api/campaigns/:id/offersSession actor matches initiated_by party
PATCH /api/offers/:idSession wins via resolveActor
PATCH /api/negotiations/:id/termsSession actor is the named party
POST /api/negotiations/:id/agreeSession actor is the named party
POST /api/negotiations/:id/force-agreeSession wins via resolveActor
POST /api/negotiations/:id/cancelSession actor is party to negotiation
POST /api/negotiations/:id/dealSession wins via resolveActor
PATCH /api/deals/:idSession wins via resolveActor
POST /api/campaigns/:id/applicationsSession wins via resolveActor
PUT /api/session-submissions/:idSession operator owns the offer

Returns 404 rather than 403 on ownership failure — a 403 confirms the resource exists, which itself leaks information.

All 20 write routes are fully guarded. resolveActor() in session.ts provides the canonical session-first / body-fallback / strict-reject logic across handlers.


IS-12 · Admin Route Protection done

Problem. Admin-only routes (POST /api/auth/impersonate, operator/streamer list endpoints, stream session list, webhook subscription management) are accessible without any authentication today.

Approach. Add X-Admin-Secret header validation to admin-designated routes. The secret is already defined as ADMIN_SECRET in env (declared in index.ts) but never checked in handlers.

ts
function requireAdmin(req: Request, env: Env): Response | null {
  const secret = req.headers.get("X-Admin-Secret");
  if (!secret || secret !== env.ADMIN_SECRET)
    return errorResponse("Forbidden", 403);
  return null;
}

Routes requiring admin gate:

RouteReasonStatus
POST /api/auth/impersonateCreates impersonation tokens for any user
GET /api/operators (full list)Internal only — public endpoint exposes all operator names/emails
GET/POST /api/invitations (all)Creates invitation links
GET /api/streams (global list)Internal analytics
GET /api/streams/:id/probesInternal stream probe data
DELETE /api/webhooks/kick/subscriptions/:idRemoves platform tracking
POST /api/webhooks/kick/subscriptions/verifyTriggers Kick API calls
POST /api/webhooks/kick/add-unregisteredAdds new tracked streamers

The admin frontend already sends X-Admin-Secret from VITE_ADMIN_SECRET. Returns 501 when ADMIN_SECRET env var is unconfigured, 403 otherwise.


Prioritisation

ItemPriorityEffortStatus
IS-9 Auth middlewareCriticalMedium✅ done — needs REQUIRE_SESSION_AUTH=true env flip
IS-11 Row-level ownership guardsCriticalMedium✅ done — all 20 routes guarded
IS-12 Admin route protectionCriticalLow✅ done
IS-10 Opaque public IDsHighMedium✅ done — all tables including campaigns
IS-2 Secrets managementHighLow⏳ not started
IS-3 Audit logHighMedium⏳ not started
IS-1 Threat modelMediumMedium⏳ not started
IS-8 External pentestRequired before launchHigh⏳ not started

Remaining work to close the IS-9/10/11 transition

  1. Flip strict mode — set REQUIRE_SESSION_AUTH=true and SESSION_SECRET in production env once the frontend is confirmed to be sending session cookies on all write requests. This converts IS-9 and IS-11 from "guarded when session present" to "always enforced". All code gaps are now closed; this is the only remaining step.

IS-2 (Secrets), IS-3 (Audit log), and IS-8 (Pentest) are the remaining pre-launch milestones.


Out of Scope

  • End-to-end encryption of chat / negotiation messages (no chat feature planned)
  • SOC 2 certification (V2 consideration)
  • Bug bounty programme (post-launch)

Verifluence Documentation