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 firstNo PAN in our DBFail closedUpdated 2026-07-24
Security goals
Goal
What 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)
Standard
How it shows up
OWASP ASVS (selected)
Session cookies, roles/scopes, tenant binding
OWASP API Top 10
Broken 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 hygiene
HttpOnly, Secure in prod, SameSite, dedicated cookie name
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)
Layer
Proves
Mechanism
Platform session
Logged-in SaaS user
pawspos.sid · sessionAuth.js
Tenant context
Which business DB
tenantResolver + session/headers
POS PIN staff
Cashier on register
Register auth · localStorage device id
Device credential
Paired hardware
x-device-id + x-device-secret
Employee portal
ESS user
employee_session cookie (separate from admin)
Master admin
Platform operator
requireRoles('master_admin')
API key (v1)
Integrator
Developer keys / v1 scopes — harden before partners
Only these families are meant to work without a staff/admin session:
Path family
Purpose
Abuse control
/api/auth/* · /auth/*
Login / logout / status
Rate limit · no password logging
/api/public/*
Tickets, barcode, memestream, signup
Ticket RL · optional IP list · signup may be off
/api/webhooks/stripe
Card events
Stripe signature + raw body
/api/webhooks/doge|ltc
Node notify
*_WALLETNOTIFY_TOKEN required in prod
/api/webhooks/generic/*
Dev echo only
Off in production unless flag
/api/rates/* · /api/crypto-prices/*
Public FX
Read-only · no secrets
/api/v1/health
Liveness
No sensitive payload
/api/employee-portal/login
ESS login
Separate cookie · tenant-bound
Storefront public config
Theme / menu hooks
Slug-scoped
/api/kiosk · /api/mobile
Device POS
Device 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]
Control
Reasoning
Orchestrator as card entry
One place for rail selection, mock policy, recon
BYO / Connect encrypted keys
Tenant settles to their account; platform is software
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 resolver — getTenantConnection(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.