32 · Platform security

How we protect tenant data, money paths, and the public internet surface— standards we align with, design reasonings, and production rules. Markdown twin: docs/PLATFORM-SECURITY.md.

Tenant isolation first No PAN in our DB Fail closed Updated 2026-07-24

Security goals

GoalWhat it means here
Tenant isolation Business A never reads/writes Business B’s carts, customers, staff, or ledgers.
Least privilege Owner, manager, cashier, device, employee portal, master admin—each is a different actor.
No PAN Card numbers never live in app DB, logs, or bridge. Processors hold card data.
Honest money state POS “paid” requires soft-rail proof or processor success—not a hopeful mock.
Safe public surface Unauthenticated routes are intentional, listed, and abuse-controlled.
Why these five Multi-tenant SMB SaaS fails most often on tenant mix-ups, open admin APIs, and money webhooks that trust the body. We bias engineering toward those failure modes.

Standards we align to (practical, not a cert claim)

StandardHow it shows up
OWASP ASVS (selected)Session cookies, roles/scopes, tenant binding
OWASP API Top 10Broken auth, BOLA/IDOR, misconfig—canonical paths easier to audit
PCI DSS (scope reduction)SAQ A / A-EP style: Stripe/Terminal/hosted fields, not raw PAN entry
Session hygieneHttpOnly, Secure in prod, SameSite, dedicated cookie name
Webhook authenticityStripe signature; crypto walletnotify shared token
TransportTLS at Nginx; app trusts proxy for secure cookies

We do not claim PCI Level 1 as a card processor. Default commercial model is tenant BYO / Connect—platform is software + orchestrator, not acquirer of record for every SMB.

Request trust model

flowchart TB
  subgraph clients [Clients]
    SPA[Admin / POS SPA]
    PUB[Public ticket / storefront]
    DEV[Kiosk · Mobile · Bridge]
    NODE[Coin node walletnotify]
    STR[Stripe]
  end
  subgraph edge [Edge]
    NGX[Nginx TLS]
  end
  subgraph app [Express]
    RL[Rate limit /api]
    AUTH{Auth kind?}
    SESS[requireSessionAuth]
    FLEX[flexibleAuth staff]
    DAUTH[deviceAuth]
    EMP[employee cookie]
    WEB[signature or token]
    TEN[tenantResolver]
    GATE[roles · scopes · features]
    H[Route + service]
  end
  subgraph data [Data]
    M[(Master MySQL)]
    T[(Tenant MySQL)]
  end
  SPA --> NGX
  PUB --> NGX
  DEV --> NGX
  NODE --> NGX
  STR --> NGX
  NGX --> RL
  RL --> AUTH
  AUTH -->|browser user| SESS
  AUTH -->|PIN staff| FLEX
  AUTH -->|paired device| DAUTH
  AUTH -->|ESS| EMP
  AUTH -->|webhook| WEB
  SESS --> TEN
  FLEX --> TEN
  DAUTH --> TEN
  EMP --> TEN
  TEN --> GATE
  GATE --> H
  H --> T
  H --> M
  WEB --> H
          
Principle Authentication proves who. Tenant resolver + roles prove which business and what they may do. Never trust client-supplied orgId alone.

Identity layers (do not collapse)

LayerProvesMechanism
Platform sessionLogged-in SaaS userpawspos.sid · sessionAuth.js
Tenant contextWhich business DBtenantResolver + session/headers
POS PIN staffCashier on registerRegister auth · localStorage device id
Device credentialPaired hardwarex-device-id + x-device-secret
Employee portalESS useremployee_session cookie (separate from admin)
Master adminPlatform operatorrequireRoles('master_admin')
API key (v1)IntegratorDeveloper keys / v1 scopes — harden before partners

See also DocHub 04 · Request & auth for sequence detail.

Intentional public surface

Only these families are meant to work without a staff/admin session:

Path familyPurposeAbuse control
/api/auth/* · /auth/*Login / logout / statusRate limit · no password logging
/api/public/*Tickets, barcode, memestream, signupTicket RL · optional IP list · signup may be off
/api/webhooks/stripeCard eventsStripe signature + raw body
/api/webhooks/doge|ltcNode notify*_WALLETNOTIFY_TOKEN required in prod
/api/webhooks/generic/*Dev echo onlyOff in production unless flag
/api/rates/* · /api/crypto-prices/*Public FXRead-only · no secrets
/api/v1/healthLivenessNo sensitive payload
/api/employee-portal/loginESS loginSeparate cookie · tenant-bound
Storefront public configTheme / menu hooksSlug-scoped
/api/kiosk · /api/mobileDevice POSDevice secret when registered
Fail closed Everything else under /api/* should return 401/403 without a valid session, staff auth, API key, or device credential. Smoke: unauthenticated GET /api/pos/catalog → 401; GET /api/public/health → 200.

Money path security

flowchart LR
  POS[POS tender card]
  ORCH[Payment orchestrator]
  STRIPE[Stripe / Terminal]
  WH[Webhook signed]
  FIN[Cart finalize]
  POS --> ORCH
  ORCH --> STRIPE
  STRIPE --> WH
  WH --> ORCH
  ORCH --> FIN
  FIN -->|PROCESSOR_PENDING| BLOCK[409 block real pending]
  FIN -->|soft / mock ok| SALE[Sale complete]
          
ControlReasoning
Orchestrator as card entryOne place for rail selection, mock policy, recon
BYO / Connect encrypted keysTenant settles to their account; platform is software
Finalize blocks processor pendingUI cannot mark paid before Stripe success
No generic unsigned money webhooks in prodAnyone can POST JSON
Mock only with allowMock / envDev without Stripe keys must be explicit

Detail: DocHub 05 · Money spine, 30 · Payment rails, PLATFORM-PAYMENTS.

Hard engineering rules

Never put router.use(requireSessionAuth) on a router mounted at bare app.use('/api', router). Global auth on that mount runs for every request that enters the router and can 401 the rest of the API (rates, public prices, walletnotify). Use per-route auth or a path prefix. (Incident S-1, 2026-07-24.)
  • Canonical path over duals — deprecate extras; security reviews need one money create path.
  • Tenant DB via resolvergetTenantConnection(slug), not ambient global Sequelize for tenant truth.
  • Dangerous shortcuts need env flags — e.g. ALLOW_INSECURE_WALLETNOTIFY, ENABLE_GENERIC_WEBHOOKS, not “if empty allow”.
  • Session secret is a production key — set SESSION_SECRET; never ship the default.
  • Employee portal ≠ admin session — separate cookie; must not elevate to master-admin APIs.

Sessions & cookies

SettingValueWhy
Cookiepawspos.sidDedicated name
HttpOnlytrueNo JS exfiltration of sid
Securetrue in productionHTTPS only (Nginx TLS)
SameSitelax (configurable)CSRF balance for SPA
StoreFile store todayOK single node; plan Redis multi-node
SecretSESSION_SECRETForgery if default known

Recent critical findings (fixed)

IDIssueSeverityStatus
S-1 Doge/LTC routers global auth on /api mount Critical Fixed — per-route auth
S-2 Walletnotify open when token empty High Fixed — require token in prod
S-3 Simple mock transactions/customers unauthenticated High Fixed — session + no mocks in prod
S-4 Default session secret High (prod) Mitigated — loud warn/error
S-5 /api/v1/status memory disclosure Low Fixed — trimmed body
S-6 CORS substring origin matching Medium Fixed — exact CORS_ORIGIN only
S-7 Weak employee portal session token Medium Fixed — crypto.randomBytes

Production lock (2026-07-24)

  • NODE_ENV=production preserved (not demoted by .env)
  • API + Vite bound to 127.0.0.1 — only Nginx 80/443 public
  • Generic webhooks → 410 on public host
  • Login lockout: 8 failures / 15 min → 15 min lock (IP+email)
  • Unpaired devices rejected in production (override ALLOW_UNPAIRED_DEVICES=1)

Tenant IDOR smoke (2026-07-24)

Two-tenant probe (mariospizza ↔ tiger-one): spoofed X-Tenant-Slug / org headers and foreign object ids. Result: no FAIL_LEAK — session-bound tenant + per-tenant DB. Report: docs/IDOR-SMOKE-REPORT.md · re-run scripts/idor-tenant-smoke.mjs.

Residual (roadmap)

  • Redis sessions (platform + employee portal) for multi-instance
  • Full API-key matrix before external v1 partners
  • Optional strict unpaired devices: set DEVICE_REQUIRE_SECRET=1 after all units are paired

Living scoreboard of other gaps: DocHub 11 · Gaps & audit · AUDIT-FINDINGS.md.

Ops checklist before real customer data

  1. Set strong SESSION_SECRET (32+ random bytes).
  2. Confirm HTTPS + Secure cookies (login survives refresh).
  3. List every real SPA origin in CORS_ORIGIN (exact URLs, comma-separated).
  4. Stripe keys + webhook secret only when processing cards.
  5. Set DOGE_WALLETNOTIFY_TOKEN / LTC_WALLETNOTIFY_TOKEN if using native crypto.
  6. Leave ENABLE_GENERIC_WEBHOOKS and ENABLE_SIMPLE_MOCKS unset.
  7. After all POS units are paired, consider DEVICE_REQUIRE_SECRET=1.
  8. Signup policy: open / closed / invite—confirm intentionally.
  9. MySQL and coin RPC not internet-exposed.
  10. Smoke public vs private paths (401 vs 200 as above).

Reviewing a new endpoint

  1. Public? If yes, add to the public table and rate-limit abuse cases.
  2. Which auth? Session, flexible staff, device, employee, or API key—pick deliberately.
  3. Tenant-bound? Resolver + tenant connection; no unbound org headers.
  4. Mount-safe? No global auth on bare /api routers.
  5. Money or PII? Prefer canonical orchestrator / customers / inventory paths.
  6. Docs: Update this page + docs/PLATFORM-SECURITY.md if the surface or default changes.