← All posts

How I Test for Broken Function Level Authorization

How I Test for Broken Function Level Authorization

Most applications check whether a user is authenticated before allowing access to a function. Far fewer check whether that specific user should be allowed to perform that specific action. The result is a logged in user with basic privileges calling administrative functions directly, and the application happily executing them because the user passed the authentication gate.

I see this constantly in REST APIs where authentication middleware runs globally but authorization logic is left to individual route handlers. A developer implements POST /api/users for admins to create accounts, protects it with an auth token check, but never validates the token belongs to an admin. Any authenticated user can call it.

Why It Hides in Plain Sight

This flaw survives code review because the protection looks present. There's a decorator, a middleware function, a guard clause checking for a valid session. The code reads as protected. What's missing is invisible: the second check that compares the user's role or permission set against what the function requires. In normal application use, the UI never renders an admin button for a regular user, so the path never gets exercised. The vulnerability only surfaces when someone inspects network traffic or reads API documentation and tries calling a privileged endpoint directly.

How I Test for It

  1. Map all endpoints and identify which ones perform administrative, destructive, or sensitive operations. Look for user management, configuration changes, bulk data exports, approval workflows, or anything that modifies system state beyond the current user's own resources.
  2. Authenticate as a low privilege user and capture the session token or cookie. Keep this session active in a separate tool or browser profile.
  3. Using the low privilege session, attempt to call each administrative or sensitive function directly. Change the HTTP method if needed. Try GET, POST, PUT, PATCH, and DELETE on the same path.
  4. Pay close attention to endpoints that return 200 or 201 responses when they should return 403. Some applications fail open, executing the privileged action but returning a generic success message.
  5. Test endpoints that accept role or permission identifiers as parameters. Try changing role=user to role=admin in the request body or query string. Some systems use this for display logic but accidentally honor it for authorization decisions.
  6. Check for inconsistent authorization between related endpoints. If GET /api/users correctly restricts to admins but GET /api/users/export does not, that's the same data with a missing control.
  7. Test with no authentication at all. Some functions accidentally bypass authorization checks entirely when no session is present, assuming unauthenticated requests will be caught earlier in the stack.

The Permission vs. Role Trap

Many authorization systems check for a role like admin instead of a specific permission like can_delete_users. This creates two problems. First, it couples your security logic to your organizational hierarchy. When you add a moderator role, you have to find and update every admin check. Second, it makes testing brittle. A tester with an admin account will never notice that a support user can also call the function. I always test with the lowest conceivable privilege level, not just one step down from admin.

Defense: Centralized Authorization Checks

The most reliable defense is a policy layer that explicitly maps every function to the permissions required to call it. Decorate each route or controller method with the required permission, and enforce it in a single reusable authorization module. Never rely on authentication alone.

// Authorization middleware that checks specific permissions
function requirePermission(permission) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }
    if (!req.user.permissions.includes(permission)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// Apply to sensitive routes
app.post('/api/users', 
  requirePermission('users:create'),
  createUserHandler
);

app.delete('/api/users/:id', 
  requirePermission('users:delete'),
  deleteUserHandler
);

Why It Persists

This flaw survives because authorization is harder to centralize than authentication. Authentication happens once per request at the entry point. Authorization must happen at every decision point, and developers forget. Frameworks provide authentication middleware out of the box but leave authorization as an exercise for the implementer. Time pressure and feature velocity mean the happy path gets built and tested, but the hostile path where a user tries something they shouldn't never gets exercised until production.

Function level authorization breaks when we treat a valid session as sufficient proof of permission. Test every privileged function with the least privileged user you can create.