API Security Hardening — May 2026
Scope: apps/api/src/ — all route surfaces, auth, JWT, socket middleware
Date: 2026-05-07
Reviewer: Claude Code
Status: All findings resolved
Summary
Full code review covering Critical → Low severity. 18 total findings across 4 tiers, all resolved.
Critical (2 findings — resolved)
C-1 · Razorpay webhook signature verification could be skipped
File: apps/api/src/routers/pgcallbacks.ts
Fix: Webhook handler now hard-fails with 500 if RAZORPAY_WEBHOOK_SECRET is not set. Signature verification is unconditional.
C-2 · DM tenant isolation — 14+ routes missing membership guard
Files: routers/dm/channels.ts, contacts.ts, costs.ts, credits.ts, important-days.ts, search-terms.ts
Fix: All DM routes that accept a tenantId parameter now verify TenantMember membership before returning data. Pattern:
if (actor.role !== "super_admin") {
const m = await db.tenantMember.findFirst({ where: { userId: actor.sub, tenantId } });
if (!m) return apiError(reply, 403, "FORBIDDEN", "No access to this tenant.");
}High (6 findings — resolved)
H-1 · Pagination shape inconsistent across endpoints
All list endpoints standardised to { data, pagination } envelope. Cursor and offset variants documented in docs/api/overview.md.
H-2 · No request ID correlation in logs
X-Request-Id header now propagated on every response. reqId field present in every Pino log line. Documented in docs/features/observability.md.
H-3 · CORS allows "null" origin in production
registerCors in fastify-setup.ts now gates "null" origin behind NODE_ENV !== "production".
H-4 · Error handler uses (err as any).statusCode cast
Replaced with (err as FastifyError).statusCode. Non-Error objects sanitised to { error: { code, message } } instead of forwarded raw.
H-5 · Registration session stores bcrypt hash in field named password
Field renamed to passwordHash in session data (write at register/start, read at register/complete).
H-6 · POST /reset-password rate limit too permissive
Changed from max: 10 to max: 5 per 15-minute window.
Medium (2 findings — resolved)
M-1 · Free-text schema fields missing maxLength constraints
auth.ts, admin/tenants.ts, admin/users.ts — all string fields now bounded:
- Names: 100–200 chars
- Email: 255
- Password: 128 (max)
- Phone: 30, country: 2, postal: 20, billing text: 500
M-2 · Refresh token not rotated
POST /auth/v1/refresh now returns { accessToken, refreshToken }. The new refresh token should replace the stored one on the client.
Low (13 findings — resolved)
L-1 · JWT algorithm not explicit
All jwt.sign() calls now specify algorithm: "HS256".
L-2 · Password validation length-only
validatePasswordStrength() helper added to routers/auth.ts. Requires uppercase + lowercase + digit in addition to min-8 length. Applied at registration, PATCH /me/password, and POST /reset-password.
L-3 · Landing page secret key returned in cleartext HTTP response
Cache-Control: private, no-store is now set on every API response (via registerRequestId onSend hook), preventing proxy or browser caching of the response body.
L-4 · No Cache-Control on API responses
private, no-store added automatically in the registerRequestId onSend hook. No per-route action needed.
L-5 · TODO comment indicating untracked known gap
routers/tenant/main.ts:57 — replaced // TODO: filter by tenant plan with a plain comment.
L-6 · Repetitive as unknown as { ... } casts in billing
Consolidated into a single named InvoiceWithReminders interface in admin/billing.ts.
L-7 · console.error() on JWT startup check
Replaced with process.stderr.write() since the structured logger is not yet initialised at module load time.
L-8 · Email maxLength missing on admin user creation
Covered by M-1 fix.
L-9 · Audit log actorName set to user ID instead of name
routers/dm/tenant.ts now fetches the user’s name before calling writeAuditLog.
L-10 · Audit log metadata for secret key rotation
Already clean — metadata: {} never included the secret key value. No change needed.
L-11 · :id path param not validated as UUID in landing-pages regenerate-key
Schema block with format: "uuid" added to POST /:id/regenerate-key.
L-12 · Socket auth middleware — auth failure path not logged
Both JWT-failure and session-failure branches in socket/middleware/auth.ts now log.warn with the specific auth path that failed.
L-13 · DM users endpoint returns all rows with no pagination
GET /dm/v1/users now accepts limit (max 200, default 100) and offset. Returns { data, pagination: { total, limit, offset, hasMore } }.
Deferred / Out of scope
- Registration session as untyped JSON blob (architectural — requires schema migration).
- Plan-based agent filtering on
GET /tenant/v1/agents(product decision, not security). - Password complexity enforcement on existing user accounts (breaking change, requires migration plan).