Skip to content

Secure-Coding Checklist (cross-stack)

A framework-agnostic checklist for any web/app/API project (Laravel, Node/Next, Python, React Native). Derived from the Laravel Boost security.md rules and hardened with real findings from the autonomous-booking audit. Use it as a pre-merge / pre-deploy gate.

How to use: skim the headers during code review. Each item lists the failure mode, then "Do this". The starred items (*) are the ones most teams actually get wrong.


1. Authorization and object ownership (*)

The single most common real vulnerability: an endpoint trusts an ID from the request and never checks the caller owns that record (IDOR / broken object-level authorization).

  • Every read or mutation keyed on a client-supplied ID must filter by the owner, e.g. where('user_id', currentUser.id) or an explicit policy/gate check. Do not rely on the ID being unguessable.
  • Never trust an ID held in client state (Livewire public property, hidden form field, JWT-decoded-then-ignored, Redux store) as proof of ownership. In Livewire specifically, lock model-bound properties (#[Locked]).
  • Prefer non-sequential public identifiers (UUID / nanoid) for anything reachable by URL, so records are not enumerable. Treat this as defense in depth, not the access control itself.
  • Authorize the action, not just the route. A logged-in user reaching a route is not authorization to act on someone else's object.

2. Business-logic and money paths (*)

Bugs here cost real money and are invisible to scanners.

  • Prices, discounts, quantities, balances, and statuses come from the server / database, never from the request. Re-derive them server-side even if the client sends them.
  • Any multi-step financial mutation (charge + credit ledger + decrement stock) runs in one DB transaction.
  • Enforce limits atomically. A non-locking count() then insert() is a TOCTOU race: two concurrent requests both pass the check. Use lockForUpdate / SELECT ... FOR UPDATE, a unique constraint, or an atomic decrement. Back every "max N redemptions / one per user" rule with a DB unique constraint, not just app code.
  • Make every redemption/credit path go through the same guarded code. Watch for "free" shortcuts (100% off, zero-card-amount, full-store-credit) that skip the normal limit checks.
  • Capacity / inventory checks must happen inside the lock that decrements them, or you overbook.

3. Webhooks and idempotency

  • Verify the provider signature on every webhook (Stripe STRIPE_WEBHOOK_SECRET, etc.). An unsigned webhook endpoint is an unauthenticated write.
  • Make handlers idempotent: persist the provider event ID and short-circuit on replay. Do not rely on derived record state. Side effects (emails, credits) must fire at most once.

4. Secrets management (*)

  • Never commit real secrets. No live keys, tokens, or private keys in the repo, including as fallback defaults: env('STRIPE_SECRET', 'sk_test_realkey...') leaks the key to anyone with repo access. Use env('STRIPE_SECRET') with no default so a missing var fails fast.
  • Read secrets through your config layer (config('services.x')), not env() scattered through code.
  • A committed .env.testing / CI key must be distinct from production. Reusing an app encryption key (APP_KEY, JWT secret, session secret) between test and prod is critical: it lets an attacker forge signed URLs, decrypt cookies/sessions, and forge framework payloads.
  • .gitignore all .env* except .env.example (placeholders only). Rotate anything that ever landed in git history.

5. Mass assignment / over-posting

  • Whitelist writable fields explicitly ($fillable, DTOs, validated payloads, serializers). Never bind a raw request body to a model.
  • Globally disabling the guard (Model::unguard(), strict: false) removes the safety net for every future write. If you must, ensure no write ever receives an unfiltered request array.
  • Sensitive columns (role, is_admin, balance, owner_id, price, verified_at, status) must never be in a user-writable set.

6. Injection

  • Parameterize all queries. Never interpolate user input into SQL, even in "raw" expressions (whereRaw('LOWER(name) = ?', [$name]), not "... = '$name'").
  • No dynamic column/table/orderBy from raw user input. Map user input to an allowlist of known-safe values.
  • No shell exec with interpolated input. Avoid exec/system/Process on user data; if unavoidable, pass args as an array, never a concatenated string.
  • No unserialize() / native deserialization of untrusted data. Use JSON.

7. Output / XSS

  • Default to auto-escaped output (, React's default text rendering). Only emit raw HTML ({!! !!}, dangerouslySetInnerHTML, v-html) for content you sanitized through a real HTML sanitizer.
  • nl2br() and similar do NOT escape. Escape first, then format: nl2br(e($value)).
  • Admin-authored content is still a stored-XSS vector against end users. Sanitize it too; "trusted admin" is not a control once a content role or a compromised admin exists.
  • Set security headers: a Content-Security-Policy, X-Content-Type-Options: nosniff, X-Frame-Options/frame-ancestors, and HSTS. Most frameworks ship none by default.

8. Authentication

  • Throttle login, register, password-reset, and token endpoints (e.g. 5/min per IP+identifier) with lockout.
  • Regenerate the session on login (fixation) and invalidate it on logout.
  • Generic responses on login and password-reset to avoid user enumeration. Registration's "email already taken" is a common enumeration leak; decide if it matters for your threat model.
  • Enforce a real password policy (min length plus a breached-password check beats complexity rules).
  • If you implement email verification, actually gate sensitive routes on it; an unused verified middleware is decoration.

9. File uploads

  • Validate real MIME type and size, not just the client-sent extension/filename. Never trust the client filename.
  • Store with a server-generated name on a private disk by default; serve through an authorized route.
  • Block or sanitize SVG (it can carry script) and anything served inline from a public bucket.

10. APIs

  • Rate-limit every authenticated and public API route, even machine-to-machine ones. A leaked client credential with no throttle is a flood / data-exhaustion vector.
  • Return only the fields the caller needs (use a resource/serializer with explicit fields or a $hidden list). Do not echo whole models.
  • Scope API queries to the authenticated client/tenant exactly like web routes.

11. Dependencies and CI

  • Run a dependency audit in CI (composer audit, npm audit --production, pip-audit) and fail on high severity.
  • Keep static analysis (PHPStan/Larastan, tsc/eslint, mypy) and the formatter green on every PR.
  • Encrypt sensitive columns at rest (API keys, tokens) with an encrypted cast and mark them hidden.

Quick pre-deploy gate

  1. No client-supplied ID acts on a record without an ownership/policy check. (#1)
  2. No price/limit/balance trusted from the request; limits enforced atomically + DB constraint. (#2)
  3. Webhooks signature-verified and idempotent. (#3)
  4. No real secret in the repo or as a config default; prod keys distinct from test. (#4)
  5. No raw request body bound to a model; no unguard() on user-facing writes. (#5)
  6. Queries parameterized; raw HTML only on sanitized content. (#6, #7)
  7. Auth + API routes throttled; security headers set. (#8, #10, #7)
  8. composer/npm/pip audit clean in CI. (#11)

CaptiFi — Guest WiFi Marketing Platform