Broken Function Level Authorization (BFLA): Exposing Hidden Admin Endpoints in Your APIs

BFLA lets regular users call admin-only API functions simply by knowing the right URL. It's OWASP API5:2023, reliably present in enterprise APIs, and almost invisible to automated scanners. Here's how it works, where it hides, and how to eliminate it.

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:

  1. Authenticate as an admin and browse the application, capturing all API calls in a proxy (Burp Suite or OWASP ZAP)
  2. Swap the session token in captured admin requests for a standard user’s session token
  3. Replay the requests — any 200 OK where you’d expect 403 Forbidden is 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.