GUIDE · OWASP Top 10:2025

A01:2025 Broken Access Control

Users act outside their intended permissions — read/write/delete data or call functions they were never authorized for.

lesson5 sectionsnode only2021: A01 same; absorbs A10 SSRF26 asserts149 lines

At a glance

WHAT
Users act outside their intended permissions — read/write/delete data or call functions they were never authorized for.
HAPPENS
IDOR / BOLA (change /orders/123 -> /orders/124); missing function-level auth (admin route, no role check); path traversal (../../etc/passwd); CORS misconfig (reflected Origin + credentials); SSRF (fetch(userUrl) -> 169.254.169.254 metadata, internal hosts); privilege escalation via tampered JWT/cookie claims; mass assignment.
SPOT
any handler that takes an id from the URL/body and loads the record without comparing owner to session user; routes with auth but no authZ; string path joins; fetch/axios with user-controlled URL; Access-Control-Allow-Origin echoing the request Origin.
FIX
deny-by-default authZ middleware; ownership check on every object access (query WHERE owner_id = session.user); RBAC/ABAC centrally; path.resolve + startsWith(root); SSRF: host allowlist + block private/link-local ranges + resolve DNS before connect + no redirects; CORS: explicit origin allowlist, never '*' with creds.
SAY
CWE-284 Improper Access Control, CWE-639 IDOR, CWE-22 Path Traversal, CWE-918 SSRF, CWE-862 Missing Authorization. Pitch: "AuthN says who you are; authZ says what you may touch. Enforce authZ server-side, per object, deny by default."

The idea#

Picture a school dance. A bouncer stands at the gym door with a flashlight and reads your wristband. The band says you belong at the dance. It also says which rooms you may enter: the gym, the snack table, the stage. The photo booth in the back is staff only. The bouncer reads the band again at the side door by the bleachers.

That second look is access control. Signing in tells the program who the person is. Broken Access Control is what you get when the program stops after that hello. The real control is a decision on the server, on the specific thing being touched, and the default answer is no. The owner id is part of the lookup. Each route names the roles that may call it, and a route with no rule stays shut. A file path has to land inside the folder you opened. A browser may call your API with cookies only from an origin you listed, character for character. A link the user asks your server to open has to be https and a host you already trust. Private, loopback, and link-local addresses stay off that list.

Failure looks like a bouncer who waves any wristband into any room. Change the number in the address and you are holding someone else's order. Being logged in is enough to run a staff delete. A download name walks out of the uploads folder. The site treats whatever origin the browser announces as a friend and sends the cookies along. A preview feature fetches an inside machine because the server trusted a URL it did not choose.

Why it matters#

This category sits at the top of the 2025 OWASP list because the bug hides in ordinary handlers. The button is missing on the screen. The route still answers. The database still returns the row. New programmers check the password at the door and then trust every later request. That first check is authentication. The room check is authorization. You need the room check every time, on the server, next to the data.

The harm is other people's records and other people's actions. One lookup that forgets the owner means every id is a door. One admin path that only asks whether someone is logged in means every account can act as staff. One fetch of a URL the user supplied turns your server into a tunnel toward machines that trusted the inside of the network. One loose cross-origin rule lets another website ride along with the user's session.

Deny when the user is missing, when the role list is missing, and when the route was never declared. When the mere existence of a record is sensitive, answer missing and forbidden the same way. The browser is a guest. The server keeps the list.

A small example#

JS
function getOrderForUser(order, sessionUserId) {  if (!order || order.ownerId !== sessionUserId) {    return null;  }  return order;} const order = { id: 123, ownerId: 'u1', total: 10 };const mine = getOrderForUser(order, 'u1');const hidden = getOrderForUser(order, 'u2');

mine is the order. hidden is null. The owner is part of the decision, so a different session receives an empty result.

What the code below is doing#

The page demonstrates the same idea five times.

Object-level authorization comes first. getOrderForUser returns the order only when ownerId matches the session user. A missing order and someone else's order both return null. You send that as not found, so the response does not advertise that the id exists.

Function-level authorization is next. routePolicy lists the roles for each route. authorize returns false when the route is unknown, the user is missing, or roles is absent from the overlap with the policy. A user role cannot call the admin delete. An admin role cannot call a route that was never written down.

Path handling is the third block. safeJoin resolves the root and the requested path, then returns the full path only when it is the root or a child of the root. A path that would land outside comes back null.

Cross-origin headers are the fourth. corsHeaders sets the allow-origin and allow-credentials headers only when the origin is an exact member of allowedOrigins. Every other origin, including a lookalike name, gets an empty object.

Server-side requests are the fifth. isAllowedUrl accepts https only, refuses a username or password embedded in the URL, refuses localhost, and refuses private, loopback, and link-local addresses. The host has to sit in allowedHosts. The note under the checks says a real fetch should refuse redirects.

When you talk about it, say this: login says who you are. The server, on each object, says what you may touch, and the default is no.

Everything below is the demonstration. Same idea, written as code you can run.

JSowasp/a01-broken-access-control.js · setup
'use strict';const assert = require('node:assert/strict');const path = require('node:path');const net = require('node:net');

Not run in the browser: uses node:path, node:net. Copy the node command and run it locally.

01

IDOR / BOLA (Broken Object Level Authorization)#

JS
// VULNERABLE://   app.get('/orders/:id', async (req, res) => {//     const order = await db.orders.findById(req.params.id);   // no owner check//     res.json(order);//   });// FIXED: the owner is part of the lookup, not a later if-statement someone forgets.const orders = new Map([  [123, { id: 123, ownerId: 'u1', total: 10 }],  [124, { id: 124, ownerId: 'u2', total: 99 }],]);function getOrderForUser(orderId, sessionUserId) {  const order = orders.get(orderId);  if (!order || order.ownerId !== sessionUserId) return null; // 404, not 403: don't leak existence  return order;}
Verified by 3 asserts
JS
assert.equal(getOrderForUser(123, 'u1').total, 10);assert.equal(getOrderForUser(124, 'u1'), null);assert.equal(getOrderForUser(999, 'u1'), null);
02

Missing function-level authorization (deny by default)#

JS
// VULNERABLE://   app.delete('/admin/users/:id', requireLogin, deleteUser);  // logged in != admin// FIXED: every route declares the role it needs; unknown route => denied.const routePolicy = {  'GET /orders': ['user', 'admin'],  'DELETE /admin/users': ['admin'],};function authorize(routeKey, user) {  const allowed = routePolicy[routeKey];  if (!allowed || !user || !Array.isArray(user.roles)) return false; // deny by default  return user.roles.some((r) => allowed.includes(r));}
Verified by 4 asserts
JS
assert.equal(authorize('DELETE /admin/users', { roles: ['user'] }), false);assert.equal(authorize('DELETE /admin/users', { roles: ['admin'] }), true);assert.equal(authorize('GET /unknown', { roles: ['admin'] }), false);assert.equal(authorize('GET /orders', null), false);
03

Path traversal#

JS
// VULNERABLE://   res.sendFile('/srv/uploads/' + req.query.name);   // name = '../../etc/passwd'// FIXED: resolve, then prove the result is still inside the root.function safeJoin(root, userPath) {  const base = path.resolve(root);  const full = path.resolve(base, userPath);  if (full !== base && !full.startsWith(base + path.sep)) return null;  return full;}
Verified by 5 asserts
JS
assert.equal(safeJoin('/srv/uploads', 'a.txt'), '/srv/uploads/a.txt');assert.equal(safeJoin('/srv/uploads', '../../etc/passwd'), null);assert.equal(safeJoin('/srv/uploads', '/etc/passwd'), null);assert.equal(safeJoin('/srv/uploads', 'sub/../../x'), null);assert.equal(safeJoin('/srv/uploads', 'sub/../ok.txt'), '/srv/uploads/ok.txt');
04

CORS misconfiguration#

JS
// VULNERABLE://   res.set('Access-Control-Allow-Origin', req.headers.origin);     // reflects anything//   res.set('Access-Control-Allow-Credentials', 'true');            // + cookies = account takeover// FIXED: exact-match allowlist; null origin and subdomain-suffix tricks rejected.const allowedOrigins = new Set(['https://app.example.com']);function corsHeaders(origin) {  if (!allowedOrigins.has(origin)) return {};  return {    'Access-Control-Allow-Origin': origin,    'Access-Control-Allow-Credentials': 'true',    'Vary': 'Origin',  };}
Verified by 4 asserts
JS
assert.deepEqual(corsHeaders('https://evil.com'), {});assert.deepEqual(corsHeaders('https://app.example.com.evil.com'), {});assert.deepEqual(corsHeaders('null'), {});assert.equal(corsHeaders('https://app.example.com')['Access-Control-Allow-Origin'], 'https://app.example.com');
05

SSRF (Server-Side Request Forgery)#

moved into A01 in 2025

JS
// VULNERABLE://   app.get('/preview', async (req, res) => {//     const r = await fetch(req.query.url);   // http://169.254.169.254/latest/meta-data/iam///     res.send(await r.text());               // or http://localhost:6379/, file:///etc/passwd//   });// FIXED: scheme + host allowlist, block private/link-local/loopback, no redirects.// (In production also resolve DNS and re-check the IP to defeat DNS rebinding.)function isPrivateIp(ip) {  if (net.isIPv4(ip)) {    const [a, b] = ip.split('.').map(Number);    return a === 10 || a === 127 || a === 0 ||      (a === 169 && b === 254) ||      (a === 172 && b >= 16 && b <= 31) ||      (a === 192 && b === 168);  }  if (net.isIPv6(ip)) {    const l = ip.toLowerCase();    return l === '::1' || l === '::' || l.startsWith('fc') || l.startsWith('fd') ||      l.startsWith('fe80') || l.startsWith('::ffff:');  }  return false;}const allowedHosts = new Set(['api.partner.com', 'cdn.example.com']);function isAllowedUrl(input) {  let u;  try { u = new URL(input); } catch { return false; }  if (u.protocol !== 'https:') return false;  if (u.username || u.password) return false;  const host = u.hostname.replace(/^\[|\]$/g, '');  if (host === 'localhost' || host.endsWith('.localhost')) return false;  if (net.isIP(host) && isPrivateIp(host)) return false;  return allowedHosts.has(host);}
Verified by 9 asserts
JS
assert.equal(isAllowedUrl('https://api.partner.com/v1/x'), true);assert.equal(isAllowedUrl('http://169.254.169.254/latest/meta-data/'), false);assert.equal(isAllowedUrl('https://169.254.169.254/'), false);assert.equal(isAllowedUrl('https://10.0.0.5/admin'), false);assert.equal(isAllowedUrl('https://localhost:6379/'), false);assert.equal(isAllowedUrl('https://[::1]/'), false);assert.equal(isAllowedUrl('file:///etc/passwd'), false);assert.equal(isAllowedUrl('https://api.partner.com@evil.com/'), false);assert.equal(isAllowedUrl('https://evil.com/'), false);// fetch(url, { redirect: 'error' }) — never follow redirects from allowlisted hosts to internal ones.

See also