Skip to Content
Code ReviewsAPI Security Hardening — May 2026

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).

© 2026 Leadmetrics — Internal use only