Developer Security Essentials: From OWASP to Supply Chain Safety
Developer Security Essentials: From OWASP to Supply Chain Safety
Security isn't a separate discipline anymore. Every developer ships code that's exposed to the internet, handles user data, or processes payments. Waiting for a security team to review your code is a luxury most teams don't have. This guide covers the security practices every developer should build into their daily workflow.
The OWASP Top 10: What Actually Matters
The OWASP Top 10 is the industry standard list of web application security risks. Here's each one with practical context, not just definitions.
1. Broken Access Control
The most common vulnerability. Users can access resources or perform actions they shouldn't.
// VULNERABLE -- checks if user is logged in, but not if they own the resource
app.get("/api/orders/:id", requireAuth, async (req, res) => {
const order = await db.orders.findById(req.params.id);
res.json(order); // Any logged-in user can see any order
});
// FIXED -- verify ownership
app.get("/api/orders/:id", requireAuth, async (req, res) => {
const order = await db.orders.findById(req.params.id);
if (!order) return res.status(404).json({ error: "Not found" });
if (order.userId !== req.user.id) return res.status(403).json({ error: "Forbidden" });
res.json(order);
});
Prevention checklist:
- Deny by default -- require explicit grants, not explicit denials
- Check authorization on every request, not just at login
- Use row-level security in your database when possible
- Never rely on client-side access control (hiding UI elements isn't security)
2. Injection
SQL injection, NoSQL injection, OS command injection. Still prevalent despite being well-understood.
// SQL INJECTION -- never concatenate user input into queries
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// Input: ' OR '1'='1' --
// Result: SELECT * FROM users WHERE email = '' OR '1'='1' --'
// SAFE -- parameterized queries
const user = await db.query("SELECT * FROM users WHERE email = $1", [req.body.email]);
// SAFE -- ORM with parameterization
const user = await prisma.user.findUnique({ where: { email: req.body.email } });
// COMMAND INJECTION -- never pass user input to shell commands
const { exec } = require("child_process");
exec(`convert ${req.body.filename} output.png`); // Vulnerable
// Input: "image.png; rm -rf /"
// SAFE -- use execFile with argument arrays
const { execFile } = require("child_process");
execFile("convert", [req.body.filename, "output.png"]); // Arguments are escaped
3. Cryptographic Failures
Using weak algorithms, storing passwords in plaintext, transmitting data without TLS.
// BAD -- MD5 or SHA-256 for passwords (no salting, too fast to brute force)
import { createHash } from "crypto";
const hash = createHash("sha256").update(password).digest("hex");
// GOOD -- bcrypt or argon2 (slow by design, auto-salted)
import bcrypt from "bcrypt";
const hash = await bcrypt.hash(password, 12); // 12 rounds
const isValid = await bcrypt.compare(inputPassword, hash);
// BETTER -- argon2id (winner of Password Hashing Competition)
import argon2 from "argon2";
const hash = await argon2.hash(password, { type: argon2.argon2id });
const isValid = await argon2.verify(hash, inputPassword);
4. Security Misconfiguration
Default credentials, unnecessary features enabled, overly permissive CORS, verbose error messages in production.
// BAD -- overly permissive CORS
app.use(cors({ origin: "*" }));
// GOOD -- explicit allowed origins
app.use(
cors({
origin: ["https://myapp.com", "https://staging.myapp.com"],
methods: ["GET", "POST", "PUT", "DELETE"],
credentials: true,
})
);
// BAD -- leaking stack traces in production
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
// GOOD -- generic errors in production
app.use((err, req, res, next) => {
console.error(err); // Log internally
res.status(500).json({ error: "Internal server error" });
});
5. Server-Side Request Forgery (SSRF)
When your server makes requests to URLs provided by users, attackers can access internal services.
// VULNERABLE -- fetching user-provided URLs without validation
app.post("/api/preview", async (req, res) => {
const response = await fetch(req.body.url); // Could be http://169.254.169.254/metadata
const html = await response.text();
res.json({ preview: html });
});
// SAFER -- validate and restrict URLs
import { URL } from "url";
import dns from "dns/promises";
async function isSafeUrl(urlString: string): Promise<boolean> {
const url = new URL(urlString);
if (!["http:", "https:"].includes(url.protocol)) return false;
// Resolve to check for internal IPs
const addresses = await dns.resolve(url.hostname);
for (const addr of addresses) {
if (
addr.startsWith("10.") ||
addr.startsWith("172.16.") ||
addr.startsWith("192.168.") ||
addr.startsWith("169.254.") ||
addr === "127.0.0.1"
) {
return false;
}
}
return true;
}
Dependency Scanning: Your Biggest Attack Surface
Most applications are 90%+ third-party code. A single compromised dependency can expose every user. Supply chain attacks are increasing every year.
Scanning Tools Comparison
| Tool | Free? | Languages | CI Integration | Auto-fix PRs |
|---|---|---|---|---|
| npm audit | Yes | JS/TS | Built-in | No |
| GitHub Dependabot | Yes | Multi-language | GitHub native | Yes |
| Snyk | Free tier | Multi-language | All major CI | Yes |
| Socket.dev | Free tier | JS/TS, Python | GitHub | No |
| Trivy | Yes (OSS) | Multi-language + containers | All major CI | No |
| Renovate | Yes (OSS) | Multi-language | All major CI | Yes |
npm audit in CI
# .github/workflows/security.yml
name: Security Scan
on:
push:
branches: [main]
pull_request:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm audit --audit-level=high
# Fails the build on high or critical vulnerabilities
Socket.dev: Beyond CVE Scanning
Traditional vulnerability scanners only catch known CVEs. Socket.dev analyzes package behavior -- detecting typosquatting, install scripts that exfiltrate data, and suspicious network calls.
# Install the Socket CLI
npm install -g @socketsecurity/cli
# Analyze your project
socket report create . --output report.json
# Check a specific package before installing
socket npm info lodash
Lock File Integrity
Your package-lock.json or bun.lockb is a security artifact. If an attacker modifies it to point to a malicious package version, your CI will install compromised code.
# Ensure CI uses exact versions from lock file
npm ci # Not `npm install`, which can modify the lock file
bun install --frozen-lockfile
Secrets Management
Hardcoded secrets in source code are the most preventable security incident. Here's how to handle secrets properly.
Never Commit Secrets
# .gitignore -- bare minimum
.env
.env.*
*.pem
*.key
credentials.json
service-account.json
Pre-commit Hook for Secrets Detection
# Install gitleaks
brew install gitleaks # macOS
# or download from https://github.com/gitleaks/gitleaks/releases
# Scan before committing
gitleaks detect --source . --verbose
# As a pre-commit hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
Runtime Secrets with Environment Variables
// config.ts -- validate secrets at startup, not at first use
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const config = {
database: {
url: requireEnv("DATABASE_URL"),
},
stripe: {
secretKey: requireEnv("STRIPE_SECRET_KEY"),
webhookSecret: requireEnv("STRIPE_WEBHOOK_SECRET"),
},
jwt: {
secret: requireEnv("JWT_SECRET"),
expiresIn: "1h",
},
} as const;
Secrets Management Services
For production systems, use a dedicated secrets manager instead of environment variables:
| Service | Best For | Rotation Support | Cost |
|---|---|---|---|
| AWS Secrets Manager | AWS-hosted apps | Automatic | $0.40/secret/month |
| HashiCorp Vault | Multi-cloud, on-prem | Automatic | Free (OSS) or paid |
| 1Password Connect | Small teams | Manual | Part of 1Password plan |
| Doppler | Developer experience | Automatic | Free tier available |
| Infisical | Open-source alternative | Automatic | Free (OSS) |
SAST: Static Application Security Testing
SAST tools analyze your source code for security vulnerabilities without running it. They catch issues early -- in your editor or CI pipeline.
Tool Comparison
| Tool | Languages | Speed | False Positive Rate | Free? |
|---|---|---|---|---|
| Semgrep | 30+ languages | Fast | Low | Free (OSS) |
| CodeQL | 10+ languages | Slow | Low | Free for open source |
| SonarQube | 30+ languages | Medium | Medium | Community edition free |
| Bandit | Python only | Fast | Low | Free (OSS) |
| Brakeman | Ruby/Rails only | Fast | Low | Free (OSS) |
Semgrep in Practice
Semgrep is the standout choice for most teams. It's fast, has a massive rule library, and lets you write custom rules.
# Install
pip install semgrep
# or
brew install semgrep
# Run with default security rules
semgrep --config=auto .
# Run specific rulesets
semgrep --config=p/owasp-top-ten .
semgrep --config=p/javascript .
semgrep --config=p/typescript .
# Custom Semgrep rule -- detect hardcoded JWT secrets
# .semgrep/custom-rules.yml
rules:
- id: hardcoded-jwt-secret
patterns:
- pattern: jwt.sign($PAYLOAD, "...")
message: |
JWT secret is hardcoded. Use an environment variable instead.
severity: ERROR
languages: [javascript, typescript]
metadata:
cwe: "CWE-798: Use of Hard-coded Credentials"
# CI integration
# .github/workflows/semgrep.yml
name: Semgrep
on: [pull_request]
jobs:
semgrep:
runs-on: ubuntu-latest
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep ci --config=auto
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
DAST: Dynamic Application Security Testing
DAST tools test your running application by sending malicious requests and analyzing responses. They find issues that static analysis can't -- like misconfigured headers or server-side vulnerabilities.
OWASP ZAP
ZAP (Zed Attack Proxy) is the most popular free DAST tool.
# Run ZAP baseline scan against your staging environment
docker run -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py \
-t https://staging.example.com \
-r report.html
# Full scan (slower, more thorough)
docker run -t ghcr.io/zaproxy/zaproxy:stable zap-full-scan.py \
-t https://staging.example.com \
-r report.html
# CI integration for ZAP
# .github/workflows/dast.yml
name: DAST Scan
on:
workflow_run:
workflows: ["Deploy to Staging"]
types: [completed]
jobs:
zap-scan:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: ZAP Baseline Scan
uses: zaproxy/[email protected]
with:
target: "https://staging.example.com"
rules_file_name: ".zap/rules.tsv"
Nuclei
Nuclei is a fast, template-based vulnerability scanner. It's particularly good for checking known CVEs and misconfigurations.
# Install
go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest
# Run with default templates
nuclei -u https://staging.example.com
# Run specific template categories
nuclei -u https://staging.example.com -tags cve,misconfig,exposure
Security Headers
Every web application should set these HTTP security headers:
// Express middleware for security headers
import helmet from "helmet";
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Avoid if possible
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "https://api.example.com"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
},
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
noSniff: true,
xssFilter: true,
})
);
You can verify your headers with:
# Check security headers
curl -I https://yoursite.com
# Or use the Security Headers API
curl https://securityheaders.com/?q=yoursite.com&followRedirects=on
Building Security Into Your Workflow
Security should be automated and continuous, not a quarterly audit. Here's a practical pipeline:
The Security Pipeline
Code -> Pre-commit hooks -> CI SAST scan -> PR review -> Deploy to staging -> DAST scan -> Production
(gitleaks) (Semgrep) (human) (ZAP/Nuclei)
Minimum Viable Security Checklist
For every project, regardless of size:
- Dependencies: Automated scanning with Dependabot or Renovate
- Secrets: Pre-commit hook with gitleaks, runtime validation of env vars
- Input validation: Validate and sanitize all user input at the API boundary
- Authentication: Use battle-tested libraries (passport, next-auth, lucia), never roll your own
- Authorization: Check on every request, deny by default
- HTTPS: Everywhere, no exceptions
- Security headers: Use helmet or equivalent
- Logging: Log authentication events, failed access attempts, and errors (but never log secrets or PII)
- Updates: Keep dependencies current -- most vulnerabilities are in outdated packages
What Not to Waste Time On
- Custom encryption algorithms (use standard libraries)
- Rolling your own authentication (use established providers)
- Security theater (WAFs without proper input validation)
- Penetration testing before you've done the basics above
Summary
Security isn't about being perfect -- it's about raising the bar high enough that attackers move on to easier targets. Start with the basics: validate input, scan dependencies, manage secrets properly, and use SAST in CI. Each layer you add makes your application significantly harder to compromise. The tools are free, the integration is straightforward, and the cost of a breach makes the investment trivial by comparison.