CWE-862: Missing Authorization - JavaScript
Overview
In Express and similar Node.js frameworks, Missing Authorization typically appears as a route that runs authentication middleware confirming a valid session or JWT but has no follow-up check on role or resource ownership, or as a new route registered directly on the router without the shared authorization middleware applied to sibling routes. NestJS applications show the same gap as a controller method missing @UseGuards(RolesGuard) or an equivalent ability check. The fix is a per-route authorization middleware (or guard) for role checks, and for resource-specific actions a check comparing the resource's owner field to the authenticated caller before performing the operation.
Common Vulnerable Patterns
Authentication Middleware With No Role Check
// VULNERABLE - requireAuth confirms a valid session, but nothing checks role
router.post('/orders/:id/refund', requireAuth, async (req, res) => {
await orderService.refund(req.params.id); // any authenticated user can call this
res.status(204).end();
});
// Attack: any logged-in user sends POST /orders/500/refund directly
// Result: the refund executes with no role check at all
Why this is vulnerable: requireAuth confirms req.user is set from a valid session or token, but nothing on this route checks whether that user is allowed to issue refunds - any authenticated caller reaches the handler.
Resource Lookup With No Ownership Check
// VULNERABLE - returns whatever order matches the ID, regardless of caller
router.get('/orders/:id', requireAuth, async (req, res) => {
const order = await Order.findById(req.params.id);
res.json(order); // no check that req.user owns this order
});
// Attack: an authenticated user requests /orders/1, /orders/2, /orders/3...
// Result: every user's order data is returned to every other user
Why this is vulnerable: The handler loads the order by ID alone and returns it, with no comparison between the order's owner and req.user.id - authentication is confirmed, but authorization for this specific record never runs.
Secure Patterns
Role-Based Middleware Applied Per Route
// SECURE - role check runs as its own middleware, between auth and the handler
function requireRole(role) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'unauthorized' });
}
if (req.user.role !== role) {
return res.status(403).json({ error: 'forbidden' });
}
next();
};
}
router.post('/orders/:id/refund', requireAuth, requireRole('admin'), async (req, res) => {
await orderService.refund(req.params.id);
res.status(204).end();
});
Why this works: requireRole runs as a distinct middleware step in the route definition, so a new route that omits it is visibly short a step in the route file rather than hiding the gap inside a handler body.
Resource-Based Ownership Check Inside the Handler
// SECURE - ownership is a term in the query, so there is one way to fail
router.get('/orders/:id', requireAuth, async (req, res) => {
const order = await Order.findOne({ _id: req.params.id, ownerId: req.user.id });
if (!order) {
return res.status(404).json({ error: 'not found' });
}
res.json(order);
});
Why this works: Ownership is part of what is asked rather than a check applied to the answer, so an order that does not exist and an order belonging to someone else are the same result - null - and there is only one response path to get wrong.
The shape to avoid is the one that looks more careful: load by ID, return 404 if the row is missing, then 403 if the caller does not own it. That splits one decision across two response paths and builds an existence oracle out of the pair, because 403 confirms the record exists and 404 confirms it does not. An attacker walks the ID space reading which is which. Both denials have to leave by the same door, and scoping the query is what makes that automatic rather than a rule somebody has to remember on the next route.
Framework-Specific Guidance
NestJS Guards
// SECURE - a Guard runs authorization before the handler executes
@Injectable()
export class OrderOwnerGuard implements CanActivate {
constructor(private readonly orders: OrdersService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const order = await this.orders.findForOwner(request.params.id, request.user.id);
if (!order) {
throw new NotFoundException(); // same answer whether it is missing or not yours
}
request.order = order; // the handler reuses it rather than re-querying
return true;
}
}
@UseGuards(AuthGuard, OrderOwnerGuard)
@Get('orders/:id')
getOrder(@Req() request) {
return request.order;
}
Why this works: Guards run before the route handler and can reject the request outright, so authentication and ownership are separate, independently testable steps rather than one tangle inside the handler body.
Stacked guards are ANDed, which is usually not the rule you want for ownership. Nest requires every guard in @UseGuards(...) to return true, so adding RolesGuard with @Roles('admin') beside OrderOwnerGuard demands the caller be an administrator and the owner - which refuses the ordinary customer the route exists for, and passes every "is another user blocked?" test unchanged. The rule an ownership route almost always wants is admin or owner, and there is no way to express a disjunction by stacking. Put the alternative inside one guard:
// SECURE - one guard, one decision - admin OR owner
const order = request.user.roles.includes('admin')
? await this.orders.findById(request.params.id)
: await this.orders.findForOwner(request.params.id, request.user.id);
if (!order) {
throw new NotFoundException();
}
The administrator branch widens the query rather than skipping the check, so an ID that does not exist still answers 404 for them too.
Two details make the guard hold. It queries by ID and owner, so a missing order and someone else's order both come back empty and both raise NotFoundException - returning false for the second would answer 403 and tell the caller the record exists. And the handler returns the object the guard already fetched rather than calling findById(id) again: an unscoped re-query after a scoped check is the same weakness reintroduced one line later, and it is easy to miss because the guard above it is correct.
CASL for Ability-Based Checks
For applications with more than a handful of role/resource combinations, a library such as CASL lets you define abilities once (can('update', 'Order', { ownerId: user.id })) and check them consistently across REST routes, GraphQL resolvers, and background jobs, instead of re-deriving the same ownership logic in each layer.
GraphQL Resolvers
Authorization does not come free with a GraphQL server the way authentication middleware might - each resolver needs its own check, since a single query can invoke many resolvers and a check on the top-level query does not automatically apply to nested field resolvers that return related, potentially unauthorized data.
Testing
- Normal: call the route as a user who owns the target resource; confirm 200 and that the body is that resource. This is the assertion that catches a scoped query with the wrong owner column, which refuses everyone while passing every test below.
- Boundary: request a resource owned by a different user, then request an ID that does not exist, and confirm the two responses are identical - same status and same body. Under the scoped-lookup patterns above both are 404. A 403 for one and a 404 for the other is an existence oracle whichever way round they are, because it tells a caller which IDs are real.
- Malicious: call the route directly with
supertest, bypassing any client-side UI, using a token for an authenticated user with no assigned role; confirm the request is refused, not 200. A role check that does not depend on a resource the caller named can answer 403 here - the caller learns nothing about what exists. - For GraphQL, test that nested field resolvers enforce authorization independently of the top-level query's check.
- Re-run any SAST/DAST scan that reported the finding to confirm it no longer triggers.
Common Pitfalls
- Registering the authorization middleware before the authentication middleware: The check then runs with
req.userstillundefined. Whether that denies or allows depends entirely on how the guard is written -if (req.user.role !== 'admin')throws, which is loud, but the defensive-lookingif (req.user && req.user.role !== 'admin')treats an unauthenticated request as passing the check. Order the middleware so identity is populated first, and write the guard to deny when there is no user rather than to skip. - Trusting a role or permission value from the request body: Reading
req.body.roleor a client-suppliedisAdminflag instead ofreq.user.roleset by the authentication layer - any value the client sends in the request is attacker-controlled and must never be treated as authoritative. - Role check with no ownership comparison: Verifying
req.user.role === 'customer'on a route that returns a record by ID, without checking that the record belongs to the caller - this is IDOR (CWE-639) hiding behind a role check. - Protecting the REST route but not the GraphQL resolver for the same data: Adding an ownership check to a REST endpoint while a GraphQL query or nested resolver exposing the same underlying data has no equivalent check.
- Hiding UI elements instead of enforcing the check server-side: Removing a button or link for users without the right role while the API route it would have called performs no check itself - the route is still reachable directly.
Dependencies and Installation
Role and ownership middleware can be written without a dependency. For ability-based checks shared across REST routes, GraphQL resolvers, and the frontend, add @casl/ability (npm install @casl/ability); NestJS projects pairing CASL with guards can additionally use @casl/prisma or a custom CaslAbilityFactory provider, depending on the ORM in use.