BFLA: Function Level Authorization

Calling the admin endpoint as a normal user, because the only thing stopping you was a hidden button.

Broken Function Level Authorization (OWASP API5) is calling an operation you are not permitted to perform at all — regardless of which object it targets. BOLA is the wrong object; BFLA is the wrong function.

How it happens

  • The UI hides the button, so nobody added a server check. The endpoint is protected by CSS.
  • Routes are grouped by convention, and a new one lands outside the admin group.
  • The check is on some methods. GET is restricted, DELETE was added later.
  • Predictable naming. /api/admin/… is the first thing anyone tries.
  • Old versions. /api/v1/users/:id/promote still exists and never got the check v2 has.

Finding it

Take every request your admin account makes, replay it with a normal user's token, and assert 403. That is the entire test, it takes an afternoon to automate, and it finds real bugs in most codebases.

Deny by default

The structural fix is the same as everywhere else: routes are restricted until someone marks them public. Apply the role requirement to the router, not to individual handlers, so a new admin route inherits protection rather than needing to remember it.

Role and scope both apply

In an OAuth world there are two ceilings. The scope is what the client application may request; the role is what the user may do. An admin using a third-party app that only holds orders:read still cannot delete. A normal user whose app holds admin:all still is not an admin. Check both.

The dangerous middle ground

Support tooling — impersonation, refunds, exports, feature toggles. These often sit outside both the admin group and the normal group, in an /internal namespace protected by nothing but an assumption about network reachability.

Example

Example · bash
# The test, in one loop. Run it against staging.

# 1. Capture what an ADMIN does (browser devtools → copy as cURL, or the log)
ADMIN_ROUTES=$(cat admin-requests.txt)

# 2. Replay every one with a NORMAL user's token
while read -r method path; do
  code=$(curl -s -o /dev/null -w '%{http_code}' \
    -X "$method" "https://staging.dfg.com$path" \
    -H "Authorization: Bearer $NORMAL_USER_TOKEN")
  [ "$code" != "403" ] && [ "$code" != "404" ] && echo "LEAK: $method $path → $code"
done <<< "$ADMIN_ROUTES"

# LEAK: DELETE /api/admin/users/42 → 204
# LEAK: POST   /api/internal/impersonate → 200
# LEAK: GET    /api/v1/reports/all → 200      ← the old version

# Anything that is not 403 or 404 is a finding.

When to use it

  • A normal user promotes themselves to admin through an endpoint that was only ever called from a hidden admin screen.
  • A DELETE method added six months after the GET on the same route inherits no role check, because the check was written per-handler.
  • An old v1 route still permits a privileged action that v2 correctly restricts, found by replaying admin requests against the older prefix.

More examples

Deny by default at the router

Grouping by router rather than decorating handlers means the protection is inherited by code that has not been written yet, which is the property you actually want.

Example · javascript
// ❌ Per-handler checks: forgetting one means exposure
app.get('/api/admin/users', auth, requireRole('admin'), listUsers);
app.post('/api/admin/users', auth, requireRole('admin'), createUser);
app.delete('/api/admin/users/:id', auth, deleteUser);       // ← forgot. Open.

// ✅ The prefix carries the requirement; individual routes cannot opt out
//    by omission.
const adminRouter = express.Router();
adminRouter.use(auth, requireRole('admin'), auditAdminAction);

adminRouter.get('/users', listUsers);
adminRouter.post('/users', createUser);
adminRouter.delete('/users/:id', deleteUser);                // ← protected
adminRouter.post('/impersonate', requireRole('superadmin'), impersonate);

app.use('/api/admin', adminRouter);

// ✅ And the whole API denies by default, with an explicit public list
const PUBLIC_ROUTES = new Set([
  'GET /api/health',
  'GET /api/public/status',
  'POST /auth/login',
  'POST /auth/register',
]);

app.use('/api', (req, res, next) => {
  const key = `${req.method} ${req.path}`;
  if (PUBLIC_ROUTES.has(key)) return next();
  return auth(req, res, next);
});

// The failure mode of forgetting is now a 401 on something that should be
// public — reported by a user within minutes — rather than an open private
// endpoint that nobody notices for a year.

// ✅ Both ceilings, where both apply
const requireScope = (s) => (req, res, next) =>
  req.user.scopes.includes(s) ? next()
    : res.status(403).json({ error: 'insufficient_scope', required: s });

adminRouter.delete('/users/:id',
  requireScope('admin:users:delete'),    // may this CLIENT ask?
  requireRole('admin'),                  // may this USER do it?
  deleteUser);

Automating the replay test

The other-tenant admin caller is the case most suites miss: someone with a legitimate admin role, in the wrong organisation.

Example · javascript
// Drive it from the route inventory so new admin routes are covered on merge.
import routes from '../route-inventory.json' with { type: 'json' };

const privileged = routes.filter((r) =>
  r.adminOnly || /\/(admin|internal|ops|debug)\//.test(r.path));

describe('BFLA: privileged routes reject unprivileged callers', () => {
  const callers = [
    { name: 'anonymous',    token: null,          expect: 401 },
    { name: 'normal user',  token: userToken,     expect: 403 },
    { name: 'read-only',    token: readOnlyToken, expect: 403 },
    { name: 'other tenant', token: otherTenantAdminToken, expect: [403, 404] },
  ];

  for (const route of privileged) {
    for (const caller of callers) {
      it(`${route.method} ${route.path} rejects ${caller.name}`, async () => {
        const req = request(app)[route.method.toLowerCase()](
          route.path.replace(/:\w+/g, seededId(route.path)));

        if (caller.token) req.set('Authorization', `Bearer ${caller.token}`);

        const res = await req.send(route.method === 'GET' ? undefined : {});
        const allowed = [].concat(caller.expect);
        expect(allowed).toContain(res.status);
      });
    }
  }

  // The one people forget: the SAME route under an older version prefix.
  for (const route of privileged) {
    it(`${route.path} is not reachable under /api/v1`, async () => {
      const legacy = route.path.replace('/api/', '/api/v1/');
      const res = await request(app)[route.method.toLowerCase()](legacy)
        .set('Authorization', `Bearer ${userToken}`);
      expect([401, 403, 404]).toContain(res.status);
    });
  }
});

Impersonation, done without creating a backdoor

Read-only impersonation plus notifying the user are the two controls that turn a support tool from an unauditable backdoor into an accountable one.

Example · javascript
// Support impersonation is the highest-privilege function most products have,
// and it is routinely the least protected. Six controls, all necessary.
adminRouter.post('/impersonate/:userId',
  requireRole('support'),                     // 1. a distinct, narrow role
  requireSudo,                                // 2. re-authenticated in the last 15m
  rateLimit({ limit: 10, windowMs: 3600_000 }),
  async (req, res) => {
    const target = await db.users.findById(req.params.userId);
    if (!target) return res.status(404).json({ error: 'not_found' });

    // 3. Never impersonate upward.
    if (PRIVILEGE[target.role] >= PRIVILEGE[req.user.role]) {
      return res.status(403).json({ error: 'cannot_impersonate_equal_or_higher' });
    }

    // 4. The session records BOTH identities and is time-boxed.
    const sid = await createSession(target.id, req, {
      impersonatedBy: req.user.id,
      ttlSeconds: 900,
      readOnly: true,                          // 5. no writes while impersonating
      reason: req.body.reason,                 // required, free text, audited
    });

    // 6. Loud, immutable audit — and tell the user it happened.
    await audit.record({
      event: 'user.impersonated',
      actor: req.user.id, target: target.id,
      reason: req.body.reason, ticket: req.body.ticketId,
      ip: req.ip, at: new Date(),
    });
    await notifyUser(target, 'A support agent accessed your account.');
    await alertSecurityChannel(`${req.user.email} impersonated ${target.email}`);

    res.cookie('sid', sid, COOKIE_OPTIONS);
    res.json({ impersonating: publicProfile(target), expiresIn: 900 });
  });

// Every downstream check sees BOTH identities:
function requireNotImpersonating(req, res, next) {
  if (req.sessionData?.impersonatedBy) {
    return res.status(403).json({ error: 'not_available_during_impersonation' });
  }
  next();
}
// Applied to: password change, MFA settings, payout details, account deletion.

Discussion

  • Be the first to comment on this lesson.