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 mistakeAn endpoint checks that you're logged in — but never checks that you're allowed to touch this specific record.
Attack in practiceA 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
- Object-level authorization on every read & write, not just the route
- Deny-by-default middleware applied to every new endpoint
- Automated cross-tenant (BOLA) test in CI for every resource route
02. Authentication You Can Forge
CWE-347OWASP A07:2025
The mistakeA JWT's payload is base64-decoded and trusted directly, or OAuth skips state/PKCE — signature verification never actually happens.
Attack in practiceDecode 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
- Verify signatures with a pinned algorithm allowlist — reject "alg: none"
- Short-lived access tokens with rotated, revocable refresh tokens
- State + PKCE on every OAuth redirect flow, no exceptions
03. Secrets Committed to Git
CWE-798OWASP A02:2025
The mistakeSigning keys, API tokens, and service-account credentials get committed straight into the repo — sometimes into .env.example "as a placeholder."
Attack in practiceOne 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
- Pre-commit + CI secret scanning (gitleaks/truffleHog) blocking the push
- Centralized secrets manager with scoped, auditable access
- Rotate anything ever committed — deleting the line doesn't undo history
04. Sensitive Data Logged in Plaintext
CWE-532OWASP A04:2025
The mistakePasswords, tokens, card numbers, or PII get printed to console/logs by "temporary" debug code that ships to production.
Attack in practiceA 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
- Structured logging with an explicit, reviewed field allowlist
- Centralized redaction / PII scanning on the log pipeline itself
- Lint rule or review gate banning raw request/response body logging
05. No Rate Limiting, Free Enumeration
CWE-307OWASP A07:2025
The mistakeLogin, password-reset, OTP, and "does this email exist" endpoints accept unlimited attempts with no backoff.
Attack in practiceA 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
- Per-IP and per-account rate limits with exponential backoff
- Identical responses regardless of whether the account exists
- CAPTCHA or proof-of-work triggered after repeated failures
06. Tokens Stored Where XSS Can Reach Them
CWE-522OWASP A04:2025
The mistakeSession tokens live in localStorage or a cookie without httpOnly/secure/sameSite — or an ID token gets reused as an API bearer token.
Attack in practiceOne 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
- httpOnly + secure + sameSite cookies for all session state, always
- Short token lifetime with silent refresh driven by the cookie itself
- Never substitute an ID token for an access token, even "temporarily"
07. No Security Headers, Wide-Open CORS
CWE-346OWASP A02:2025
The mistakeNo Content-Security-Policy, no HSTS, no X-Frame-Options — and CORS configured with origin:'*' alongside credentials:true.
Attack in practiceA 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
- Helmet (or equivalent) applied by default to every service
- Explicit CORS origin allowlist — never wildcard combined with credentials
- CSP + X-Frame-Options as a clickjacking backstop, not the only control
08. XSS via Unsanitized HTML
CWE-79OWASP A05:2025
The mistakeCMS or user-supplied content is rendered with dangerouslySetInnerHTML / innerHTML / eval(), with no sanitization step.
Attack in practiceA 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
- Sanitize on every render, with no "trusted CMS user" exception
- CSP with no unsafe-inline as a defense-in-depth backstop
- Escape output by default; gate dangerouslySetInnerHTML behind review
09. Trusting Client-Side-Only Checks
CWE-602OWASP A06:2025
The mistakeFile-type/size limits, cooldown timers, or discount/business logic are enforced only in frontend JavaScript.
Attack in practiceOpen 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
- Re-validate type, size, and business rules server-side, always
- Treat client checks as UX only — never document them as "the" check
- A server-side test that calls the API directly, bypassing the UI
10. SSRF via Unvalidated Outbound Requests
CWE-918OWASP A01:2025
The mistakeA "fetch this URL for me" feature (image proxy, link preview) accepts any user-supplied URL with no allowlist or IP filtering.
Attack in practicePoint 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
- Allowlist destination domains and protocols, not a blocklist
- Block private / link-local / metadata IP ranges at the network layer too
- Enforce timeouts and response-size caps on every outbound fetch
11. Race Conditions in Financial Logic
CWE-362OWASP A06:2025
The mistakeA balance or credit is read, checked, and written back in three separate steps with no locking or atomic guarantee.
Attack in practiceFire 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
- Atomic DB operations (conditional updates / transactions) for balances
- Idempotency keys on every retryable write, especially payments
- Concurrency/load-test critical financial paths before shipping
12. Missing Webhook Signature Verification
CWE-345OWASP A08:2025
The mistakeA webhook handler trusts the incoming payload unconditionally — the provider's signature header is never checked.
Attack in practiceThe 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
- Verify the provider's signature on every webhook, using the raw body
- Reject events outside a small timestamp window (replay protection)
- Make handlers idempotent — the same legitimate event may arrive twice
13. Mass Assignment / Over-Posting
CWE-915OWASP A01:2025
The mistakeA write endpoint assigns the entire request body to the model, instead of an explicit allowlist of editable fields.
Attack in practiceThe "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
- Explicit field allowlists or DTO/schema validation on every write
- Separate, more heavily guarded endpoints for privileged fields
- Schema-level "immutable after creation" flags for fields like ownerId
14. Weak Cryptography & Predictable Randomness
CWE-330OWASP A04:2025
The mistakeTokens, OTPs, or password-reset codes are generated with Math.random() or a timestamp instead of a CSPRNG.
Attack in practiceMath.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
- Use a CSPRNG for anything security-sensitive — tokens, OTPs, reset codes
- Pair randomness with rate limiting and short expiry, not either alone
- 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.
- Download the local scanner runner —
security.mk - Install the review skill —
security-tools-runner - Run the scanners:
make -j 8 -f security.mk WEBSITE_URL={website_url} PROJECT_DIR={project_dir} all - Run the review:
/security-tools-runnerwith the project directory - Review the combined report the skill returns.
[🔴/🟠/🟡/🔵] 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