0x55aa
← Back to Blog

Mass Assignment: The Oops-I-Gave-Everyone-Admin Bug 🎭

7 min read

Mass Assignment: The Oops-I-Gave-Everyone-Admin Bug 🎭

Imagine you spend weeks building a slick user registration API. It's clean, it's fast, it validates email formats. You're proud of it.

Then some bored user types "role": "admin" into the JSON body.

And it works.

Congratulations — you've just made a stranger the admin of your app. This is Mass Assignment, and it's been silently haunting developers since ORMs became popular. GitHub, Twitter, and GitLab have all shipped it to production. You might be shipping it right now.

What Is Mass Assignment? 🤔

Mass assignment happens when your code blindly binds user-supplied input directly to a model object — all of it — without filtering which fields are actually allowed to be set.

Frameworks love convenience. "Hey, just pass request.body straight to your model!" they say cheerfully. What they don't mention is that your model also has a role, isAdmin, balance, and emailVerified field that users can now set too.

Think of it like a hotel front desk that lets you hand over a form saying:

  • Name: John
  • Check-in: March 1st
  • Room type: Standard
  • Daily rate: $0
  • Status: VIP Penthouse

And the clerk just... processes the whole form.

The Attack in Practice 🎯

Here's a typical Express.js registration endpoint that looks totally fine:

// Looks innocent, right? 💀
app.post('/register', async (req, res) => {
  const user = await User.create(req.body); // ← THE PROBLEM
  res.json({ message: 'Welcome!', user });
});

Your database schema has these columns:

id | email | password | name | role | isVerified | credits

An attacker sends this instead of a normal registration:

{
  "email": "[email protected]",
  "password": "password123",
  "name": "Totally Normal User",
  "role": "admin",
  "isVerified": true,
  "credits": 99999
}

Your ORM happily maps every key to a column. The attacker is now an admin with free credits and a verified email. Zero exploits. Zero malware. Just JSON. 🎉 (for them)

Real-World Hall of Shame 💀

  • GitHub (2012): A researcher exploited mass assignment to add his SSH key to the Ruby on Rails organization's repository. He pushed directly to Rails itself to prove it. Rails was the framework with the vulnerability. Beautiful irony.
  • GitLab: Multiple mass assignment bugs have let users modify fields they shouldn't own.
  • Countless startups: Every week, bug bounty hunters find isAdmin, role, and balance fields sitting wide open on registration and update endpoints.

The scarier truth? These endpoints don't log unusual fields. Nobody is alerting on "role": "admin" showing up in a registration request. It just... works silently.

The Fix: Allowlists, Not Blocklists 🛡️

The golden rule: explicitly define what can be set, not what can't.

Node.js / Express with manual allowlist:

const ALLOWED_REGISTRATION_FIELDS = ['email', 'password', 'name'];

app.post('/register', async (req, res) => {
  // Only pick fields we explicitly allow
  const safeData = pick(req.body, ALLOWED_REGISTRATION_FIELDS);

  // Now create the user with only safe fields
  const user = await User.create({
    ...safeData,
    role: 'user',        // Always set server-side
    isVerified: false,   // Never trust the client
    credits: 0,          // Defaults enforced here
  });

  res.json({ message: 'Welcome!', user: { id: user.id, email: user.email } });
});

// Simple pick utility (or use lodash)
function pick(obj, keys) {
  return keys.reduce((acc, key) => {
    if (key in obj) acc[key] = obj[key];
    return acc;
  }, {});
}

Laravel — use $fillable not $guarded:

// ❌ The lazy way (blocks specific fields, everything else is open)
class User extends Model {
    protected $guarded = ['role', 'isAdmin']; // Blocklist = dangerous
}

// ✅ The correct way (only allows specific fields, everything else is blocked)
class User extends Model {
    protected $fillable = ['name', 'email', 'password']; // Allowlist = safe
}

// In your controller:
public function register(Request $request) {
    $validated = $request->validate([
        'name'     => 'required|string|max:255',
        'email'    => 'required|email|unique:users',
        'password' => 'required|min:8|confirmed',
    ]);

    // validated() only returns what passed validation
    // fillable only allows what's in the allowlist
    $user = User::create($validated);
}

The key insight: $guarded says "block these specific fields." $fillable says "only allow these specific fields." One new column in your DB and $guarded is wide open. $fillable requires you to explicitly grant access. Always use $fillable.

The Update Endpoint Problem 📝

Registration gets attention, but profile update endpoints are often worse:

// User updates their own profile
app.put('/profile', authenticate, async (req, res) => {
  await req.user.update(req.body); // ← Same problem, different endpoint
  res.json({ message: 'Profile updated!' });
});

Now authenticated users can update their own role, balance, or planTier. They're already logged in, so it feels "safe." It's not.

// ✅ Explicit allowlist per operation
const PROFILE_UPDATE_FIELDS = ['name', 'bio', 'avatarUrl', 'timezone'];

app.put('/profile', authenticate, async (req, res) => {
  const safeUpdates = pick(req.body, PROFILE_UPDATE_FIELDS);

  if (Object.keys(safeUpdates).length === 0) {
    return res.status(400).json({ error: 'No valid fields to update' });
  }

  await req.user.update(safeUpdates);
  res.json({ message: 'Profile updated!' });
});

Different operations need different allowlists. Registration ≠ profile update ≠ admin update.

Bonus: Return Only What You Need 🎁

Mass assignment goes both ways. You can also accidentally expose sensitive fields in your API response:

// ❌ Exposes password hash, role, internal IDs, everything
res.json({ user });

// ✅ Return only what the client actually needs
res.json({
  user: {
    id: user.id,
    name: user.name,
    email: user.email,
  }
});

That role: "admin" field you didn't set? You're probably also broadcasting it to every user who views a profile. Check your serializers.

Your Mass Assignment Checklist ✅

Before you ship that endpoint:

  • Are you using $fillable (not $guarded) in every model?
  • Does every create() / update() call use an explicit allowlist?
  • Are server-controlled fields (role, isAdmin, isVerified, balance) set server-side only?
  • Does your update endpoint have a different allowlist from your create endpoint?
  • Are you stripping sensitive fields from API responses?
  • Do you have tests that send unexpected fields and assert they're ignored?

That last point is important — add a test that sends role: admin to your registration endpoint and asserts the user was created with role: user. If the test fails, you have a bug.

The Mindset Shift 🧠

Stop thinking "which fields should I block?" and start thinking "which fields am I explicitly allowing?"

Blocklists fail the moment you add a new column. Allowlists require deliberate opt-in for every field. The friction is the feature.

Your ORM's convenience features exist to make development fast. But fast development isn't the same as secure development. That extra five minutes to write an allowlist has saved companies from losing user data, account takeovers, and very awkward security disclosures.

Mass assignment is boring. It's not a zero-day or a fancy exploit. It's just "user input was trusted too much." And that's precisely why it keeps shipping to production — nobody thinks it's exciting enough to fix until it's too late.

Fix it before it's a story on a bug bounty platform. Or worse, a tweet.


Found a mass assignment bug in your own codebase? Share your war story on LinkedIn — you're definitely not alone.

More security content coming soon! Because your endpoints deserve better than blindly trusting req.body. 🔐

P.S. — Go check your profile update endpoint right now. I'll wait. 🛡️✨