Security Analysis — Security Decisions
Authentication
How Identity Is Verified
- Method: JWT (JSON Web Tokens) with HS256 symmetric signing
- Library:
jsonwebtokenv9 - Secret:
process.env.JWT_SECRETwith hardcoded fallback "mysecret" (jwtToken.jsL13) - Expiry: 24h for session tokens, 1h for password reset tokens
Token Storage Strategy
- Client-side:
localStorage.setItem('token', token)in browser - Sent as:
Authorization: Bearer <token>header on every API request - Parsed on server:
req.headers.authorization.split(" ")[1]
⚠ Token Storage Vulnerability
localStorage is accessible to any JavaScript running on the page. If there's an XSS vulnerability, an attacker can steal the JWT and impersonate the user. httpOnly cookies would be more secure but are incompatible with the current architecture (different origins for frontend/backend).
⚠ Fallback JWT Secret
// jwtToken.js L8-16
const getJwtSecret = () => {
if (!process.env.JWT_SECRET) {
console.warn("WARNING: JWT_SECRET is not set...");
return "mysecret"; // ← Anyone who reads the source can forge tokens
}
return process.env.JWT_SECRET;
};If JWT_SECRET is not set in production, all tokens are signed with "mysecret". An attacker can create { id: 1, role: "doctor", email: "admin@example.com" } and access any doctor's data.
Authorization
How Roles/Permissions Are Enforced
Authorization checks are inline in each route handler:
// Pattern used in most handlers:
if (req.user.role !== 'doctor') {
return res.status(403).json({ message: "Unauthorized: Only doctors can..." });
}Role Matrix
| Endpoint | Doctor | Patient | Public |
|---|---|---|---|
| Auth (signup/signin/reset) | — | — | ✅ |
/doctor/* | ✅ | ❌ (403) | ❌ (401) |
/patient/* | ❌ (403) | ✅ | ❌ (401) |
/appointments (create) | ❌ | ✅ | ❌ |
/appointments/update-status | ✅ (any status) | ✅ (cancel only) | ❌ |
/chats, /chats/:id/messages | ✅ (own chats) | ✅ (own chats) | ❌ |
/api/medicines | — | — | ✅ |
/admin/send-medication-reminders | — | — | ✅ (⚠ NO AUTH!) |
⚠ Missing Authorization Checks
/admin/send-medication-reminders— completely unprotected. Anyone can trigger mass email sends.GET /doctor/doctors— returns ALL patients with their doctors. No role check. Any authenticated user can see all patient-doctor relationships.- Patient ID in URL —
GET /patient/medications/today/:patientIdchecksreq.user.role === 'patient'but doesn't verify thatreq.user.id === patientId. A patient could theoretically view another patient's medications by changing the URL.
Ownership Verification Gaps
The updateMedicationStatus handler checks:
const isAuthorized = decoded.role === 'patient' && decoded.id == patientId || decoded.role === 'doctor';Note the == (loose equality) and missing parentheses — this is (patient && id_match) || doctor. Any authenticated doctor can update any patient's medication status, even patients not assigned to them.
Input Validation
Where and How
Validation is manual, per-handler, with if (!field) checks:
// Typical pattern:
if (!patientId) {
return res.status(400).json({ message: "Patient ID is required" });
}What's Validated
| Input | Validated? | How |
|---|---|---|
| Email format | ❌ No | Relies on DB @unique to catch duplicates, no format check |
| Password strength | ❌ No | Any string accepted, even empty after falsy check |
| Patient age | Partial | parseInt(age) — NaN if invalid but stored anyway |
| Medicine fields | ✅ Yes | medicines.every(med => med.id && med.name && ...) |
| Appointment date | ❌ No | new Date(appointmentDate) — Invalid Date silently creates garbage |
| Status values | ✅ Yes | Checked against ['scheduled', 'completed', 'cancelled', 'missed'] |
⚠ No Validation Library
No Zod, Joi, or express-validator. Each handler has its own validation logic, which is inconsistent across the codebase. Some handlers validate thoroughly (prescription), others barely check at all (assign-patient).
SQL/NoSQL Injection Prevention
Current Protection
Prisma ORM parameterizes all queries by default. There are no raw SQL queries in the application code (except the migration file). This effectively prevents SQL injection.
// Prisma automatically parameterizes:
await prisma.Patient.findUnique({
where: { email: userInput } // ← Parameterized, not concatenated
});⚠ One Exception
The migration 20250806120000 contains raw SQL, but this only runs during prisma migrate deploy, not at runtime.
XSS Protection
Current State
- React auto-escapes: JSX expressions
{variable}are auto-escaped by React, preventing reflected XSS in rendered content. - No
dangerouslySetInnerHTMLused anywhere in the frontend. - Chat messages: Stored as plain text, rendered as text in React — safe from stored XSS.
⚠ Vulnerability
- Email HTML templates in
emailService.jsandemailServiceAlert.jsuse string interpolation:Ifhtml: `<p>Hello ${patientName},</p>`patientNamecontains<script>alert('xss')</script>, it gets injected into the email HTML. Most email clients strip<script>tags, but some render inline event handlers.
CSRF Protection
Current State
No CSRF protection. The app uses JWT in Authorization headers (not cookies), which is inherently CSRF-resistant because:
- Browsers don't automatically send
Authorizationheaders on cross-origin requests - An attacker would need the JWT value to forge a request
However, if the app ever moves to cookie-based auth, CSRF tokens would be needed.
Password Handling
| Aspect | Implementation |
|---|---|
| Algorithm | bcrypt (via bcryptjs) |
| Salt rounds | 10 (industry standard minimum) |
| Salt | Auto-generated by bcrypt (included in hash output) |
| Storage | Hashed password in DB, never plain text |
| Comparison | bcrypt.compare(plaintext, hash) — timing-safe |
⚠ Concerns
- No password strength validation — empty passwords, single-character passwords, or common passwords like "password123" are all accepted.
- No account lockout — unlimited login attempts. An attacker can brute-force passwords without being blocked.
- No password history — users can reset to the same password repeatedly.
Sensitive Data
What's Stored
| Data | Encrypted? | Where |
|---|---|---|
| Passwords | ✅ bcrypt hash | Patient.password, Doctor.password |
| Email addresses | ❌ Plain text | Patient.email, Doctor.email |
| Patient names | ❌ Plain text | Patient.name |
| Patient age/gender | ❌ Plain text | Patient.age, Patient.gender |
| Medical diagnoses | ❌ Plain text | MedicalRecord.diagnosis |
| Medication names | ❌ Plain text | PrescribedMedicine.medicineName, MedicineAdherence.medication |
| Chat messages | ❌ Plain text | Message.content |
| JWT tokens | ❌ Base64-encoded (not encrypted) | Client localStorage + Authorization header |
⚠ HIPAA/Healthcare Compliance Concerns
This system stores Protected Health Information (PHI): patient names, email addresses, medical diagnoses, medication records, and appointment history. In the US, this requires HIPAA compliance:
- Data at rest encryption (not implemented)
- Data in transit encryption (HTTPS — depends on deployment)
- Audit logging (not implemented)
- Access controls with audit trails (partially implemented via role checks, no audit trail)
- Business Associate Agreements with all vendors (Neon, Gmail, Vercel)
Rate Limiting
Current State
No rate limiting whatsoever.
| Attack Vector | Current Vulnerability |
|---|---|
| Brute-force login | Unlimited attempts, no lockout |
Email spam via /admin/send-medication-reminders | Unprotected endpoint, unlimited calls |
| API abuse | Any authenticated user can make unlimited requests |
| Registration spam | No captcha, no rate limit on /auth/signup |
What Should Exist
import rateLimit from 'express-rate-limit';
// Global
app.use(rateLimit({ windowMs: 60 * 1000, max: 100 }));
// Auth-specific (brute-force protection)
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5 });
app.use('/auth/signin', authLimiter);
app.use('/auth/forgot-password', authLimiter);HTTPS/TLS
Current State
- Development: HTTP on localhost (no encryption)
- Production: Depends entirely on the deployment platform. Vercel provides automatic HTTPS. If deployed elsewhere without HTTPS, all JWT tokens, passwords, and patient data travel in plain text.
- No HSTS header — browser can be downgraded to HTTP via MITM
Security Headers
Current State
No security headers are set. Missing headers:
| Header | Purpose | Status |
|---|---|---|
X-Content-Type-Options: nosniff | Prevent MIME type sniffing | ❌ Missing |
X-Frame-Options: DENY | Prevent clickjacking | ❌ Missing |
X-XSS-Protection: 1; mode=block | Legacy XSS protection | ❌ Missing |
Strict-Transport-Security | Force HTTPS | ❌ Missing |
Content-Security-Policy | Prevent XSS and data injection | ❌ Missing |
Referrer-Policy: no-referrer | Prevent token leakage in referrer | ❌ Missing |
Fix: Add helmet middleware:
import helmet from 'helmet';
app.use(helmet());Vulnerabilities This Project Is Still Exposed To
Critical
- Hardcoded JWT fallback secret — if
JWT_SECRETenv var isn't set, any attacker with source code access can forge tokens. - No rate limiting on login — brute-force attacks are trivial.
- Unprotected admin endpoint —
/admin/send-medication-reminderscan be called by anyone. - No HTTPS enforcement — depends on deployment platform.
High
- Patient ID guessing — some endpoints don't verify ownership (patient can access other patients' data by changing
patientIdin URL). - No security headers — susceptible to clickjacking, MIME sniffing, and referrer leakage.
- JWT in localStorage — XSS attack can steal session tokens.
- No password strength policy — weak passwords accepted.
Medium
- No audit logging — no trail of who accessed what patient data.
- Email template injection — user names inserted into HTML emails without sanitization.
- CORS set to
*for Socket.IO — any origin can connect to WebSocket server. - Verbose error messages — some handlers return internal error details to the client (e.g.,
"Error creating prescription: " + err.message). - No account lockout — unlimited failed login attempts.
Low
- Token bloat — entire user profile in JWT payload increases header size.
- No token revocation — can't force-logout compromised accounts.
dotenv.config()called in multiple files — race conditions on environment variable loading.