CWE-285: Improper Authorization
Overview
Improper Authorization occurs when an application fails to enforce or incorrectly implements authorization checks, allowing users to access resources or perform actions beyond their intended permissions.
Relationship to Other CWEs
CWE-285 is a MITRE Class, one level below the CWE-284 (Improper Access Control) Pillar, and MITRE marks it Discouraged for direct mapping since a more specific descendant usually applies better.
- CWE-285 (this page) - an authorization decision that is made and is wrong, with no more specific descendant identified
- CWE-862 (Missing Authorization) - a formal child
- CWE-863 (Incorrect Authorization) - a formal child
- CWE-732 (Incorrect Permission Assignment for Critical Resource) - a formal child
- CWE-926 (Improper Export of Android Application Components) - a formal child
- CWE-639 (Authorization Bypass Through User-Controlled Key) and its child CWE-566 - the closely related pattern of trusting a user-controlled object key, better known as IDOR. MITRE files those under CWE-863 and CWE-284 rather than directly under this page, but the fix is the same family: verify that the authenticated user is permitted for the specific object, not just that some permission exists. If a finding names CWE-639 or CWE-566, prefer that page for the concrete IDOR remediation
OWASP Classification
A01:2025 - Broken Access Control
Risk
High: A caller reaches data and operations their permissions do not cover - another user's records, an admin function - and can read, modify or delete them. Where the exposed operation grants permissions or roles, the caller escalates their own access.
Remediation Steps
Core Principle: Never infer authorization from authentication alone or from a prior check; every security-sensitive action must be explicitly authorized against the specific resource and operation being performed.
Locate the missing or improper authorization
- Find the handler the finding points at and the protected resource or operation it exposes: an admin function, another user's data, a sensitive operation
- Determine whether there is an authorization check for both the operation and the specific resource
- Work out what an unauthorized user could do there: view data, modify resources, escalate privileges
Enforce authorization on every request (Primary Defense)
- Check permissions for every resource and action on every request; a check that passed earlier, or a URL nobody links to, is not authorization
- Centralize the logic in middleware, decorators, or an authorization service rather than scattering checks through handlers
- Enforce on the server; hidden buttons and disabled fields are not controls
- Check both the function and the data: the user may call this function, and may act on this specific resource
- Pattern:
if (!currentUser.hasPermission(REQUIRED_PERMISSION, resource)) return 403 Forbidden- whichever status this returns, give the "no such resource" path the same one, or the pair enumerates the table (see the pitfalls below)
Make ownership part of the query, not a check applied to the answer
The check above needs a resource to check against, so it can only run where the request names one. That leaves the endpoint that names none - the list, the search, the export, the bulk operation - with nothing for the check to attach to, and those are the endpoints that return the whole table in a single request rather than one row at a time. The single-resource route being correctly guarded is what lets this survive review, because the ownership logic is visibly present in the same file.
The fix that reaches both is to put the caller's identity into the query:
// Ownership is a term in what is asked, so it applies wherever the query does
function scopedTo(caller):
return caller.isAdmin ? allOrders : allOrders.where(ownerId = caller.id)
route GET /orders/{id}:
order = scopedTo(currentUser).findById(id)
if order is null: return 404
return order
route GET /orders:
return scopedTo(currentUser).all()
- Concretely this is a scoped
get_queryset(), afindByIdAndOwnerUsername(...)repository method, anownerIdclause on theSELECT, or aFindForCallerAsync(id, caller)wrapper - whatever the framework's equivalent is of narrowing the set before it is read - A route that forgets the scope is visibly different from one that has it; a route missing a post-load comparison looks like nothing at all
- Keep the resource-based check as well where the decision is more than "is this row mine" - a shared record, a delegated permission, a state that only allows edits before dispatch
- Enumerate every route and verb on the resource, including bulk, search and export variants, and confirm each is scoped; a fix applied to the flagged handler protects that handler only
Apply the principle of least privilege
- Deny by default: start each user with no permissions and grant only what their role needs
- Keep admin and user roles separate; do not give a regular user admin permissions "temporarily"
- Review access at a cadence that fits the risk and any compliance obligations, and revoke permissions when roles change
Implement RBAC or ABAC for authorization management
- Role-Based Access Control (RBAC): define roles such as USER, ADMIN and MODERATOR, attach permissions to roles, and assign roles to users
- Attribute-Based Access Control (ABAC) where roles are not enough: decide on attributes such as the user's department, the resource's classification, or the time of day
- Use the framework's own support rather than hand-written checks: Java
@PreAuthorize, .NET[Authorize], Django@permission_required, Express middleware
Monitor and audit access attempts
- Log authorization decisions and permission changes with safe metadata: successful and failed checks, role assignments, permission grants
- Alert on repeated 403 responses, privilege escalation attempts, and unusual access patterns
- Track authorization failures by user and resource to spot attacks and misconfigured permissions
- Review the audit logs regularly
Test the authorization fix
Re-running the scanner confirms a check is present. It cannot tell a check that permits the right callers from one that permits nobody, whether the check is on the path the request takes, or which of two denials a caller received - so state each of these as an assertion with an expected result and write it as a test:
- The owner still gets their own record. The legitimate caller receives
200and the resource body. Assert this first: a control that refuses everyone passes every rejection assertion below identically, and that is the most common way an authorization fix ships broken. - The two denials are indistinguishable. User B requesting user A's record and user B requesting an ID that exists in no row return the same status and the same body. A
404for one and a403for the other enumerates the table one request at a time, and it is the ownership check that opens that gap rather than closing it - which status you standardise on matters less than that they match. - The collection endpoint returns only the caller's rows. Assert the IDs and the count, not the status. This is the endpoint an object-level hook never fires on and the one that leaks the whole table.
- Anonymous is refused everywhere it should be. Call every route with no credentials and assert
401, not200and not a redirect. This is what catches a route that inherited an anonymous exception from its group or class. - A role the check does not recognise is denied. Submit a role value outside the allowlist - a typo, a role added after the check was written - and assert a denial rather than a pass-through.
- Every verb and every variant is covered. Repeat the cross-owner request for each HTTP method on the path and for any bulk, search or export route over the same data.
- Client-supplied privilege fields are ignored. Send a body carrying a role or owner ID for someone else, then read the stored record and assert the field holds the server-side value. A response that omits the field is not evidence it was not written.
- Then re-scan to confirm the original finding is closed
Common Vulnerable Patterns
- Missing or inconsistent authorization checks
- Relying on client-side enforcement
- Using predictable resource identifiers without ownership checks
Missing Authorization Check on Privileged Endpoint
// VULNERABLE - no authorization check
route("/admin"):
return renderAdminPanel() // anyone can access, authenticated or not
Why this is vulnerable: The route is reachable by anyone who knows the path, and the path is not a secret - it appears in JavaScript bundles, sitemaps, error pages and wordlists. Nothing else in the request is consulted, so authentication is not required and no role is examined.
An absent check is CWE-862 (Missing Authorization); a check that runs and reaches the wrong conclusion is CWE-863 (Incorrect Authorization). Both are children of this page, which MITRE discourages mapping to directly, so a finding on the route above belongs on CWE-862 rather than here. CWE-285 is the right home when a finding does not separate the two - a scanner reporting "authorization issue" on a handler, or a design where it is genuinely unclear whether the check is missing or wrong.
The distinction is worth keeping because it changes where to look next. Absent means the fix is to add a check, and the likely blast radius is every other endpoint in the same group, which will be equally bare. Wrong means the fix is to correct a decision, and the blast radius is every caller of that same decision. Enforcing at a filter or middleware that denies by default, rather than per-handler, is what stops a newly added route from inheriting the absent case by omission.
Secure Patterns
Role-Based Access Control Check
// SECURE - authenticate, then authorize
route("/admin"):
if not currentUser.isAuthenticated:
return 401 // Unauthorized
if not currentUser.isAdmin:
return 403 // Forbidden
return renderAdminPanel()
Why this works:
- The role check runs on the server before the privileged function, so hiding the URL or editing the client changes nothing
- Authentication and authorization are separate decisions: an authenticated user without the admin role is still refused
- The two refusals are distinct: 401 for no credentials, 403 for an authenticated caller who lacks the role
Common Pitfalls
- Checking function-level access but not object-level access: Verifying a user has the "delete" permission in the abstract, then deleting whatever ID the request specifies without checking that this particular user is allowed to touch this particular record - a correctly-permissioned user can then act on any object, not just their own.
- Answering one decision from two places: Adding a missing-record check ahead of the authorization call splits one decision across two response paths and rebuilds the enumeration oracle the ownership check was added to close - a nonexistent ID answers
404from the first check and someone else's ID answers403from the second, so the pair maps which records exist. The first check also answers before the authorization call runs, so it responds even to a caller the check would have refused outright. Let one place decide the whole thing and give every failure the same door. - Adding the check only to the route the scanner flagged: The reported line is a sample of the population, not the population. The list, search, export and bulk routes over the same data have no resource for an object-level check to attach to, so they are usually the ones left bare - and they return more rows per request than the route that was flagged.
- Deriving the authorization decision from client-supplied data: Trusting a role or permission flag carried in a request body, hidden field, or unverified token claim instead of loading it from server-side session/context - an attacker who can edit the request can edit their own "authorization."
- Scattering permission checks across handlers instead of centralizing them: Adding the check inline in each route rather than through one reusable middleware/service means a newly added or refactored endpoint is authorization-blind by default until someone remembers to copy the check over.
- Assuming a prior check in the request flow still applies later: Authorizing a user to open a form, then accepting the submission without re-checking - a request that jumps straight to the later step bypasses the earlier check entirely.
Language-Specific Guidance
- C# - [Authorize] attribute, ClaimsPrincipal, policy-based authorization
- Java - Spring Security SecurityFilterChain, @PreAuthorize, object-level ownership checks
- JavaScript - Express authorization middleware, role/permission checks, object-level ownership checks
- Python - Django @permission_required/PermissionRequiredMixin, DRF permission_classes and scoped querysets, Flask authorization