OWASP Top 10 lists are everywhere, but most coverage treats them as abstract categories rather than concrete engineering problems. I approach security differently after participating in PwC's Capture the Flag competition in 2023, where I saw firsthand how theoretical vulnerabilities translate into actual exploitation. I run production VPS infrastructure that gets probed daily (fail2ban blocks hundreds of attempts per day), and I build API-first ERP systems where a security failure means real financial data exposure. This is my practical OWASP Top 10 checklist — what I actually implement, not what security marketing tells you to implement.
The #1 vulnerability for three consecutive OWASP cycles. Broken access control means users can act outside their intended permissions — accessing other users' records, performing admin actions, or viewing data they should not see. The most common form I encounter in ERP systems is IDOR (Insecure Direct Object Reference): API endpoints like GET /invoices/:id that do not verify the requesting user owns that invoice. Every database query that fetches user-owned data must include a WHERE user_id = :currentUserId condition. In NestJS, I use a custom @CheckOwnership() decorator that wraps repository methods with automatic ownership validation.
The pattern I use in every NestJS ERP endpoint: extract user from JWT in a guard, attach user to request, in service methods always filter by user_id (or tenant_id for multi-tenant). Never trust a resource ID from the request body without verifying ownership. For admin operations, use a separate admin guard that checks role explicitly — not just 'role is not null' but 'role === Role.ADMIN'. Audit log every access control check failure — a burst of 403s from one user is often a probing attack.
// NestJS: Ownership check decorator pattern
@Injectable()
export class OwnershipGuard implements CanActivate {
constructor(private readonly invoiceService: InvoiceService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest()
const user = request.user
const resourceId = request.params.id
// Always query with user filter — never trust raw ID
const resource = await this.invoiceService.findOne({
where: { id: resourceId, userId: user.id }
})
if (!resource) {
throw new ForbiddenException('Resource not found or access denied')
}
request.resource = resource
return true
}
}
// Security headers in Next.js (next.config.js)
const securityHeaders = [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'", // adjust for your needs
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self' https://api.yourdomain.com",
].join('; ')
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
]From my experience hardening ERP systems: implement a centralized authorization service rather than scattering ownership checks throughout service methods. Every resource type (Invoice, Order, Employee) should have a canAccess(user, resource) method that centralizes the logic. This makes security auditing straightforward — you review authorization logic in one place rather than hunting through 50 service files.
Cryptographic failures (formerly 'Sensitive Data Exposure') cover inadequate encryption, weak algorithms, and key management failures. In practice: always use bcrypt (cost factor 12+) for password hashing, never MD5 or SHA1. Use TLS 1.2+ everywhere — check your nginx config with ssllabs.com/ssltest. Store secrets in environment variables or a secrets manager (HashiCorp Vault, AWS Secrets Manager), never in code or committed config files. For A03 Injection (SQL, command, LDAP), the fix is parameterized queries or ORMs with safe query builders — covered in depth in my SQL injection post.
HTTP security headers are free and take 10 minutes to configure but prevent entire classes of attacks. The headers I configure on every deployment: Content-Security-Policy (prevents XSS), X-Frame-Options: DENY (prevents clickjacking), X-Content-Type-Options: nosniff (prevents MIME sniffing), Strict-Transport-Security (enforces HTTPS), Referrer-Policy: strict-origin-when-cross-origin. Check your headers at securityheaders.com. In Next.js, add these in next.config.js headers(). In nginx, add add_header directives to the server block.
On one of my production VPS servers, I discovered via fail2ban logs that an attacker had found an exposed .env file in a public directory. The Nginx config had an improperly set document root that served the parent directory. The .env file contained database credentials and API keys. Fortunately, I caught it via the access logs before the credentials were abused — the attacker had downloaded the file but had not yet used the credentials. Immediately rotated all secrets. Lesson: always verify your Nginx document root is exactly /var/www/html/public (or equivalent), and never commit .env files. Add .env to .gitignore AND verify it with git ls-files --ignored.
Security misconfiguration is the most common finding in real deployments. In my VPS hardening checklist: disable root SSH login, use SSH key authentication only (PasswordAuthentication no in sshd_config), run fail2ban with aggressive SSH rules (3 failures = 1 hour ban), disable unused ports (ufw deny everything, allow only 22/80/443 plus app ports), remove default nginx server blocks that expose server version, and set proper file permissions on config files (chmod 600 on .env, private keys). Run lynis audit system regularly to catch configuration drift.
# VPS hardening checklist (Ubuntu 22.04)
# 1. Disable root SSH login
sudo sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
sudo sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl reload sshd
# 2. Install and configure fail2ban
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[sshd]
enabled = true
port = ssh
maxretry = 3
bantime = 3600
findtime = 600
[nginx-limit-req]
enabled = true
port = http,https
maxretry = 20
bantime = 86400
EOF
sudo systemctl enable --now fail2ban
# 3. UFW firewall
sudo ufw default deny incoming
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
# 4. Audit with lynis
sudo apt install -y lynis
sudo lynis audit system
# 5. Check for exposed .env files
curl -s https://yourdomain.com/.env | head -3
# Should return 403 or 404, never file contentsAuthentication failures include weak passwords, missing MFA, broken session management, and credential stuffing vulnerability. What I implement: rate limiting on login endpoints (5 attempts per minute per IP, then 15-minute lockout), bcrypt with cost 12 for all passwords, JWT with 15-minute access token expiry and 7-day refresh tokens stored in httpOnly cookies (not localStorage), account lockout after 10 failed attempts with email notification to user, and leaked password checking against HaveIBeenPwned API on registration/password change.
A09 Security Logging and Monitoring Failures: you need logs to detect breaches. Log all authentication events (success and failure with IP), all authorization failures, all admin actions, and all input validation failures. Store logs on a separate server or managed logging service — logs on the same server as the application can be deleted by an attacker who gains access. A10 SSRF (Server-Side Request Forgery): if your application fetches URLs provided by users, validate the URL against an allowlist of permitted domains and block requests to private IP ranges (10.x, 172.16-31.x, 192.168.x). Use a URL validation library, not regex — it is too easy to bypass with unicode characters or IP encoding tricks.
Sources & Further Reading