MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Laravel E-Learning Rebuild

1

Part 1: Introduction & Why I'm Upgrading

2

Part 2: Setting Up Laravel 12 + Filament

3

Part 3: Database Schema Design

4

Part 4: Authentication & User Roles

5

Part 5: Building the Exam System

6

Part 6: Real-Time Features

7

Part 7: Testing Strategy

8

Part 8: Deployment

9

Part 9: Community Issue Fix & CI/CD

10

Part 10: IDOR Security Hardening

This article is available in Indonesian

🇮🇩 Baca dalam Bahasa Indonesia
🇬🇧 English📚 Laravel E-Learning Rebuild

Laravel E-Learning Part 10: Eliminating IDOR Vulnerabilities & Security Hardening (PR #18)

Part 10 of the Laravel E-Learning series: deep-diving into Insecure Direct Object Reference (IDOR) vulnerabilities, implementing granular Laravel Policies, securing Livewire components with #[Locked], hiding exam answers, and hardening admin impersonation in PR #18.

Muhammad Fari MadyanAuthor

15 min read

·

Oct 3, 2026


Moving Beyond Functionality: Why Security Hardening Matters

In Part 9, we triaged community bug reports, resolved CBT exam scoring logic, and established a GitHub Actions CI pipeline. With automated testing running reliably on every push, we turned our focus to a deeper architectural challenge: authorization and access control.

In a school learning management system (LMS) and computer-based testing (CBT) platform, multiple user personas share the exact same database:

  • Administrators managing master academic data.
  • Teachers authoring exams, grading submissions, and uploading materials.
  • Students taking timed tests, downloading coursework, and participating in forum discussions.

When multiple users share models like Examination, Question, Assignment, and LearningMaterial, a critical question arises:

Can Teacher A tamper with or delete Teacher B's exam questions? Can a student in Class 10-A view an unreleased exam or access learning materials assigned exclusively to Class 10-B by changing an ID in the URL?

Without rigorous authorization guards, the answer is often an uncomfortable yes. This vulnerability class is known as IDOR (Insecure Direct Object Reference), classified under A01:2021 – Broken Access Control in the OWASP Top 10.

In Pull Request #18: "Resolve IDOR vulnerabilities and harden authorization across student, teacher, and admin modules", we conducted a comprehensive security audit of our Laravel 12 and Livewire 3 application, implementing a defense-in-depth model across 30 files, adding 6 granular Laravel Policies, and crafting a 411-line automated security test suite.

Here is the full breakdown of what was vulnerable, how it was exploited, and how we eliminated every single attack vector.


Threat Modeling: The 5 Vulnerability Vectors in Detail

Before diving into code, let's map out the comprehensive attack surface that existed before PR #18:

1. Teacher Cross-Exam & Question Tampering (IDOR)

In our Livewire QuestionIndex component, questions were edited and deleted via their primary key:

PHP

// BEFORE: Dangerous direct lookups without teacher scope
public function edit(int $id): void
{
    $q = Question::findOrFail($id); // Any teacher can load any question!
    $this->editingId = $id;
    // ...
}

public function delete(): void
{
    Question::findOrFail($this->deletingId)->delete(); // Any teacher can delete any question!
}

Because Question::findOrFail($id) performed a global database query without scoping to auth()->user()->teacher->id, Teacher B could send a Livewire action payload targeting a question owned by Teacher A and overwrite or delete it.

Similarly, routes like /teacher/exams/{id}/monitor and /teacher/exams/{id}/print only checked if the user had the guru role, but failed to verify whether the logged-in teacher was actually the author of that specific examination.

2. Student Unauthorized Resource Access & Premature Viewing

Students belong to specific classrooms (e.g., Classroom X-A). Teachers frequently draft materials and exams days ahead of time, leaving them in is_published = false or status = 'draft'.

Two severe issues existed:

  1. Cross-Classroom Material Leakage: If a student changed the URL parameter from /student/materials/12 to /student/materials/13, they could read materials assigned to another class.
  2. Draft Preview Bypass: Even worse, they could view unpublished materials before the teacher officially released them.
  3. Expired & Cross-Class Assignment Submissions: A student could invoke Assignments::openSubmit($assignmentId) for an assignment belonging to another classroom or submit work after the due date even when allow_late_submission = false.

3. CBT Cross-Exam Question Poisoning

During an online exam, Livewire's ExamStart::saveAnswer(int $questionId, string $answer) allowed saving student responses:

PHP

// BEFORE: Missing cross-exam ownership check
public function saveAnswer(int $questionId, string $answer): void
{
    if (!$this->attempt) return;

    $this->answers[$questionId] = $answer;
    ExamAnswer::updateOrCreate(
        ['exam_attempt_id' => $this->attempt->id, 'question_id' => $questionId],
        ['answer' => $answer]
    );
}

A tech-savvy student sitting Exam 1 could craft a network request to saveAnswer with a $questionId belonging to Exam 2 (or an exam from next week). The database would happily associate an answer for a foreign question with their current attempt!

4. Client-Side Data Leakage: Question Answers in Payloads

In Laravel Eloquent, models serialized to JSON include all database columns unless explicitly hidden. In our Question model:

  • correct_answer (e.g. 'B')
  • explanation (e.g. 'According to Newton's Second Law...')

When Livewire hydrated or passed questions to the view, these fields were included in the component snapshot. Any student inspecting the Chrome DevTools Network tab or inspecting window.Livewire could extract the answer key directly from the page response.

5. Insecure Admin Impersonation (GET-based CSRF)

Our admin panel featured an impersonation tool allowing administrators to diagnose student or teacher issues by logging in as them.

However:

  • The routes were Route::get('/impersonate/{userId}') and Route::get('/stop-impersonate').
  • Because they used GET, they had zero CSRF protection. An external site could embed <img src="https://elearning.school.id/admin/impersonate/42"> and hijack the admin's session.
  • An admin could accidentally impersonate another admin (privilege escalation confusion) or even impersonate themselves.
  • The session was not regenerated upon switching users, making it susceptible to session fixation.

6. Real-Time Broadcast Channel Eavesdropping

In routes/channels.php:

PHP

// BEFORE: Loose channel authorization
Broadcast::channel('assignment.{assignmentId}', function ($user, $assignmentId) {
    if ($user->hasRole('guru')) {
        return true; // Any teacher could listen to any other teacher's chat!
    }
    // ...
});

Any authenticated teacher could connect to Laravel Echo presence channels for classes they did not teach and eavesdrop on private student-teacher assignment discussions.


The Solution: A 4-Layer Security Architecture

To completely resolve these vulnerabilities, we designed a defense-in-depth model with four distinct layers operating simultaneously to guarantee that every incoming user request is rigorously validated:

The interaction and sequence flow between these components during request lifecycle execution is mapped out below:


Layer 1: Dedicated Granular Laravel Policies

In Laravel, Policies are the canonical way to centralize authorization logic. In PR #18, we created 6 specialized policies and registered them explicitly in AppServiceProvider.php:

PHP

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Gate::policy(Examination::class, ExaminationPolicy::class);
    Gate::policy(Assignment::class, AssignmentPolicy::class);
    Gate::policy(LearningMaterial::class, LearningMaterialPolicy::class);
    Gate::policy(Question::class, QuestionPolicy::class);
    Gate::policy(ExamAttempt::class, ExamAttemptPolicy::class);
    Gate::policy(AssignmentSubmission::class, AssignmentSubmissionPolicy::class);
}

Let's examine the design of these policies.

ExaminationPolicy: Separation of Teacher & Student Rules

PHP

// app/Policies/ExaminationPolicy.php
namespace App\Policies;

use App\Models\ExamAttempt;
use App\Models\Examination;
use App\Models\User;

class ExaminationPolicy
{
    public function view(User $user, Examination $examination): bool
    {
        if ($user->hasRole('admin')) return true;

        if ($user->hasRole('guru')) {
            return (int) $examination->teacher_id === (int) $user->teacher?->id;
        }

        if ($user->hasRole('siswa')) {
            return $examination->status === 'published'
                && (int) $examination->classroom_id === (int) $user->student?->classroom_id;
        }

        return false;
    }

    public function monitor(User $user, Examination $examination): bool
    {
        if ($user->hasRole('admin')) return true;
        if ($user->hasRole('guru')) {
            return (int) $examination->teacher_id === (int) $user->teacher?->id;
        }
        return false;
    }

    public function grade(User $user, Examination $examination, ?ExamAttempt $attempt = null): bool
    {
        if ($user->hasRole('admin')) return true;

        if ($user->hasRole('guru')) {
            $isOwner = (int) $examination->teacher_id === (int) $user->teacher?->id;
            if (!$isOwner) return false;

            // Prevent grading an attempt that doesn't belong to this exam!
            if ($attempt !== null && (int) $attempt->examination_id !== (int) $examination->id) {
                return false;
            }

            return true;
        }

        return false;
    }

    public function update(User $user, Examination $examination): bool
    {
        if ($user->hasRole('admin')) return true;
        if ($user->hasRole('guru')) {
            return (int) $examination->teacher_id === (int) $user->teacher?->id;
        }
        return false;
    }
}

Notice the crucial check in grade(): not only must the teacher own the examination, but if an $attempt is passed, the attempt must belong to that specific examination. This stops cross-exam grading attacks dead in their tracks.

AssignmentPolicy: Controlling Submissions and Discussions

PHP

// app/Policies/AssignmentPolicy.php
public function submit(User $user, Assignment $assignment): bool
{
    if (!$user->hasRole('siswa')) return false;

    $student = $user->student;
    if (!$student || (int) $assignment->classroom_id !== (int) $student->classroom_id) {
        return false;
    }

    if ($assignment->status !== 'published') {
        return false;
    }

    if (!$assignment->allow_late_submission && now()->gt($assignment->due_date)) {
        return false;
    }

    return true;
}

public function discuss(User $user, Assignment $assignment): bool
{
    if ($user->hasRole('admin')) return true;

    if ($user->hasRole('guru')) {
        return (int) $assignment->teacher_id === (int) $user->teacher?->id;
    }

    if ($user->hasRole('siswa')) {
        return (int) $assignment->classroom_id === (int) $user->student?->classroom_id;
    }

    return false;
}

Layer 2: Scoped Database Queries & Defense in Depth

While Policies guard controller routes and Livewire method entry points, defense-in-depth dictates that database queries must also be scoped to the authenticated tenant.

In app/Livewire/Teacher/QuestionIndex.php, we replaced all global Question::findOrFail() calls with scoped sub-queries:

PHP

// AFTER: Safe, scoped query that guarantees tenant isolation
public function edit(int $id): void
{
    $teacherId = auth()->user()->teacher?->id;
    $q = Question::whereHas('examination', fn ($query) => 
        $query->where('teacher_id', $teacherId)
    )->findOrFail($id);

    $this->editingId = $id;
    $this->examination_id = (string) $q->examination_id;
    $this->question_text = $q->question_text;
    // ...
}

public function delete(): void
{
    $teacherId = auth()->user()->teacher?->id;
    Question::whereHas('examination', fn ($query) => 
        $query->where('teacher_id', $teacherId)
    )->findOrFail($this->deletingId)->delete();

    $this->showDeleteModal = false;
    session()->flash('success', 'Soal berhasil dihapus.');
}

Even if an attacker bypasses the UI or modifies frontend state, findOrFail() immediately throws a ModelNotFoundException (resulting in a clean 404), ensuring zero data leakage.


Layer 3: Hardening Livewire 3 Components with #[Locked]

Livewire components store state across HTTP requests by passing an encrypted checksummed snapshot to the client. However, developers often forget that public component properties can be manipulated in network requests unless locked.

In PR #18, we added Livewire's #[Locked] attribute to every identifier and model instance:

PHP

// app/Livewire/Student/Assignments.php
use Livewire\Attributes\Locked;

class Assignments extends Component
{
    public bool $showSubmit = false;

    #[Locked]
    public ?int $submittingId = null; // Cannot be altered by client!
    
    // ...
    public function openSubmit(int $assignmentId): void
    {
        $assignment = Assignment::findOrFail($assignmentId);
        $this->authorize('submit', $assignment); // Policy check!

        $this->submittingId = $assignment->id;
        $this->showSubmit = true;
    }

    public function submit(): void
    {
        $this->validate();
        if (!$this->submittingId) return;

        $assignment = Assignment::findOrFail($this->submittingId);
        $this->authorize('submit', $assignment); // Re-authorize before write!

        // Perform safe updateOrCreate
    }
}

Hardening ExamStart.php Against Cross-Exam Question Poisoning

In app/Livewire/Student/ExamStart.php, we implemented a two-fold check when saving answers:

PHP

public function saveAnswer(int $questionId, string $answer): void
{
    if (!$this->attempt || $this->attempt->status !== 'in_progress') {
        return;
    }

    // 1. Verify exam duration hasn't expired
    if ($this->attempt->started_at) {
        $durationDeadline = $this->attempt->started_at->copy()->addMinutes($this->examination->duration_minutes);
        $deadline = $durationDeadline->lt($this->examination->end_at) ? $durationDeadline : $this->examination->end_at;
        if (now()->gt($deadline)) {
            $this->finishExam();
            return;
        }
    }

    // 2. CRITICAL: Verify question belongs to THIS specific examination
    $validQuestion = $this->examination->questions()->where('id', $questionId)->exists();
    if (!$validQuestion) {
        return; // Ignore foreign questions!
    }

    $this->answers[$questionId] = $answer;
    ExamAnswer::updateOrCreate(
        ['exam_attempt_id' => $this->attempt->id, 'question_id' => $questionId],
        ['answer' => $answer]
    );
}

Layer 4: Hiding Model Fields & Securing Admin Impersonation

1. Hiding Exam Answers in Question.php

To prevent the client snapshot from leaking answers during active tests:

PHP

// app/Models/Question.php
class Question extends Model
{
    protected $hidden = [
        'correct_answer',
        'explanation',
    ];
}

Now, whenever questions are serialized to JSON for student views, correct_answer and explanation are completely omitted. Teachers and grading services access the raw attributes via backend PHP without exposing them to browser serialization.

2. Securing Admin Impersonation

We completely eliminated the insecure GET impersonation pattern:

PHP

// routes/admin.php
Route::middleware(['auth', 'role:admin'])->prefix('admin')->group(function () {
    // Replaced GET with POST
    Route::post('/impersonate/{userId}', [ImpersonateController::class, 'start'])
        ->name('admin.impersonate.start');
});

Route::post('/admin/stop-impersonate', [ImpersonateController::class, 'stop'])
    ->middleware(['auth'])
    ->name('impersonate.stop');

In ImpersonateController.php:

PHP

public function start(Request $request, int $userId)
{
    $admin = Auth::user();
    if (!$admin || !$admin->hasRole('admin')) {
        abort(403, 'Akses ditolak.');
    }

    $target = User::findOrFail($userId);

    // Guard: Prevent impersonating another admin or oneself
    if ($target->hasRole('admin') || $target->id === $admin->id) {
        abort(403, 'Tidak dapat melakukan impersonasi sesama admin atau akun sendiri.');
    }

    session()->put('impersonating_admin_id', $admin->id);
    session()->regenerate(); // Prevent session fixation
    Auth::login($target);

    // Redirect to respective portal
    if ($target->hasRole('guru')) return redirect('/teacher/dashboard');
    if ($target->hasRole('siswa')) return redirect('/student/dashboard');
    return redirect('/');
}

In the Blade views, links were converted to POST forms with @csrf and styled amber warning banners:

BLADE

<form method="POST" action="{{ route('impersonate.stop') }}" class="inline">
    @csrf
    <button type="submit" class="inline-flex items-center gap-1 bg-white text-amber-700 px-3 py-1 rounded-md text-xs font-bold hover:bg-amber-50 transition cursor-pointer">
        ← Kembali ke Admin
    </button>
</form>

Layer 5: Hardening WebSocket Presence Channels

In routes/channels.php, we replaced the broad hasRole('guru') check with strict ownership validation:

PHP

Broadcast::channel('assignment.{assignmentId}', function ($user, $assignmentId) {
    $assignment = Assignment::find($assignmentId);
    if (!$assignment) return false;

    if ($user->hasRole('admin')) return true;

    // Only the teacher who authored the assignment can listen
    if ($user->hasRole('guru')) {
        return (int) $user->teacher?->id === (int) $assignment->teacher_id;
    }

    // Only students enrolled in that assignment's classroom can listen
    if ($user->hasRole('siswa') && $assignment->classroom) {
        return $assignment->classroom->students()->where('user_id', $user->id)->exists();
    }

    return false;
});

Automated Security Regression Suite: IdorTest.php

Security fixes without automated tests tend to erode over time. To ensure that future contributors never inadvertently undo these authorization boundaries, we wrote a comprehensive test suite in tests/Feature/Security/IdorTest.php:

tests/Feature/Security/IdorTest.php (411 lines)
✓ test_teacher_cannot_monitor_another_teachers_exam
✓ test_teacher_cannot_grade_another_teachers_exam
✓ test_teacher_cannot_print_another_teachers_exam
✓ test_teacher_cannot_edit_or_delete_another_teachers_question
✓ test_teacher_cannot_view_or_grade_another_teachers_assignment_submissions
✓ test_student_cannot_view_unpublished_material_or_material_from_another_classroom
✓ test_student_cannot_submit_assignment_of_another_classroom_or_after_due_date
✓ test_student_cannot_save_answers_to_questions_from_different_exam
✓ test_admin_cannot_impersonate_another_admin

Here is a snippet showing how we test cross-exam answer injection prevention:

PHP

public function test_student_cannot_save_answers_to_questions_from_different_exam(): void
{
    [$t1User, $t1] = $this->createTeacher('[email protected]');
    $classA = $this->createClassroom('X-A');
    $subject = Subject::create(['name' => 'Kimia', 'code' => 'KIM']);
    [$sUser, $student] = $this->createStudent($classA->id, '[email protected]');

    $exam1 = Examination::create([ /* ... Exam 1 in progress ... */ ]);
    $exam2 = Examination::create([ /* ... Exam 2 ... */ ]);

    $qExam2 = Question::create([
        'examination_id' => $exam2->id,
        'question_text' => 'Question belonging to Exam 2',
        'options' => ['A', 'B'],
        'correct_answer' => 'A',
    ]);

    $attempt = ExamAttempt::create([
        'examination_id' => $exam1->id,
        'student_id' => $student->id,
        'started_at' => now(),
        'status' => 'in_progress',
    ]);

    // Attempting to submit answer to question from exam2 while taking exam1
    Livewire::actingAs($sUser, 'web')
        ->test(ExamStart::class, ['examination' => $exam1])
        ->call('saveAnswer', $qExam2->id, 'A');

    // Assert database was NOT contaminated
    $this->assertDatabaseMissing('exam_answers', [
        'exam_attempt_id' => $attempt->id,
        'question_id' => $qExam2->id,
    ]);
}

Running php artisan test --filter=IdorTest produces 100% green assertions across all personas.


Architectural Comparison: Before vs. After PR #18

Module / VectorVulnerability Before PR #18Hardened Implementation After PR #18
Question ManagementGlobal Question::findOrFail($id) allowed cross-teacher edits & deletionScoped query to teacher_id + QuestionPolicy check
Exam Monitoring & PrintingAny user with guru role could view any examExaminationPolicy@monitor strictly checks author ownership
Exam Attempt GradingTeacher could grade attempts of other examsExaminationPolicy@grade verifies both exam author and attempt origin
Learning MaterialsStudents could access other classes' materials & draftsLearningMaterialPolicy enforces is_published and classroom_id match
Assignment SubmissionsStudents could submit to other classrooms or after deadlineAssignmentPolicy@submit validates enrollment, status, and deadline
CBT Answer SubmissionStudents could save answers to questions from unrelated examsValidates $examination->questions()->where('id', $questionId)->exists()
Question Model SerializationAnswers leaked in JSON snapshotsQuestion::$hidden = ['correct_answer', 'explanation']
Admin ImpersonationGET request, no CSRF, no session regenerationPOST request, @csrf, prevents admin-to-admin, session()->regenerate()
WebSocket Presence ChannelsAny teacher could listen to any assignment chatStrictly authorized to assignment author and enrolled students
Regression TestingNo dedicated security test suite411-line IdorTest.php suite running on GitHub Actions CI

Key Takeaways for Building Secure Laravel Applications

  1. Never Rely on UI-Level Hiding: Hiding an "Edit" or "Delete" button in Blade does not secure an endpoint. If a user can craft a POST payload or invoke a Livewire action, the backend must independently reject the operation.
  2. Lock Your Livewire State: Always annotate internal IDs with #[Locked]. Without #[Locked], an attacker can modify component state before calling actions.
  3. Combine Policies with Scoped Queries: Use Policies for high-level authorization ($this->authorize(...)) and Eloquent scopes (whereHas('examination', ...)) for database-level tenant isolation.
  4. Treat Student Test Environments as Untrusted: Never send answer keys or explanations to the frontend during active exams. Use Eloquent $hidden attributes to ensure zero data leakage.
  5. Turn Security Fixes into Automated Tests: Every security patch should be accompanied by a failing test that passes only when the fix is applied. This prevents regressions in future sprints.

🔗 Resources & Further Reading:

Continue Reading

Previous article

← Previous Article

Laravel E-Learning Part 9: Resolving Community Issues, Exam Flow Hardening & CI/CD Pipeline

Next Article →

React Native Expo Event Management Part 1: Pengenalan & Arsitektur

Next article