Case Study
IDOR
Broken Access Control
Fintech
Node.js

How We Found IDOR Bugs in a Fintech Startup — A Real Code Review Story

SecureCodeReviews Team
March 8, 2026
12 min read
Share

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

PatternCountEffect
Missing ownership checks14 endpointsAny user could access any resource
Sequential integer IDsAll tablesTrivially enumerable
Auth ≠ Authorization confusionSystemicTeam 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.

Request a Free Sample Code Review →

Advertisement