This document covers what's actually implemented, why, and what's explicitly out of scope for a portfolio project.
- Passwords hashed with bcrypt (cost factor 12), never stored or logged in plaintext
- JWT access tokens (15 min) + refresh tokens (7 days), both
httpOnly— never touched by JavaScript, so they're not readable by an XSS payload even if one somehow got through - Refresh token rotation: every refresh revokes the old session and issues a new one, tracked in a
Sessioncollection with a TTL index for automatic expiry cleanup tokenVersionon theUsermodel: changing your password immediately invalidates every other refresh token in existence, not just the one used to change it- Account lockout: 5 failed login attempts locks the account for 15 minutes, independent of the IP-based rate limiter below — this protects against distributed credential-stuffing attempts that spread requests across many IPs to dodge per-IP limits
- Login/signup/password-reset error messages are deliberately generic ("Invalid email or password") so they don't reveal whether a given email is registered
Cookie-based auth (vs. bearer tokens in localStorage) trades one class of risk (XSS token theft) for another (CSRF), so this needed explicit handling:
- All state-changing requests (
POST/PUT/PATCH/DELETE) require a customX-Requested-With: XMLHttpRequestheader (src/middleware/csrf.middleware.ts) - Plain HTML forms — including
multipart/form-datafile-upload forms, which are exempt from CORS preflight and would otherwise be a CSRF hole even with strict CORS — cannot set custom headers, so a forged cross-site form submission never reaches this header check - The frontend's axios instance sends this header by default on every request, so this is invisible to normal usage
- Locked to a single configured origin (
CLIENT_URL), not a wildcard, withcredentials: true - Explicit
methodsandallowedHeadersallowlists
- Every request body/query/params validated with Zod schemas before reaching a controller
express-mongo-sanitizestrips$/.keys from input (NoSQL injection protection)hppblocks HTTP parameter pollution (duplicate query keys)- User-supplied free text that gets stored and later rendered elsewhere (name, interview role, tech stack, resume-extracted text) is stripped of any HTML via
sanitize-htmlbefore it's saved — defense-in-depth beyond React's default JSX escaping, since this text also flows into PDF reports and the admin panel
- Global limiter on all
/apiroutes - A stricter limiter specifically on auth routes (signup/login/password-reset), separate from the account-lockout mechanism above — this covers the case where an attacker spreads attempts across many different accounts from one IP, which per-account lockout alone wouldn't catch
multer(upgraded to 2.x during this hardening pass — 1.x had several disclosed vulnerabilities) with memory storage, so files never touch disk on the API server- Resume uploads: PDF/DOCX only (MIME-type allowlist), 5MB cap
- Voice answer uploads: audio MIME types only, 20MB cap
- Both go through Cloudinary, not local storage
helmetwith an explicit Content-Security-Policy (default-src 'self', no inline scripts/styles, images allowed from Cloudinary) rather than relying on helmet's defaults aloneapp.set('trust proxy', 1)— required for correct client IP detection (rate limiting) and secure cookies behind Railway/most PaaS reverse proxies
Ran npm audit on both backend and frontend as part of this hardening pass and fixed everything it found:
nodemailer(had multiple disclosed CVEs including SMTP command injection) → upgraded to 9.xbcrypt→ upgraded to 6.x (pulled in a patchedtartransitively)multer→ upgraded to 2.x- Both projects show 0 vulnerabilities as of this pass
- All secrets (
JWT_ACCESS_SECRET,JWT_REFRESH_SECRET, SMTP credentials, Cloudinary keys, OpenAI key, MongoDB URI) load from environment variables, validated with Zod at boot (src/config/env.ts) — the app refuses to start with missing/malformed config rather than failing unpredictably later .envis gitignored in both projects; only.env.example(with placeholder values) is committed
Being upfront about what a portfolio project like this doesn't need to solve:
- WAF / DDoS protection — handled at the infrastructure layer (Cloudflare/Railway/Vercel), not application code
- Automated dependency scanning in CI —
npm auditwas run manually as part of this pass; a real production app would wire this into CI (Dependabot, Snyk, etc.) - Penetration testing — everything above was verified via direct testing in this environment (see each phase's README for what was actually run), not a professional security audit
- 2FA/MFA — not implemented; would be a reasonable v2 addition
- Content moderation on resume uploads / interview answers — no scanning for malicious file content beyond MIME-type + size checks
- New free-text field being stored? Run it through
sanitizeText()before saving - New state-changing route? It's automatically covered by the CSRF middleware — no per-route action needed, just make sure your frontend client sends
X-Requested-With - New file upload endpoint? Use
multer.memoryStorage(), set an explicit MIME allowlist and size cap, never write to local disk