I've lost count of how many times I've sat in a code review and watched a teammate proudly point to a disabled button on the screen and say, "Don't worry, the user can't click that — it's greyed out."
That's not security. That's theatre.
Let me tell you what actually happens in the real world.
The Illusion of Frontend Permissions
When we build role-based access control (RBAC) into an application, the natural instinct is to reflect permissions in the UI. An operator sees fewer menu items than an admin. A read-only user sees a disabled "Delete" button. It looks secure.
The problem? Anyone with a browser's Dev tools, Postman, or even a curl command can bypass every single pixel of that UI.
# What I can do anyway:
curl -X DELETE https://api.yourapp.com/modules/sensitive-data/42 \
-H "Authorization: Bearer eyJ..."If your API doesn't independently verify permissions on every request, that DELETE goes through. The module gets wiped. The data is gone.
The Root Cause: Trusting the Client
The fundamental mistake is trusting the client to enforce business rules. The client — browser, mobile app, third-party consumer — is an untrusted environment. Always. No exceptions.
Here's what this looks like in a real multi-module application:
The scenario: You have a SaaS platform with modules — Billing, Reports, User Management. Permissions are granted at the module level (view, edit, delete) and sometimes at the field level (e.g., can see invoice amounts but not export them).
The bad pattern:
// Frontend
if (user.permissions.includes('reports:export')) {
showExportButton(); // visible
} else {
hideExportButton(); // hidden — "secure", right?
}
// Backend API (Express) — the dangerous part
app.post('/api/reports/export', async (req, res) => {
const { reportId } = req.body;
// ⚠️ No permission check. Just... process it.
const data = await generateExport(reportId);
res.json(data);
});The frontend hides the button. The backend trusts that only people who see the button will call the endpoint. An attacker — or even just a curious soul (might be a developer)— calls the endpoint directly and gets a full data export they were never supposed to see.
Parameter Tampering: The Sneaky Cousin
Beyond missing auth checks, there's a subtler vulnerability that burns teams: parameter injection and tampering.
Even when an API does check if a user is authenticated, it often fails to verify that the parameters they're passing make sense for their permission level.
Example — IDOR (Insecure Direct Object Reference):
# User A is logged in with userId = 101
GET /api/users/101/invoices ✅ # Their own invoices — fine
# But what if they change the parameter?
GET /api/users/205/invoices ❌ # Someone else's invoicesIf the backend only checks "is the user logged in?" without checking "does this user have permission to access resource 205?", you have a data breach waiting to happen.
Example — Privilege Escalation via Body Injection:
POST /api/users/update-profile
{
"name": "John Doe",
"email": "john@example.com",
"role": "admin" ← This should NEVER be accepted from the client
}If the backend blindly accepts all fields from the request body and passes them to an ORM update, a regular user just gave themselves admin rights.
What Backend Permission Checks Actually Look Like
Here's the pattern every API endpoint should follow. I call it ABAC at the gate — Attribute-Based Access Control, enforced before business logic runs.
// Express + middleware pattern
app.delete(
'/api/modules/:moduleId/records/:recordId',
requireAuth, // Step 1: Is the token valid?
async (req, res) => {
const { moduleId, recordId } = req.params;
const user = req.user;
// Step 2: Does this user have permission on THIS module?
if (!user.hasPermission(moduleId, 'delete')) {
return res.status(403).json({ error: 'Forbidden' });
}
// Step 3: Does the resource belong to the user's scope?
const record = await Record.findById(recordId);
if (!record || record.moduleId !== moduleId) {
return res.status(403).json({ error: 'Resource mismatch' });
}
if (record.organizationId !== user.organizationId) {
return res.status(403).json({ error: 'Forbidden' });
}
// Step 4: Only NOW do we execute business logic
await record.delete();
res.json({ success: true });
}
);Notice what's happening in steps 2 and 3: we're not just checking if the user exists or if the token is valid — we're checking if the combination of user + action + resource is allowed.
The Checklist Your Team Should Use
Before any API endpoint ships, run through this:
Authentication
- Is the request token validated (not just present)?
- Is the token expired or revoked?
Authorization
- Does this user have the required permission for this module/feature?
- Is the action (read/write/delete/export) explicitly allowed, not just assumed?
Parameter Validation
- Are all resource IDs (path params, query params, body params) verified to belong to the user's scope?
- Is there any field in the request body that could modify privilege levels (role, permissions, isAdmin)?
- Are you whitelisting accepted fields rather than accepting everything?
Scope Boundaries
- Multi-tenant app? Is tenant isolation enforced in the query, not just the permission check?
- If a user has "view" access, can they accidentally trigger a "write" operation through a side effect?
A Note on Module-Level Permissions
When building granular module permissions (like "User X can view Billing but not edit it, and can't see Reports at all"), the pattern I've found most reliable is a permission matrix checked middleware-side, not scattered through endpoint logic.
// permissions.js — one place, all rules
const PERMISSION_MATRIX = {
billing: {
viewer: ['read'],
editor: ['read', 'write'],
admin: ['read', 'write', 'delete', 'export'],
},
reports: {
viewer: ['read'],
editor: ['read', 'write', 'export'],
},
};
function checkPermission(user, module, action) {
const role = user.getRoleForModule(module);
const allowedActions = PERMISSION_MATRIX[module]?.[role] ?? [];
return allowedActions.includes(action);
}
// Reusable middleware factory
const requirePermission = (module, action) => (req, res, next) => {
if (!checkPermission(req.user, module, action)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
// Usage on any route — clean and consistent
app.delete(
'/api/billing/invoices/:id',
requireAuth,
requirePermission('billing', 'delete'),
deleteInvoiceHandler
);This way, the permission logic lives in one place. When requirements change, you update the matrix — not 40 individual endpoints.
The Hard Conversation
The frustrating reality is that these vulnerabilities don't come from malicious developers. They come from teams that move fast, skip security reviews, and genuinely believe "the UI handles that."
If you're a tech lead or senior engineer, here's the culture shift that matters:
Make backend auth a code review requirement, not a nice-to-have. Every new endpoint gets reviewed for: authentication, authorization, and parameter scope validation. No exceptions, even for "internal" APIs.
Build it into your PR template:
Security Checklist:
- [ ] Auth middleware applied
- [ ] Permission check for this resource/action
- [ ] Path/body params validated against user scope
- [ ] No privilege-escalation fields accepted from clientClosing Thought
The grey-out tells the user what they should do. The backend check enforces what they can do.
In security, "should" is not good enough.
Your permissions system is only as strong as its weakest enforcement point — and that point is almost always the API.
Build the backend like the frontend doesn't exist. Because for an attacker, it doesn't.
Enjoyed this? I write about backend architecture, API security, and the things nobody teaches you in tutorials. Follow for more.
#BackendDevelopment #APISecurity #WebSecurity #SoftwareEngineering #RBAC #DevSecOps