Broken Function Level Authorization (BFLA) is OWASP API Security Top 10 item API5:2023, and it’s one of the most reliably present vulnerabilities in enterprise APIs. Unlike IDOR — which is about accessing the wrong data object — BFLA is about accessing the wrong function. Specifically, administrative or privileged API endpoints that should only be callable by certain roles, but aren’t properly protected.
The pattern is predictable: a developer builds an admin endpoint, restricts access at the UI layer, and either forgets to add server-side role checks or trusts that the frontend will prevent regular users from reaching it. It doesn’t.
What BFLA Looks Like
Consider a typical REST API for a SaaS platform:
GET /api/v1/users # list all users (admin only)
POST /api/v1/users # create user (admin only)
DELETE /api/v1/users/{id} # delete user (admin only)
GET /api/v1/users/me # get own profile (any authenticated user)
The frontend shows regular users only their profile page. The admin endpoints exist, are routed, and are callable by anyone with a valid session token — the server just never checks whether the caller is an admin.
The Vulnerable Pattern: Missing Middleware
Node.js / Express — Vulnerable:
// Missing role check on admin endpoint
app.delete('/api/v1/users/:id', authenticate, async (req, res) => {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json({ message: 'User deleted' });
});
app.get('/api/v1/users', authenticate, async (req, res) => {
const users = await User.find({});
res.json(users);
});
The authenticate middleware verifies the session token is valid — but it doesn’t check the caller’s role. Any authenticated user can delete any other user, or dump the full user list.
Node.js / Express — Fixed:
const requireRole = (role) => (req, res, next) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
app.delete('/api/v1/users/:id', authenticate, requireRole('admin'), async (req, res) => {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json({ message: 'User deleted' });
});
app.get('/api/v1/users', authenticate, requireRole('admin'), async (req, res) => {
const users = await User.find({});
res.json(users);
});
The fix is one middleware call per route. The vulnerability is its absence.
Where BFLA Hides in Real Codebases
HTTP method confusion is one of the most common patterns. A route handler serves both GET (public or low-privilege) and POST (admin action), and the authorization check is only on one:
# FastAPI — Vulnerable: POST requires admin but check is missing
@router.get("/api/v1/settings")
async def get_settings():
return await Settings.get_all()
@router.post("/api/v1/settings")
async def update_settings(data: SettingsUpdate): # No Depends(require_admin)
await Settings.update(data)
return {"status": "updated"}
# Fixed
from app.dependencies import require_admin
@router.post("/api/v1/settings")
async def update_settings(
data: SettingsUpdate,
current_user: User = Depends(require_admin)
):
await Settings.update(data)
return {"status": "updated"}
API version mismatches are another frequent source. /api/v1/ was built carefully with authorization middleware; /api/v2/ was added quickly and the middleware wasn’t ported:
// v1 — properly protected
app.use('/api/v1/admin', authenticate, requireRole('admin'), adminV1Router);
// v2 — authorization middleware dropped during refactor
app.use('/api/v2/admin', authenticate, adminV2Router);
Internal-to-external exposure happens when endpoints originally designed for service-to-service calls get accidentally exposed via API gateway configuration changes or firewall rule updates. These endpoints often have no user-facing authorization because they were designed for trusted internal callers.
Why Automated Scanners Miss It
Most DAST tools are good at finding technical vulnerabilities — injection, authentication bypasses, information disclosure via error messages. BFLA is a semantic problem: the scanner needs to understand that a regular user shouldn’t be able to call /api/v1/admin/export-users, even though the endpoint returns 200 OK with valid data.
Without role-aware scanning configured with multiple test personas (a standard user credential and an admin credential), a scanner will call the endpoint, get a response, and move on. The vulnerability requires business logic context to identify.
Role-aware DAST tools (StackHawk, Escape, 42Crunch) can test the same endpoint with different credential tiers and flag cases where standard-user requests return admin-level responses. Manual testing is more reliable for novel codebases.
Testing Manually
The basic test requires two browser sessions or two sets of credentials:
- Authenticate as an admin and browse the application, capturing all API calls in a proxy (Burp Suite or OWASP ZAP)
- Swap the session token in captured admin requests for a standard user’s session token
- Replay the requests — any
200 OKwhere you’d expect403 Forbiddenis BFLA
Pay particular attention to:
- Endpoints under
/admin/,/api/admin/,/internal/ - DELETE, PUT, and PATCH methods on resource collections
- Export, bulk-action, and report endpoints
- User management, role assignment, and permission modification endpoints
Structural Fix: Centralise Authorization
Rather than adding authorization checks to individual routes — where they get forgotten in refactors — enforce them in a policy map or middleware layer:
// Central policy map — auditable in one place
const ENDPOINT_POLICIES = {
'DELETE /api/v1/users/:id': { requiredRole: 'admin' },
'POST /api/v1/users': { requiredRole: 'admin' },
'GET /api/v1/users': { requiredRole: 'admin' },
'GET /api/v1/reports/export': { requiredRole: 'manager' },
};
// Authorization middleware that consults the policy map
app.use((req, res, next) => {
const key = `${req.method} ${matchRoute(req.path)}`;
const policy = ENDPOINT_POLICIES[key];
if (policy && req.user?.role !== policy.requiredRole) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
});
The policy map makes the access control matrix visible and auditable. A security review of the entire API’s authorization can happen in one file, rather than requiring a grep through hundreds of route definitions.
The underlying principle is deny-by-default: admin functions require positive authorization, not the absence of restriction. Any endpoint that isn’t explicitly listed as public or low-privilege should require an explicit role check — and that check should live on the server, not in the client.