← Back to all writeups

Code Review: Insecure Direct Object Reference — Accessing Any User's Data

A profile endpoint that fetched user data by ID from the request parameter — with no ownership check — allowed any authenticated user to read any other user's private information.

idorbroken-access-controlnodejscode-reviewweb

IDOR — Insecure Direct Object Reference — consistently ranks as one of the most common and most impactful vulnerabilities found in web applications. It’s also one of the simplest. No complex payloads. No chaining. Just changing a number in a request.

This code review covers a classic example — a profile endpoint that forgets to ask one critical question.


The Code

app.get('/api/user/profile', async (req, res) => {
    const userId = req.query.userId;

    const user = await db.query(
        'SELECT id, name, email, phone, address FROM users WHERE id = ?',
        [userId]
    );

    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }

    return res.json(user);
});

Simple. Clean. Broken.


Breaking It Down

The endpoint accepts a userId parameter from the query string and fetches that user’s profile from the database. The response includes name, email, phone number, and home address.

Here’s the flow:

  1. Request comes in with ?userId=123
  2. userId is taken directly from req.query
  3. A database query fetches the user with that ID
  4. The full profile is returned — name, email, phone, address

At no point does the code ask: “Does the person making this request actually own this profile?”

That missing question is the entire vulnerability.


The Attack

An authenticated user makes a legitimate request to view their own profile:

GET /api/user/profile?userId=1001 HTTP/1.1
Authorization: Bearer <valid_token>

Response:

{
  "id": 1001,
  "name": "Current User",
  "email": "user@email.com",
  "phone": "555-0101",
  "address": "123 Main St"
}

Now they change the userId to someone else’s:

GET /api/user/profile?userId=1002 HTTP/1.1
Authorization: Bearer <valid_token>

Response:

{
  "id": 1002,
  "name": "Another User",
  "email": "victim@email.com",
  "phone": "555-0199",
  "address": "456 Private Rd"
}

Full PII of any user in the system — returned without restriction. The attacker just needs to increment the ID and repeat. Automated with a script, this becomes a full database dump of every user’s personal information.


Why Authentication Alone Is Not Enough

The endpoint does require authentication — you need a valid Bearer token to call it. Many developers stop there and consider the endpoint secure.

Authentication answers: “Who are you?”

Authorization answers: “Are you allowed to do this?”

This endpoint handles authentication but skips authorization entirely. It confirms the caller is a logged-in user but never checks whether that user has permission to access the requested resource. These are two completely different controls — and both are required.


Root Cause

One missing check:

const userId = req.query.userId;
// Never asks: does req.user.id === userId ?

The authenticated user’s identity is available on the request object — req.user from the JWT or session middleware. It’s simply never compared against the requested resource.


The Fix

Fix 1 — Always serve the authenticated user’s own data:

app.get('/api/user/profile', async (req, res) => {
    // Use the authenticated user's ID from the token — not from the request
    const userId = req.user.id;

    const user = await db.query(
        'SELECT id, name, email, phone, address FROM users WHERE id = ?',
        [userId]
    );

    if (!user) {
        return res.status(404).json({ error: 'User not found' });
    }

    return res.json(user);
});

The user ID comes from the verified JWT — not from user-controlled input. The parameter is removed entirely.

Fix 2 — If the ID must come from the request, validate ownership:

app.get('/api/user/profile', async (req, res) => {
    const requestedId = parseInt(req.query.userId);

    // Reject if the requested ID doesn't match the authenticated user
    if (requestedId !== req.user.id) {
        return res.status(403).json({ error: 'Access denied' });
    }

    const user = await db.query(
        'SELECT id, name, email, phone, address FROM users WHERE id = ?',
        [requestedId]
    );

    return res.json(user);
});

Fix 3 — For admin access to other profiles, add explicit role check:

if (requestedId !== req.user.id && req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Access denied' });
}

Key Takeaways

  • Authentication and authorization are not the same thing — always implement both
  • Never trust user-supplied IDs for resource ownership — derive the owner from the verified session or token
  • IDOR is easy to miss in code review — look for any endpoint that accepts an ID parameter and fetches a resource without an ownership check
  • Sequential integer IDs make IDOR trivial to exploit — consider UUIDs to reduce guessability, though this is not a substitute for proper authorization
  • Test every resource endpoint with another user’s token — if you can access it, it’s vulnerable

Found this useful? More code reviews coming. Hit me up on X if you want to discuss.