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:
- Cross-Classroom Material Leakage: If a student changed the URL parameter from
/student/materials/12to/student/materials/13, they could read materials assigned to another class. - Draft Preview Bypass: Even worse, they could view unpublished materials before the teacher officially released them.
- 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 whenallow_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}')andRoute::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 / Vector | Vulnerability Before PR #18 | Hardened Implementation After PR #18 |
|---|---|---|
| Question Management | Global Question::findOrFail($id) allowed cross-teacher edits & deletion | Scoped query to teacher_id + QuestionPolicy check |
| Exam Monitoring & Printing | Any user with guru role could view any exam | ExaminationPolicy@monitor strictly checks author ownership |
| Exam Attempt Grading | Teacher could grade attempts of other exams | ExaminationPolicy@grade verifies both exam author and attempt origin |
| Learning Materials | Students could access other classes' materials & drafts | LearningMaterialPolicy enforces is_published and classroom_id match |
| Assignment Submissions | Students could submit to other classrooms or after deadline | AssignmentPolicy@submit validates enrollment, status, and deadline |
| CBT Answer Submission | Students could save answers to questions from unrelated exams | Validates $examination->questions()->where('id', $questionId)->exists() |
| Question Model Serialization | Answers leaked in JSON snapshots | Question::$hidden = ['correct_answer', 'explanation'] |
| Admin Impersonation | GET request, no CSRF, no session regeneration | POST request, @csrf, prevents admin-to-admin, session()->regenerate() |
| WebSocket Presence Channels | Any teacher could listen to any assignment chat | Strictly authorized to assignment author and enrolled students |
| Regression Testing | No dedicated security test suite | 411-line IdorTest.php suite running on GitHub Actions CI |
Key Takeaways for Building Secure Laravel Applications
- 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.
- Lock Your Livewire State:
Always annotate internal IDs with
#[Locked]. Without#[Locked], an attacker can modify component state before calling actions. - Combine Policies with Scoped Queries:
Use Policies for high-level authorization (
$this->authorize(...)) and Eloquent scopes (whereHas('examination', ...)) for database-level tenant isolation. - Treat Student Test Environments as Untrusted:
Never send answer keys or explanations to the frontend during active exams. Use Eloquent
$hiddenattributes to ensure zero data leakage. - 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:

