Security Best Practices

A field guide to the mistakes our own code keeps making — root causes, CWE/OWASP mapping, before/after fixes, and a shift-left plan across Infrastructure, Backend, Frontend & Mobile.

Part 1 — Why This Matters

Why this matters

Across the codebases we've reviewed, ~135 distinct security findings clustered into 14 repeat patterns — every one of them a named, well-studied weakness class, not a one-off fluke.

Figure What it means
~135 Findings reviewed
14 Repeat patterns they collapse into
4 Patterns found in nearly every single codebase reviewed

These are anonymized, aggregated categories from multiple codebases — never any one project or team. What matters is the pattern, not the source.

These aren't random bugs

Every pattern below is a named weakness class (CWE) inside a current OWASP Top 10:2025 category — which is exactly why the same fixes work across completely unrelated codebases.

Pattern CWE OWASP Top 10:2025
Broken access control / IDOR CWE-639 A01 · Broken Access Control
Sensitive data logged in plaintext CWE-532 A04 · Cryptographic Failures
Weak / bypassable authentication CWE-347 A07 · Authentication Failures
Hardcoded secrets in git CWE-798 A02 · Security Misconfiguration
Missing rate limiting / enumeration CWE-307 A07 · Authentication Failures
Insecure token / cookie storage CWE-522 A04 · Cryptographic Failures
Missing security headers / CORS CWE-346 A02 · Security Misconfiguration
XSS via unsanitized HTML CWE-79 A05 · Injection
Trusting client-side-only checks CWE-602 A06 · Insecure Design
SSRF via unvalidated requests CWE-918 A01 · Broken Access Control
Race conditions (financial logic) CWE-362 A06 · Insecure Design
Missing webhook signature check CWE-345 A08 · Software/Data Integrity
Mass assignment / over-posting CWE-915 A01 · Broken Access Control
Weak crypto / predictable RNG CWE-330 A04 · Cryptographic Failures

Part 2 — Fourteen Mistakes We Keep Making

01. Broken Access Control / IDOR

CWE-639OWASP A01:2025

The mistake

An endpoint checks that you're logged in — but never checks that you're allowed to touch this specific record.

Attack in practice

A user changes the ID in /invoices/1042 to /invoices/1043 and reads a stranger's invoice — no exploit tooling, just curiosity.

Vulnerable
// ❌ Vulnerable
router.get('/invoices/:id', auth,
async (req,res)=>{
const inv = await Invoice
.findById(req.params.id)
res.json(inv)
})
Fixed
// ✅ Fixed
router.get('/invoices/:id', auth, async (req,res)=>{
const inv = await Invoice.findOne(
{_id: req.params.id, ownerId: req.user.id})
if (!inv) return res.sendStatus(404)
res.json(inv)
})
Defense in depth — layer the fix
  1. Object-level authorization on every read & write, not just the route
  2. Deny-by-default middleware applied to every new endpoint
  3. Automated cross-tenant (BOLA) test in CI for every resource route

02. Authentication You Can Forge

CWE-347OWASP A07:2025

The mistake

A JWT's payload is base64-decoded and trusted directly, or OAuth skips state/PKCE — signature verification never actually happens.

Attack in practice

Decode the token, change "role":"user" to "role":"admin", re-encode, replay — nothing checks the signature, so it's accepted.

Vulnerable
// ❌ Vulnerable
const [h,p,s] = token.split('.')
const claims = JSON.parse(atob(p))
if (claims.exp > Date.now())
req.user = claims
Fixed
// ✅ Fixed
const claims = jwt.verify(token,
PUBLIC_KEY,
{ algorithms: ['RS256'] }
)
req.user = claims
Defense in depth — layer the fix
  1. Verify signatures with a pinned algorithm allowlist — reject "alg: none"
  2. Short-lived access tokens with rotated, revocable refresh tokens
  3. State + PKCE on every OAuth redirect flow, no exceptions

03. Secrets Committed to Git

CWE-798OWASP A02:2025

The mistake

Signing keys, API tokens, and service-account credentials get committed straight into the repo — sometimes into .env.example "as a placeholder."

Attack in practice

One public fork, one leaked CI log, or one ex-contractor's laptop — and an outsider has the exact key production uses.

Vulnerable
// ❌ Vulnerable
// config.ts
export const STRIPE_KEY =
'sk_live_51H8x...'
Fixed
// ✅ Fixed
// config.ts
export const STRIPE_KEY =
process.env.STRIPE_KEY
// injected at deploy time
// from Secrets Manager
Defense in depth — layer the fix
  1. Pre-commit + CI secret scanning (gitleaks/truffleHog) blocking the push
  2. Centralized secrets manager with scoped, auditable access
  3. Rotate anything ever committed — deleting the line doesn't undo history

04. Sensitive Data Logged in Plaintext

CWE-532OWASP A04:2025

The mistake

Passwords, tokens, card numbers, or PII get printed to console/logs by "temporary" debug code that ships to production.

Attack in practice

A log line meant for debugging lands in a log aggregator or a shared Slack thread — the breach happens without touching the database.

Vulnerable
// ❌ Vulnerable
logger.info('checkout payload',
req.body)
// includes card number, cvv,
// auth token...
Fixed
// ✅ Fixed
logger.info('checkout attempt', {
orderId: order.id,
amountCents: order.total
})
Defense in depth — layer the fix
  1. Structured logging with an explicit, reviewed field allowlist
  2. Centralized redaction / PII scanning on the log pipeline itself
  3. Lint rule or review gate banning raw request/response body logging

05. No Rate Limiting, Free Enumeration

CWE-307OWASP A07:2025

The mistake

Login, password-reset, OTP, and "does this email exist" endpoints accept unlimited attempts with no backoff.

Attack in practice

A script tries 50,000 emails against the login endpoint overnight; the 200-vs-404 response hands back a ready-made phishing list for free.

Vulnerable
// ❌ Vulnerable
app.post('/auth/check-email',
(req,res)=>{
const ok = db.users.find(
req.body.email)
res.sendStatus(ok?200:404)
})
Fixed
// ✅ Fixed
app.post('/auth/check-email',
rateLimit({windowMs:60000,max:5}),
(req,res)=>{
queueVerifyIfExists(req.body.email)
res.sendStatus(200) // always 200
})
Defense in depth — layer the fix
  1. Per-IP and per-account rate limits with exponential backoff
  2. Identical responses regardless of whether the account exists
  3. CAPTCHA or proof-of-work triggered after repeated failures

06. Tokens Stored Where XSS Can Reach Them

CWE-522OWASP A04:2025

The mistake

Session tokens live in localStorage or a cookie without httpOnly/secure/sameSite — or an ID token gets reused as an API bearer token.

Attack in practice

One unrelated XSS bug anywhere in the app reads localStorage and quietly ships every visitor's session to an attacker's server.

Vulnerable
// ❌ Vulnerable
localStorage.setItem(
'accessToken', token
)
// readable by any inline script
Fixed
// ✅ Fixed
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'strict'
})
Defense in depth — layer the fix
  1. httpOnly + secure + sameSite cookies for all session state, always
  2. Short token lifetime with silent refresh driven by the cookie itself
  3. Never substitute an ID token for an access token, even "temporarily"

07. No Security Headers, Wide-Open CORS

CWE-346OWASP A02:2025

The mistake

No Content-Security-Policy, no HSTS, no X-Frame-Options — and CORS configured with origin:'*' alongside credentials:true.

Attack in practice

A malicious site iframes your dashboard for clickjacking, or calls fetch() with credentials and reads your API as the logged-in user.

Vulnerable
// ❌ Vulnerable
app.use(cors({
origin: '*',
credentials: true
}))
Fixed
// ✅ Fixed
app.use(cors({
origin: ['https://app.example.com'],
credentials: true
}))
app.use(helmet())
Defense in depth — layer the fix
  1. Helmet (or equivalent) applied by default to every service
  2. Explicit CORS origin allowlist — never wildcard combined with credentials
  3. CSP + X-Frame-Options as a clickjacking backstop, not the only control

08. XSS via Unsanitized HTML

CWE-79OWASP A05:2025

The mistake

CMS or user-supplied content is rendered with dangerouslySetInnerHTML / innerHTML / eval(), with no sanitization step.

Attack in practice

A CMS field holds <img src=x onerror=steal()> — every visitor who views that page silently exfiltrates their session cookie.

Vulnerable
// ❌ Vulnerable
<div dangerouslySetInnerHTML=
{{ __html: cms.body }} />
// cms.body is editor-controlled
Fixed
// ✅ Fixed
import DOMPurify from 'dompurify'
<div dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
cms.body)
}} />
Defense in depth — layer the fix
  1. Sanitize on every render, with no "trusted CMS user" exception
  2. CSP with no unsafe-inline as a defense-in-depth backstop
  3. Escape output by default; gate dangerouslySetInnerHTML behind review

09. Trusting Client-Side-Only Checks

CWE-602OWASP A06:2025

The mistake

File-type/size limits, cooldown timers, or discount/business logic are enforced only in frontend JavaScript.

Attack in practice

Open devtools, delete the disabled attribute and the size check, submit straight to the API — the frontend code never even ran.

Vulnerable
// ❌ Vulnerable
// frontend only
if (file.size > MAX)
return reject()
uploadFile(file)
Fixed
// ✅ Fixed
// backend — re-checked regardless
// of what the client already did
if (file.size > MAX ||
!ALLOWED_TYPES.has(file.mimetype))
return res.sendStatus(413)
Defense in depth — layer the fix
  1. Re-validate type, size, and business rules server-side, always
  2. Treat client checks as UX only — never document them as "the" check
  3. A server-side test that calls the API directly, bypassing the UI

10. SSRF via Unvalidated Outbound Requests

CWE-918OWASP A01:2025

The mistake

A "fetch this URL for me" feature (image proxy, link preview) accepts any user-supplied URL with no allowlist or IP filtering.

Attack in practice

Point the "preview this link" feature at the cloud metadata endpoint, and the server hands back its own IAM credentials in the preview.

Vulnerable
// ❌ Vulnerable
app.get('/preview', async (req,res)=>{
const html = await fetch(
req.query.url).then(r=>r.text())
res.send(html)
})
Fixed
// ✅ Fixed
app.get('/preview', async (req,res)=>{
assertAllowedHost(req.query.url)
const html = await fetchSafe(req.query.url,
{timeoutMs:3000, maxBytes:1e6})
res.send(html)
})
Defense in depth — layer the fix
  1. Allowlist destination domains and protocols, not a blocklist
  2. Block private / link-local / metadata IP ranges at the network layer too
  3. Enforce timeouts and response-size caps on every outbound fetch

11. Race Conditions in Financial Logic

CWE-362OWASP A06:2025

The mistake

A balance or credit is read, checked, and written back in three separate steps with no locking or atomic guarantee.

Attack in practice

Fire the same "redeem credit" request ten times in parallel — each reads the same starting balance, so all ten succeed.

Vulnerable
// ❌ Vulnerable
const user = await User.findById(id)
if (user.credit >= amount) {
user.credit -= amount
await user.save()
}
Fixed
// ✅ Fixed
await db.collection('users')
.updateOne(
{ _id: id, credit: {$gte: amount} },
{ $inc: { credit: -amount } }
)
// atomic — fails safely if already spent
Defense in depth — layer the fix
  1. Atomic DB operations (conditional updates / transactions) for balances
  2. Idempotency keys on every retryable write, especially payments
  3. Concurrency/load-test critical financial paths before shipping

12. Missing Webhook Signature Verification

CWE-345OWASP A08:2025

The mistake

A webhook handler trusts the incoming payload unconditionally — the provider's signature header is never checked.

Attack in practice

The webhook URL is rarely secret (check your JS bundle or API docs) — anyone can POST a fake "payment.succeeded" event directly.

Vulnerable
// ❌ Vulnerable
app.post('/webhooks/billing',
(req,res)=>{
handleEvent(req.body)
// trusted unconditionally
})
Fixed
// ✅ Fixed
app.post('/webhooks/billing', (req,res)=>{
const sig = req.headers['x-signature']
if (!verifyHmac(req.rawBody, sig, WEBHOOK_SECRET))
return res.sendStatus(401)
handleEvent(req.body)
})
Defense in depth — layer the fix
  1. Verify the provider's signature on every webhook, using the raw body
  2. Reject events outside a small timestamp window (replay protection)
  3. Make handlers idempotent — the same legitimate event may arrive twice

13. Mass Assignment / Over-Posting

CWE-915OWASP A01:2025

The mistake

A write endpoint assigns the entire request body to the model, instead of an explicit allowlist of editable fields.

Attack in practice

The "edit my profile" form only shows name and bio, but the attacker adds "role":"admin" to the JSON body by hand — and it saves.

Vulnerable
// ❌ Vulnerable
await User.findByIdAndUpdate(
req.user.id, req.body)
Fixed
// ✅ Fixed
const { name, bio } = req.body
// explicit allowlist only
await User.findByIdAndUpdate(
req.user.id, { name, bio })
Defense in depth — layer the fix
  1. Explicit field allowlists or DTO/schema validation on every write
  2. Separate, more heavily guarded endpoints for privileged fields
  3. Schema-level "immutable after creation" flags for fields like ownerId

14. Weak Cryptography & Predictable Randomness

CWE-330OWASP A04:2025

The mistake

Tokens, OTPs, or password-reset codes are generated with Math.random() or a timestamp instead of a CSPRNG.

Attack in practice

Math.random()-based OTPs aren't cryptographically unpredictable; enough samples narrow the search space inside the code's expiry window.

Vulnerable
// ❌ Vulnerable
const code = Math.floor(
100000 + Math.random()*900000
)
Fixed
// ✅ Fixed
const code = crypto.randomInt(
100000, 999999
)
// CSPRNG + rate limit + short expiry
Defense in depth — layer the fix
  1. Use a CSPRNG for anything security-sensitive — tokens, OTPs, reset codes
  2. Pair randomness with rate limiting and short expiry, not either alone
  3. Never derive an authorization-boundary ID from Math.random() or a timestamp

Part 3 — How to Triage What You Find

How to triage what you find

A finding without a severity is just a to-do list. Use likelihood × impact to turn "we found 135 things" into a backlog someone can actually act on.

Severity Definition Typical examples
CRITICAL Full account takeover, or plaintext access to many users' data/secrets — no special access needed. Forgeable authentication · Secrets in git · Metadata-endpoint SSRF
HIGH Access to one user's or one tenant's data or funds by manipulating a normal, otherwise-valid request. Broken access control / IDOR · Race conditions · Mass assignment
MEDIUM Makes a real attack meaningfully easier, or removes a safety net — not independently exploitable. Missing rate limiting · Missing headers/CORS · No webhook signature check
LOW Hygiene and defense-in-depth — closes a gap before it becomes tomorrow's Critical. Weak randomness outside auth · Verbose errors · Client-side-only checks

Rule of thumb: fix every Critical this week, every High this month — Medium and Low ride along with normal roadmap work.

Part 4 — The Complete Best-Practices Reference

The complete best-practices reference

116 practices researched from OWASP, AWS Well-Architected, MASVS and current AI-agent/MCP security guidance — each with detail and a concrete example. Because it's long, it lives on its own pages:

  • Security Checklists — all 116 practices, split into nine per-discipline checklists, each with a description and a worked example.
  • Project Security Checklist — the same 116 practices as a tickable checklist for a single project, with progress tracking.
  • AWS Quick Checklist — the 21-item IAM and security-group pass for any AWS account.

Part 5 — Catching It Before It Ships

Shift left, one gate at a time

Where each pattern in this deck gets caught automatically, long before production.

Gate What it catches
Pre-commit gitleaks / truffleHog block the push itself the moment a secret pattern matches — the fix from Mistake #3, automated.
CI · SAST Semgrep or CodeQL scans every PR for the exact patterns in Part 2 — IDOR shapes, unverified JWTs, dangerouslySetInnerHTML.
CI · Dependencies Dependabot / Snyk flags EOL and vulnerable packages automatically, before they're the reason for an incident report.
Code review A 5-question security checklist on the PR template: auth? authz? input validated? secrets? logging?
Pre-release A lightweight threat model for any feature touching auth, payments, or PII — 30 minutes, not a formal audit.
Ongoing An annual third-party pentest, plus a clear, low-friction way for anyone to report a finding responsibly.

Part 6 — Tools You Can Run Today

Tools you can run today

Free, CLI-first scanners for your code, your dependencies, and your live website.

Static Code Analysis (SAST)

Semgrep

Pattern-based static analysis that understands syntax across dozens of languages — point it at a ruleset (OWASP Top Ten, or "auto" to infer from your stack) and it flags injection, crypto misuse, and auth bugs right in the PR diff. The most widely adopted general-purpose SAST tool; free and open-source, with a paid AppSec Platform for cross-repo dashboards.

semgrep scan --config auto .
# or a specific ruleset:
semgrep --config "p/owasp-top-ten" .
CodeQL

GitHub's own semantic/dataflow SAST engine — traces how untrusted input actually flows through the codebase instead of just matching text, catching bugs regex-based tools miss. The default choice for any team already on GitHub; free for public repos and via GitHub Advanced Security, paid for private repos.

codeql database create db --language=javascript
codeql database analyze db \
--format=sarif-latest --output=results.sarif \
codeql/javascript-queries

Secrets & Dependencies

Gitleaks

Regex- and entropy-based scanning for API keys, tokens, and credentials — across a working tree or the full git history. The most commonly recommended pre-commit and CI secrets scanner, and free and open-source.

gitleaks detect --source . \
--report-format json --report-path gitleaks-report.json
npm audit

Checks every package in your dependency tree against the npm/GitHub advisory database for known CVEs, and can rewrite package.json to non-breaking safe versions automatically. Built into npm itself — the fastest first check for any Node project, no separate install needed.

npm audit
# auto-fix what it safely can:
npm audit fix
# fail CI on anything high or above:
npm audit --audit-level=high
Snyk CLI

Multi-ecosystem scanning — OSS dependencies, container images, IaC, and (via Snyk Code) SAST — all from one CLI. The most widely adopted dependency scanner for teams that outgrow a single package manager's built-in audit command. A free tier covers small teams; paid tiers unlock private repos and higher scan volume.

snyk test --severity-threshold=high
# container images too:
snyk container test node:20

Container, IaC & Cloud Posture

Trivy

An all-in-one scanner: container image and OS package vulnerabilities, IaC misconfiguration (Terraform, Kubernetes, CloudFormation, Dockerfiles), secrets, and SBOM generation. The most widely used free scanner in this category — tfsec has been folded into it, so `trivy config` is now the recommended path for Terraform scanning too.

trivy image myapp:latest
trivy fs .
trivy config ./terraform-dir
Prowler

Hundreds of checks against CIS benchmarks and common compliance frameworks across AWS, Azure, GCP, and Kubernetes. The most widely used free cloud security posture tool — the CLI is open-source and actively developed; a paid SaaS tier adds fleet-wide dashboards and continuous monitoring.

prowler aws --severity critical high

Website & API Scanning (DAST)

OWASP ZAP

A passive baseline scan checks a live site for common web vulnerabilities — headers, cookies, XSS indicators — without firing active attack payloads, making it safe to run against staging in CI. The most widely used free DAST tool; the Docker image moved to ghcr.io, so update old `owasp/` image references.

docker run -t ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t https://staging.example.com \
-r zap-report.html
Nuclei

Template-based scanning for known CVEs, exposed admin panels, and common misconfigurations, backed by a huge and frequently updated community template library. Free and open-source (ProjectDiscovery) — the fastest-growing scanner in this category; a paid cloud platform adds team-scale features.

nuclei -u https://example.com -t cves/ \
-severity critical,high

How we run security tools at Offspring Digital

security.mk runs the scanners locally; the security-tools-runner skill reviews the code, verifies every finding, and merges everything into one report.

Running the scan
  1. Download the local scanner runner — security.mk
  2. Install the review skill — security-tools-runner
  3. Run the scanners:
    make -j 8 -f security.mk WEBSITE_URL={website_url} PROJECT_DIR={project_dir} all
  4. Run the review: /security-tools-runner with the project directory
  5. Review the combined report the skill returns.
Finding template
[🔴/🟠/🟡/🔵] Issue #X: [Brief Title]
Location: file:line or general pattern
Category: Security / Performance / Quality / Practice
Risk Level: Critical / High / Medium / Low
Problem: [2-3 sentence description]
Impact: [What could go wrong]
Fix:
  // Current problematic code (if applicable)
  [bad code here]
  // Recommended solution
  [fixed code here]

Resources

Resource Why it's here
OWASP Top 10 The standard reference for web application risk categories.
OWASP ASVS A verification checklist to grade maturity against, not just a top-risks list.
CWE Top 25 The specific weakness classes behind every pattern in this deck.
OWASP MASVS The mobile equivalent, for app-specific checks.
Semgrep / CodeQL SAST that catches these exact patterns automatically in CI.
Snyk / Dependabot Dependency and EOL-package scanning, wired into the PR flow.
gitleaks / truffleHog Pre-commit and CI secret scanning — catches the next hardcoded key before it ships.
DOMPurify Battle-tested HTML sanitization before any dangerouslySetInnerHTML / innerHTML.

Last updated: September 2026 · Source: Offspring — Security Best Practices.pptx