DEV Community

Cover image for IDOR in PHP and Laravel — When Changing One Number Exposes Someone Else's Data

IDOR in PHP and Laravel — When Changing One Number Exposes Someone Else's Data

This is the fifteenth article in a series on PHP and Laravel application security.

So far we have covered:

  • Article 1: What your PHP logs actually look like during a SQL injection attack
  • Article 2: Why URL encoding can break PHP security checks
  • Article 3: The decode bomb problem — why unlimited URL decoding can be its own vulnerability
  • Article 4: Parameterized queries — the only real fix for SQL injection
  • Article 5: XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • Article 6: How attackers enumerate your Laravel app before exploiting it
  • Article 7: File upload security in PHP and Laravel
  • Article 8: Path traversal in PHP — how ../ escapes your application
  • Article 9: Command injection in PHP — when exec() becomes an attack surface
  • Article 10: Broken access control in Laravel — why being logged in is not enough
  • Article 11: Secrets in Laravel — why .env is only the beginning
  • Article 12: Session security in PHP — what most developers get wrong
  • Article 13: Rate limiting in Laravel and PHP — how to stop brute force before it starts
  • Article 14: Security headers in PHP and Laravel — the lines that harden every response

Every article in this series follows the same principle: understand the attack before you try to stop it.

IDOR does not require exploiting a buffer overflow. It does not require reverse engineering binary code. It does not require any technical skill beyond knowing how to change a number in a URL. And it has caused some of the most significant data breaches in recent history.

What IDOR Actually Is

Insecure Direct Object Reference is when your application exposes a reference to an internal object a database record, a file, a user account — and does not verify that the requesting user is authorized to access that specific object.

Your application shows a user their order:

https://yoursite.com/orders/1042
Enter fullscreen mode Exit fullscreen mode

The number 1042 is the direct object reference. It points directly to a row in your database. The application checked that the user is logged in. It did not check that order 1042 belongs to this user.

An attacker changes the number:

https://yoursite.com/orders/1043
https://yoursite.com/orders/1044
https://yoursite.com/orders/1045
Enter fullscreen mode Exit fullscreen mode

Each one returns a different customer's order. The attacker enumerates every order in your system by incrementing a number. No hacking required. No exploit needed. Just arithmetic.

Why IDOR Is So Common

IDOR is part of Broken Access Control the number one vulnerability on the OWASP Top 10 since the 2021 edition. It is so common because it comes from a natural but wrong assumption:

The user is logged in so they must be allowed to see this.

Authentication proves identity. It says nothing about authorization. A logged-in user is not automatically allowed to access every resource in your system only the resources that belong to them or that they have been explicitly granted access to.

Most developers check authentication. Most developers forget authorization at the object level. The code looks correct because the authentication check is there. The vulnerability exists because the ownership check is not.

This is also where request-level security monitoring becomes useful. Authorization prevents the unauthorized request from succeeding but monitoring can reveal that someone is repeatedly attempting those requests. A single failed request to /orders/1043 may be normal. Hundreds of sequential requests across /orders/1043, /orders/1044, /orders/1045 and beyond are a very different signal — the kind of behavioral pattern Kriosa is designed to surface.

Where IDOR Appears in PHP Applications

IDOR hides in four common places.

URL parameters:

GET /profile?user_id=42
GET /invoice/download?id=1089
GET /messages?thread_id=567
Enter fullscreen mode Exit fullscreen mode

POST body parameters:

POST /update-address
Body: user_id=42&street=New+Street
Enter fullscreen mode Exit fullscreen mode

API endpoints:

GET /api/v1/users/42
DELETE /api/v1/documents/1089
PUT /api/v1/orders/567
Enter fullscreen mode Exit fullscreen mode

File downloads:

GET /download?file=invoice_1089.pdf
GET /documents/contract_42.pdf
Enter fullscreen mode Exit fullscreen mode

Every one of these is a potential IDOR vulnerability if the application does not verify that the authenticated user is authorized to access that specific resource.

The Vulnerable Code Pattern

Here is what vulnerable PHP looks like:

// Dangerous — checks authentication but not authorization
function getOrder(PDO $pdo, int $orderId): ?array
{
    if (!isset($_SESSION['user_id'])) {
        header('Location: /login');
        exit;
    }

    $stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
    $stmt->execute([$orderId]);
    return $stmt->fetch() ?: null;
}

$orderId = (int) $_GET['id'];
$order = getOrder($pdo, $orderId);
Enter fullscreen mode Exit fullscreen mode

The authentication check is present. The authorization check is absent. Any logged-in user can access any order in the database by changing the ID in the URL.

Fix 1 — Ownership Check in the Query

The simplest and most reliable fix: include the authenticated user's ID in every query that retrieves user-specific data.

function getOrder(PDO $pdo, int $orderId, int $userId): ?array
{
    $stmt = $pdo->prepare('
        SELECT * FROM orders
        WHERE id = ? AND user_id = ?
    ');
    $stmt->execute([$orderId, $userId]);
    $order = $stmt->fetch();

    if (!$order) {
        http_response_code(404);
        exit;
    }

    return $order;
}

$orderId = (int) $_GET['id'];
$userId  = (int) $_SESSION['user_id'];
$order   = getOrder($pdo, $orderId, $userId);
Enter fullscreen mode Exit fullscreen mode

The AND user_id = ? clause means the database only returns the order if both conditions are true the ID matches and it belongs to the authenticated user. An attacker who changes the order ID gets a 404 for any order that does not belong to them.

On returning 404 versus 403:

When revealing whether the resource exists would itself disclose sensitive information returning 404 instead of 403 is a useful defensive pattern. A 403 tells the attacker "this resource exists but you cannot access it." A 404 tells them nothing. In many contexts this distinction meaningfully reduces information leakage.

Fix 2 — Scoped Queries

Scoped queries make IDOR structurally impossible by always filtering by the authenticated user before any ID lookup:

// Returns only orders belonging to this user
function getUserOrders(PDO $pdo, int $userId): array
{
    $stmt = $pdo->prepare('
        SELECT * FROM orders
        WHERE user_id = ?
        ORDER BY created_at DESC
    ');
    $stmt->execute([$userId]);
    return $stmt->fetchAll();
}

// Returns one specific order only if it belongs to this user
function getUserOrder(PDO $pdo, int $orderId, int $userId): ?array
{
    $stmt = $pdo->prepare('
        SELECT * FROM orders
        WHERE id = ? AND user_id = ?
    ');
    $stmt->execute([$orderId, $userId]);
    return $stmt->fetch() ?: null;
}
Enter fullscreen mode Exit fullscreen mode

There is no code path in these functions where a user can retrieve another user's data. The scope is enforced at the database layer not as a conditional check after the fact.

Fix 3 — Non-Sequential Identifiers

Sequential integer IDs make enumeration trivial the attacker increments and iterates. Non-sequential identifiers like UUIDs make this significantly harder:

/orders/550e8400-e29b-41d4-a716-446655440000
Enter fullscreen mode Exit fullscreen mode

Use a trusted library rather than implementing UUID generation manually:

// composer require ramsey/uuid
use Ramsey\Uuid\Uuid;

function createOrder(PDO $pdo, int $userId): string
{
    $uuid = Uuid::uuid4()->toString();

    $stmt = $pdo->prepare('
        INSERT INTO orders (uuid, user_id, created_at)
        VALUES (?, ?, NOW())
    ');
    $stmt->execute([$uuid, $userId]);

    return $uuid;
}
Enter fullscreen mode Exit fullscreen mode

Important: UUIDs are not an authorization mechanism. They make enumeration harder not impossible. A UUID discovered through logs, referrer headers, or shared links can still be accessed by unauthorized users if no authorization check exists. Always combine non-sequential identifiers with proper ownership verification.

IDOR in File Downloads

File download endpoints are a particularly common IDOR vector:

// Dangerous — filename comes directly from user input
$filename = $_GET['file'];
$filepath = '/var/www/storage/invoices/' . $filename;

if (file_exists($filepath)) {
    header('Content-Type: application/pdf');
    readfile($filepath);
}
Enter fullscreen mode Exit fullscreen mode

The fix verifies ownership before constructing the file path and never takes the filename from user input.

function downloadInvoice(PDO $pdo, int $invoiceId, int $userId): void
{
    // Verify ownership first — fetch filename from database
    $stmt = $pdo->prepare('
        SELECT filename FROM invoices
        WHERE id = ? AND user_id = ?
    ');
    $stmt->execute([$invoiceId, $userId]);
    $invoice = $stmt->fetch();

    if (!$invoice) {
        http_response_code(404);
        exit;
    }

    // Filename comes from database not from user input
    $filepath = '/var/www/storage/invoices/' . $invoice['filename'];

    if (!file_exists($filepath)) {
        http_response_code(404);
        exit;
    }

    header('Content-Type: application/pdf');
    header('Content-Disposition: attachment; filename="invoice.pdf"');
    readfile($filepath);
}

$invoiceId = (int) $_GET['id'];
$userId    = (int) $_SESSION['user_id'];
downloadInvoice($pdo, $invoiceId, $userId);
Enter fullscreen mode Exit fullscreen mode

The user supplies an ID. The database returns the filename but only after confirming the invoice belongs to the authenticated user. The user never controls the file path directly.

IDOR in Laravel

Laravel provides several mechanisms to prevent IDOR.

Explicit ownership check:

// Dangerous — no ownership check
public function show(Order $order)
{
    return view('orders.show', compact('order'));
}

// Safe — explicit ownership check
public function show(Order $order)
{
    if ($order->user_id !== auth()->id()) {
        abort(404);
    }
    return view('orders.show', compact('order'));
}
Enter fullscreen mode Exit fullscreen mode

Using policies:

// app/Policies/OrderPolicy.php
class OrderPolicy
{
    public function view(User $user, Order $order): bool
    {
        return $user->id === $order->user_id;
    }

    public function update(User $user, Order $order): bool
    {
        return $user->id === $order->user_id;
    }

    public function delete(User $user, Order $order): bool
    {
        return $user->id === $order->user_id;
    }
}
Enter fullscreen mode Exit fullscreen mode
public function show(Order $order)
{
    $this->authorize('view', $order);
    return view('orders.show', compact('order'));
}
Enter fullscreen mode Exit fullscreen mode

Scoped queries the strongest approach:

// This can never return another user's order
public function show(int $id)
{
    $order = auth()->user()->orders()->findOrFail($id);
    return view('orders.show', compact('order'));
}
Enter fullscreen mode Exit fullscreen mode

The query is scoped to the authenticated user before the ID lookup. findOrFail() returns a 404 automatically if no matching record is found for this user.

Scoped route model binding:

Route::get('/users/{user}/orders/{order}', [OrderController::class, 'show'])
    ->scopeBindings();
Enter fullscreen mode Exit fullscreen mode

Laravel scopes the {order} binding through the parent {user} relationship automatically no manual ownership check needed in the controller.

File downloads in Laravel:

public function download(int $id)
{
    $invoice = auth()->user()->invoices()->findOrFail($id);

    if (!Storage::disk('local')->exists('invoices/' . $invoice->filename)) {
        abort(404);
    }

    return response()->download(
        storage_path('app/invoices/' . $invoice->filename),
        'invoice.pdf'
    );
}
Enter fullscreen mode Exit fullscreen mode

API Endpoints and IDOR

API endpoints are where IDOR is most commonly forgotten:

// Dangerous — any authenticated user can access any user's data
Route::get('/api/users/{id}', function ($id) {
    return User::find($id);
})->middleware('auth:sanctum');

// Safe — scoped to authenticated user
Route::get('/api/profile', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');

// Safe — ownership check for specific resources
Route::get('/api/orders/{id}', function (Request $request, $id) {
    $order = $request->user()->orders()->findOrFail($id);
    return response()->json($order);
})->middleware('auth:sanctum');
Enter fullscreen mode Exit fullscreen mode

Being authenticated via Sanctum does not mean the user is authorized to access every resource. The ownership check is required on every API endpoint that returns user-specific data.

The IDOR Prevention Checklist

For plain PHP:

  • Never fetch a resource using only a user-supplied ID
  • Always include the authenticated user's ID in every ownership-sensitive query
  • Return 404 not 403 when revealing resource existence would leak sensitive information
  • Use non-sequential identifiers from a trusted UUID library as an additional layer — not as a substitute for authorization
  • Never construct file paths from user-supplied filenames fetch filenames from the database after ownership verification
  • Apply ownership checks to every endpoint GET, POST, PUT, DELETE, and file downloads
  • Apply the same rules to API endpoints as to web endpoints

For Laravel:

  • Use scoped queries auth()->user()->orders()->findOrFail($id) as the default pattern
  • Use policies for complex authorization logic
  • Use scoped route model binding for nested resources
  • Never use Model::find($id) without an ownership check for user-specific resources
  • Apply the same authorization rules to API routes as web routes
  • Return abort(404) when ownership verification fails

Where Kriosa Fits

Authorization controls prevent unauthorized access. They do not tell you when someone is systematically probing for authorization gaps.

Kriosa complements these controls by monitoring incoming request patterns for behavioral signals such as repeated resource enumeration and unusual access patterns. When detected those signals are surfaced in the security dashboard alongside the request context giving you visibility into what is happening before a breach occurs.

A single failed request to /orders/1043 may be a typo. Hundreds of sequential requests across incrementing order IDs from the same authenticated session is a pattern worth investigating. Authorization alone cannot surface that distinction. Behavioral monitoring can.

In production applications like
Prolify where each client session is a trust boundary and client work must never cross between accounts and
Belle-Full where customer order data belongs to specific users these two layers work together. Authorization controls prevent breaches. Behavioral monitoring surfaces the probing that precedes them.

Prevention stops the breach. Detection tells you someone is looking for one.

Try it free: kriosa.com
Install it: composer require kriosa-ai/kriosa-php

Sleep better — we're awake. Kriosa.

Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.

The Series So Far

  • Article 1: What your PHP logs actually look like during a SQL injection attack
  • Article 2: Why URL encoding can break PHP security checks
  • Article 3: The decode bomb problem — why unlimited URL decoding can be its own vulnerability
  • Article 4: Parameterized queries — the only real fix for SQL injection
  • Article 5: XSS prevention in Laravel and why {!! !!} is the line between safe and hacked
  • Article 6: How attackers enumerate your Laravel app before exploiting it
  • Article 7: File upload security in PHP and Laravel
  • Article 8: Path traversal in PHP — how ../ escapes your application
  • Article 9: Command injection in PHP — when exec() becomes an attack surface
  • Article 10: Broken access control in Laravel — why being logged in is not enough
  • Article 11: Secrets in Laravel — why .env is only the beginning
  • Article 12: Session security in PHP — what most developers get wrong
  • Article 13: Rate limiting in Laravel and PHP — how to stop brute force before it starts
  • Article 14: Security headers in PHP and Laravel — the lines that harden every response
  • Article 15: This article — IDOR in PHP and Laravel and when changing one number exposes someone else's data

Top comments (0)