CWE-863: Incorrect Authorization
Overview
Incorrect Authorization occurs when an authorization check exists but its logic is flawed, letting an attacker satisfy a condition the developer did not intend to grant. Common causes are denylist role comparisons that fail open on an unexpected value, checks that confirm a resource's type but never its ownership or tenant, inverted or short-circuited boolean logic, and checks enforced on one code path but missing from a duplicate one - an alternate HTTP method, a bulk endpoint, or a client-only check the server never repeats.
Relationship to Other CWEs
CWE-863 sits under CWE-285 (Improper Authorization), which MITRE marks Discouraged for mapping - report the finding here rather than against the class.
- CWE-863 (this page) - a check exists but its logic is wrong, so the fix is to correct the logic rather than add a missing check. It covers the wider class of flawed authorization logic: role and permission checks, control-flow gaps, and boolean errors that are not tied to a single identifier parameter.
- CWE-862 (Missing Authorization) - a sibling under the broader Improper Authorization class, for when no authorization check exists on the path at all.
- CWE-639 (Authorization Bypass Through User-Controlled Key) - MITRE's child of CWE-863 and the narrower case, commonly called IDOR: a user-controlled object identifier selecting another user's data. Where a finding names an identifier in the request, prefer that page for the concrete remediation.
OWASP Classification
A01:2025 - Broken Access Control
Risk
High: A flawed authorization check can be satisfied without stealing credentials or bypassing authentication. The attacker triggers the condition the logic gets wrong: an unrecognized role value, a resource ID belonging to another user or tenant, or a duplicate endpoint the check never reached. Depending on the resource, that means horizontal or vertical privilege escalation, cross-tenant data exposure, or unauthorized state changes.
Remediation Steps
Core Principle: Find the exact condition the authorization check evaluates, determine why it can be satisfied by an unintended caller, and correct the logic - not just the symptom - on every path that reaches the protected action.
Trace the Data Path
- Source: The role, permission, or ownership data the check evaluates - a session claim, a verified token field, or a resource record loaded from the database
- Sink: The authorization decision point itself - the
if/switchcondition, framework guard, or policy expression that allows or denies the sensitive action - Data Flow / Missing Controls: A denylist comparison instead of an allowlist, a resource-type check with no matching ownership/tenant check, an inverted or short-circuited boolean, or a code path (alternate HTTP method, bulk operation, internal API) that never reaches the check at all
Replace Denylist Logic with an Allowlist (Primary Defense)
A denylist comparison (if role != "admin") only rejects the values the developer anticipated; any new, renamed, or unexpected role value falls through and is treated as allowed. Replace it with an explicit allowlist of permitted roles or permissions, evaluated so that anything not on the list is denied by default.
- Enumerate the exact set of roles or permissions allowed to perform the action, and compare against that set rather than against the set that should be blocked
- Treat an unrecognized, empty, or malformed role/permission value as a denial, not a pass-through
Pair Resource-Type Checks with Ownership or Tenant Checks
Confirming "this is a document" or "this is an order" is not the same as confirming "this is the caller's document" or "this order belongs to the caller's tenant." Every check that authorizes access to a specific resource instance must also verify the caller's relationship to that instance.
- Load the resource server-side and compare its owner, tenant, or account field against the authenticated caller's identity resolved from a trusted server-side source
- Never accept an ownership or tenant claim supplied by the client (a body field, header, or hidden form value) as the value being checked against
Audit Boolean Logic for Inversions and Short-Circuit Errors
Authorization conditions are easy to get backwards: an || where && was intended turns "must satisfy both conditions" into "either one is enough," and a negated comparison can silently invert the intended rule. Re-derive each condition from the intended access rule in plain language, then verify the code matches it term by term.
- Write out the intended rule before reading the code ("admin OR (owner AND active)") and compare it to the actual expression
- Watch conditions that mix role checks and resource checks with
||, where a single misplaced operator makes either condition alone sufficient
Apply the Corrected Check to Every Path (Defense in Depth)
A fix applied to one entry point does not protect a duplicate one. Enumerate every route, method, and operation that reaches the same sensitive action and confirm each one runs the corrected check.
- Cover all HTTP methods for the resource (not just
GET, butPUT/PATCH/DELETEand any bulk or batch variant) - Remove or re-implement any authorization logic that exists only in client-side code; the server must independently re-run the check on every request
- Fail closed: an unmatched role, an unresolved ownership lookup, or an error evaluating the check must result in denial, never a default allow
Test with Malicious Inputs
- Submit a role the allowlist does not recognize and confirm it is denied, not silently permitted
- Authenticate as one user and request another user's or tenant's resource ID directly
- Request an ID that exists in no row and an ID owned by another user, and assert both return the identical status and body. A
403for one and a404or500for the other is an existence oracle whichever way round it falls, because it lets a caller walk the ID space reading which records exist - Repeat every test against each HTTP method and any bulk or duplicate endpoint for the resource, not only the one where the fix was applied
- Re-scan with the security scanner to confirm the finding is resolved
Common Vulnerable Patterns
// VULNERABLE - pseudo-code
function authorizeAction(role, resource, currentUser):
if role != "admin": // denylist: names the one role it refuses...
return true // ...and the branches are inverted, so every
return false // other role - known or not - is allowed
function updateResource(id, request):
resource = loadResource(id) // resource type confirmed, ownership never checked
if authorizeAction(request.role, resource, currentUser):
resource.apply(request.body)
save(resource)
// Attack: send role="Support" (a value the check never anticipated), or a
// valid role with another user's resource ID
// Result: "Support" != "admin" is true, so the denylist comparison returns
// allow, and no ownership check exists to stop the request from modifying
// another user's resource
Why this is vulnerable: three separate defects sit in these two functions, and a fix that addresses only one leaves the endpoint exploitable. The inverted branches are the immediate bug. The denylist is the structural one, and it would still be wrong after the branches were corrected, because a role added next quarter is permitted the moment it exists. The missing ownership check is independent of both: correcting the role logic entirely still leaves any allowed role able to modify any resource.
Secure Patterns
// SECURE - pseudo-code
ALLOWED_ROLES = {"admin", "editor"}
function authorizeAction(role, resource, currentUser):
if role not in ALLOWED_ROLES: // allowlist: unmatched values are denied
return false
if role == "admin":
return true
return resource.ownerId == currentUser.id // ownership checked alongside role
function updateResource(id, request, currentUser):
resource = loadResource(id)
// one branch for both refusals: an ID that exists in no row and an ID
// belonging to someone else must be indistinguishable from outside
if resource is null or not authorizeAction(currentUser.role, resource, currentUser):
return 404
resource.apply(request.body)
save(resource)
// The same authorizeAction() call is applied to every route that reaches
// this resource - GET, PUT, DELETE, and any bulk-update variant.
Why this works: the allowlist denies any role value the developer did not approve, closing the fail-open gap a denylist leaves behind. The ownership comparison runs against a server-loaded resource field, not a client-supplied claim, so a valid role alone is no longer enough to act on someone else's resource. Because the same function is called from every entry point for the resource, a fix applied once cannot be skipped by a duplicate route.
The null check is not defensive tidiness. Without it, authorizeAction dereferences resource.ownerId on an ID that matches no row and the endpoint answers 500 where a foreign ID answers 403 - which hands a caller an existence oracle for the whole table, built out of two different failures rather than one decision. Folding both into a single 404 costs nothing and removes the difference. Where the resource is fetched by a user-controlled identifier at all, CWE-639 is the narrower CWE and carries the stronger form of this fix: put ownership into the query (WHERE id = ? AND owner_id = ?) so a route that forgets the check is still confined to rows the caller could have seen.
Common Pitfalls
- Fixing the denylist by adding more excluded values: Adding
role != "admin" && role != "support"after a bypass report still fails open on the next unanticipated role - replace the comparison with an allowlist rather than extending the denylist. - Checking resource type without ownership: Confirming
resource != nullor that the caller "can access orders" in the abstract, then acting on whatever order ID the request specifies - this authorizes the resource class, not the specific instance. - Patching only the endpoint the scanner flagged: Correcting the logic on
GET /orders/{id}whileDELETE /orders/{id}or a/orders/bulk-updateendpoint retains the original flawed check, because it was implemented as a separate, unsynchronized copy. - Trusting a client-supplied ownership or role field: Accepting an
ownerIdorrolevalue from the request body as the value being checked, instead of resolving it from a server-side session, token, or database lookup - an attacker sets the field to whatever the check expects.
Language-Specific Guidance
- C# - Policy-based and resource-based authorization with
IAuthorizationHandler, replacing inline role comparisons and[Authorize(Roles = "...")]misuse - Go - Centralizing authorization in shared functions, allowlist role checks, and closing gaps from missing or mis-ordered middleware
- Java - Spring Security
@PreAuthorizeSpEL expressions combining role and ownership checks, and common method-security misconfigurations - JavaScript - Express/NestJS guard and middleware logic bugs, allowlist role checks, and server-side ownership verification
- PHP - Laravel Policy and Gate logic bugs, instance-level versus class-level authorization checks
- Python - Django REST Framework permission class logic,
has_permissionversushas_object_permissioncoverage gaps