How We Found IDOR Bugs in a Fintech Startup — A Real Code Review Story
What is IDOR?
Insecure Direct Object Reference (IDOR) is a type of broken access control where an application exposes internal object IDs (database IDs, filenames, etc.) and fails to verify that the requesting user is authorized to access that object.
OWASP A01 — Broken Access Control has been the #1 web vulnerability since 2021. IDOR is the most common manifestation.
The Engagement
A Series A fintech startup approached us for a security code review before their SOC 2 audit. Their stack:
- Backend: Node.js + Express
- Database: PostgreSQL
- Auth: JWT-based, custom implementation
- Users: ~50,000 active accounts
- API: RESTful, consumed by React frontend and mobile apps
Scope: Full source code review of all API endpoints (~120 routes).
Finding #1: IDOR in Account Balance Endpoint (Critical)
The Vulnerable Code
// GET /api/accounts/:accountId/balance
router.get('/accounts/:accountId/balance', authMiddleware, async (req, res) => {
const { accountId } = req.params;
const account = await db.query(
'SELECT balance, currency FROM accounts WHERE id = $1',
[accountId]
);
if (!account.rows[0]) {
return res.status(404).json({ error: 'Account not found' });
}
res.json({ balance: account.rows[0].balance, currency: account.rows[0].currency });
});
The Problem
The endpoint checks if the user is authenticated (via authMiddleware) but never checks if the authenticated user owns the requested account. Any authenticated user could view any other user's balance by changing the accountId parameter.
Attack scenario:
# Attacker is user 42, but requests user 1's account
GET /api/accounts/1/balance
Authorization: Bearer <attacker's valid JWT>
# Response: { "balance": 147832.50, "currency": "USD" }
The Fix
router.get('/accounts/:accountId/balance', authMiddleware, async (req, res) => {
const { accountId } = req.params;
const userId = req.user.id; // From JWT
// Authorization check: user must own this account
const account = await db.query(
'SELECT balance, currency FROM accounts WHERE id = $1 AND user_id = $2',
[accountId, userId]
);
if (!account.rows[0]) {
return res.status(404).json({ error: 'Account not found' });
}
res.json({ balance: account.rows[0].balance, currency: account.rows[0].currency });
});
Finding #2: IDOR in Transaction History (Critical)
The Vulnerable Code
// GET /api/transactions?accountId=123&from=2024-01-01&to=2024-12-31
router.get('/transactions', authMiddleware, async (req, res) => {
const { accountId, from, to } = req.query;
const transactions = await db.query(
'SELECT * FROM transactions WHERE account_id = $1 AND date BETWEEN $2 AND $3 ORDER BY date DESC',
[accountId, from, to]
);
res.json({ transactions: transactions.rows });
});
Identical issue — any authenticated user could pull the full transaction history of any account. This exposed transaction amounts, dates, merchant names, and counterparty details.
Finding #3: IDOR in Document Download (High)
// GET /api/documents/:docId/download
router.get('/documents/:docId/download', authMiddleware, async (req, res) => {
const doc = await db.query(
'SELECT * FROM documents WHERE id = $1', [req.params.docId]
);
if (!doc.rows[0]) return res.status(404).json({ error: 'Not found' });
const filePath = path.join(UPLOADS_DIR, doc.rows[0].filename);
res.download(filePath);
});
Tax documents, bank statements, and KYC documents were downloadable by any authenticated user by iterating document IDs.
Root Cause Analysis
| Pattern | Count | Effect |
|---|---|---|
| Missing ownership checks | 14 endpoints | Any user could access any resource |
| Sequential integer IDs | All tables | Trivially enumerable |
| Auth ≠ Authorization confusion | Systemic | Team confused "logged in" with "allowed" |
The development team had implemented robust authentication (JWT, refresh tokens, HTTPS) but had a systemic blind spot for authorization. Every authMiddleware check only verified identity — never permissions.
Remediation Recommendations
1. Ownership Middleware
// Reusable authorization middleware
function ownsResource(resourceType) {
return async (req, res, next) => {
const resourceId = req.params.id || req.params[resourceType + 'Id'];
const userId = req.user.id;
const resource = await db.query(
`SELECT id FROM ${resourceType}s WHERE id = $1 AND user_id = $2`,
[resourceId, userId]
);
if (!resource.rows[0]) {
return res.status(404).json({ error: 'Not found' });
}
next();
};
}
// Usage
router.get('/accounts/:accountId/balance',
authMiddleware,
ownsResource('account'),
getBalance
);
2. Use UUIDs Instead of Sequential IDs
-- Use gen_random_uuid() for all user-facing IDs
CREATE TABLE accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
...
);
3. Automated IDOR Testing in CI/CD
Add authorization tests to your test suite:
describe('IDOR Prevention', () => {
it('should not allow user A to access user B account', async () => {
const response = await request(app)
.get(`/api/accounts/${userBAccountId}/balance`)
.set('Authorization', `Bearer ${userAToken}`);
expect(response.status).toBe(404); // Not 200!
});
});
Outcome
- 14 IDOR vulnerabilities fixed within 48 hours of our report
- Sequential IDs replaced with UUIDs across all user-facing APIs
- Authorization middleware added to all 120+ endpoints
- Automated IDOR test suite added to CI/CD pipeline
- Client passed SOC 2 Type II audit the following quarter
Get Your Code Reviewed
IDOR is the #1 vulnerability we find in code reviews — and it's almost always missed by automated scanners. Manual code review by security experts is the most effective way to catch authorization flaws.
Advertisement
Free Security Tools
Try our tools now
Expert Services
Get professional help
OWASP Top 10
Learn the top risks
Related Articles
Broken Access Control: Why It's the #1 OWASP Risk (With Real Exploits & Fixes)
Broken Access Control has been the #1 OWASP Top 10 risk since 2021. This deep dive covers IDOR, privilege escalation, forced browsing, and JWT flaws with real-world exploits, code examples, and enterprise-grade mitigations.
7 Security Mistakes Every Express.js App Makes in Production
From missing Helmet.js to unsafe deserialization — the most common security mistakes we find in Express.js applications during code reviews, with production-ready fixes.
WebSocket Security: 6 Vulnerabilities Developers Always Miss
WebSockets bypass traditional HTTP security controls. Here are the 6 most common vulnerabilities we find in WebSocket implementations — from CSWSH to message injection.