Web Security (OWASP Top 10:2025) Module 2 – A01:2025 Broken Access Control
Function- and property-level access control
Lock admin functions on every path and method, return only the fields each role may see, and prove both with contract tests that fail when a field leaks.
What you will learn
- Protect privileged functions, such as a user export, at the function level on every path that reaches them
- Explain why hidden buttons, obscure addresses and client-side flags are not access control
- Recognise method, path-alias and override variants that reach a protected handler
- Build each response from an output allowlist per role, applied at every level of nesting
- Write contract tests that fail on any field outside a role's contract
Before you start
What you’ll learn · Before you start
What you will learn
- Protect privileged functions, such as a user export, at the function level on every path that reaches them
- Explain why hidden buttons, obscure addresses and client-side flags are not access control
- Recognise method, path-alias and override variants that reach a protected handler
- Build each response from an output allowlist per role, applied at every level of nesting
- Write contract tests that fail on any field outside a role's contract
Before you start
On this page
An admin export guarded at /admin/users/export and mounted again at /api/users/export for the mobile app hands
every user’s record to any customer who asks for the second address. A profile endpoint that returns its database row
sends 10 fields where a stranger should see 2, a password hash among them. Both are the same mistake one level apart:
the check was attached to the wrong thing.
Function-level access control decides who may run a function, wherever it is reached from. Property-level access control decides which fields of a record each caller may read or set. This lesson fixes both, through paths, methods, overrides and included objects, and ends with a contract test that fails the day a new column reaches a response.
An admin function needs its own check, on every path
Some functions are dangerous whoever calls them on whatever record: exporting every user, impersonating a customer, changing someone’s role, issuing a refund. OWASP’s API Security Top 10 calls a missing check on such a function broken function level authorization (BFLA): Risk API5:2023 of the OWASP API Security Top 10: a caller can run a function meant for another group, such as an admin export, because the function does not check the caller's role., risk API5:2023, and the second scenario of A01:2025 is its plainest form: an admin page that a non-admin reaches just by asking for its address, which OWASP calls forced browsing: Requesting a page or an API address directly, by typing or guessing it, instead of following a link the application showed. A protected function must refuse such requests by itself.. ASVS 5.0.0 requirement 8.2.1 asks that every function be restricted to callers with explicit permission for it.
The program below has one admin function, export_users, reachable at two paths. The weak app guards the /admin/
prefix; the fixed app registers the function together with its policy. Step through Asha’s two requests first:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One function. export_users returns every user's record. It is an admin function, and A01:2025 lists guessing such addresses, forced browsing, among the ways in.
- The guarded path. Asha asks for /admin/users/export. The rule for the /admin/ prefix sees a customer and answers 403, and the function never runs.
- The second path. The mobile app needed the same data, so the same function was mounted at /api/users/export. No rule mentions that prefix.
- The function runs. The weak app finds no rule for /api/, so it calls export_users and Asha receives every user's record. The check belonged to a path; the privilege belongs to the function.
- The policy on the function. The fixed app registers export_users together with its policy, admin only. Both paths reach the function through that registration.
- Every path refuses. Asha gets 403 at both addresses, and so will any third path someone adds later, because the check travels with the function.
# The admin user export is guarded on its /admin path. A second path to the same function was added for the mobile
# app. The weak app checks by path; the fixed app attaches the policy to the function, so every path inherits it.
def export_users():
return "3 users: asha, ravi, nisha"
def admin_only(user):
return user["role"] == "admin"
# Weak: a route table of path -> handler, and a separate rule for the /admin/ prefix.
weak_routes = {"/admin/users/export": export_users, "/api/users/export": export_users}
def weak_call(user, path):
if path.startswith("/admin/") and not admin_only(user):
return "403"
return weak_routes[path]()
# Fixed: the policy is part of the function's registration, so it travels with the function to every path.
def protected(policy, handler):
return {"policy": policy, "handler": handler}
export = protected(admin_only, export_users)
fixed_routes = {"/admin/users/export": export, "/api/users/export": export}
def fixed_call(user, path):
route = fixed_routes[path]
return route["handler"]() if route["policy"](user) else "403"
asha = {"name": "asha", "role": "customer"}
for path in ["/admin/users/export", "/api/users/export"]:
print(f"asha GET {path:<20} weak: {weak_call(asha, path):<28} fixed: {fixed_call(asha, path)}") Output
asha GET /admin/users/export weak: 403 fixed: 403 asha GET /api/users/export weak: 3 users: asha, ravi, nisha fixed: 403
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 function_level.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The weak app refuses Asha at /admin/users/export and sends her every user’s record at /api/users/export. The check
was attached to an address, and the privilege belonged to the function, so the second address had none. API5:2023
warns against exactly this assumption, that an endpoint is regular or administrative because of its URL path, and asks
for administrative controllers to inherit their checks from a shared base and for every function to go through one
consistent authorisation module. The fixed app follows the same idea: wherever export_users is mounted, its policy
comes with it.
Key idea
Attach a privileged function’s policy to the function or to its route group, never to one path to it: then every path that reaches the function, including the next one someone adds, inherits the check.
Checkpoint
Check: Function-level checks on every path
Hidden buttons, secret addresses and client flags are not checks
Three habits feel like access control and are not. Each is a choice about what a client shows or knows, and every client is in the user’s hands:
| What the code does | What it really does | What is needed |
|---|---|---|
Hides the Admin menu unless user.isAdmin is true in the page |
Changes what the page draws | A policy on every admin function |
Serves the admin panel at an unlisted address, /ops-7f3a9/ |
Nothing, once the address is in the bundle or a log | The same policy; the address may be public |
Keeps an isAdmin flag in local storage or a cookie |
Lets the user switch it on | The role from the server’s records |
The second row deserves a moment. A single-page app ships its route table and its API calls to every visitor, so the address of every admin endpoint it uses is in a file anyone can download and read. OWASP’s Authorization Cheat Sheet is direct: developers must never rely on client-side access control checks, which may help the interface but must not decide. The third scenario of A01:2025 makes the point with a front end that blocks the admin address and an API that answers it to anyone who asks directly.
Security through an unlisted address
A long random admin path keeps casual visitors away, and that is all it does. Keep the address if you like, but give the functions behind it a policy, and test that a customer gets 403 there as everywhere else.
Checkpoint
Check: What client-side measures do not do
Methods, aliases and overrides reach the same handler
A policy has to protect the function the router actually runs, and routers are generous about what they accept.
Express treats /Admin/users and /admin/users as the same route unless case-sensitive routing is switched on,
treats /admin/users/ and /admin/users as the same unless strict routing is on, and runs a GET route for HEAD
requests too, unless a HEAD route was registered for the path before it. The method-override middleware turns a POST into the
method named in X-HTTP-Method-Override. A guard that compares the raw request with a list of protected strings is
deciding about a different request from the one that runs. Step through two of them:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A capital A. Asha asks for GET /Admin/users. The guard compares it with its protected pattern, GET /admin/users, and the two strings differ.
- The guard lets it pass. No protected pattern matched, so the guard waves the request on. It decided about the text of the request, not about the route that will run.
- The router finds the admin route. The router ignores case, as Express does unless case-sensitive routing is on, so /Admin/users runs the admin handler, and Asha receives the list of every user.
- A method override. Next, Asha sends POST /admin/users/7 with X-HTTP-Method-Override set to DELETE. The guard protects DELETE on that path, and this request says POST.
- Deleted after the check. The guard passes the POST, and the override middleware turns it into a DELETE before the router matches, so user 7 is deleted.
- Decide on the route that runs. The fixed app has no guard in front. The router resolves the method and the path first, then runs the policy of the route it found, so the capital letter, a final slash and the override all get 403.
The program sends Asha’s four requests to a weak app, a list of protected patterns checked before the router, and to a fixed app, in which the router resolves the method and the path first and then runs the policy of the route it found:
// A guard that reads the request as it arrived decides about a different request than the router runs. This router
// ignores case and a trailing slash, as Express does by default, and applies X-HTTP-Method-Override before it matches.
const routes = [
{ method: 'GET', path: '/admin/users', policy: 'admin', run: () => 'the list of every user' },
{ method: 'DELETE', path: '/admin/users/:id', policy: 'admin', run: (p) => `user ${p.id} deleted` },
];
function match(method, path) {
const parts = path.toLowerCase().replace(/\/+$/, '').split('/');
for (const r of routes) {
const want = r.path.split('/');
if (r.method !== method || want.length !== parts.length) continue;
const params = {};
if (want.every((w, i) => (w.startsWith(':') ? (params[w.slice(1)] = parts[i]) : w === parts[i]))) return { r, params };
}
return null;
}
const methodOf = (req) => (req.headers['x-http-method-override'] ?? req.method).toUpperCase();
// Weak: a list of protected "METHOD path" patterns, compared with the raw request before the router runs.
const adminOnly = ['GET /admin/users', 'DELETE /admin/users/:id'];
const literal = (pattern, req) => {
const [m, p] = pattern.split(' ');
return m === req.method && new RegExp(`^${p.replace(/:\w+/g, '[^/]+')}$`).test(req.path);
};
function weak(user, req) {
if (adminOnly.some((pattern) => literal(pattern, req)) && user.role !== 'admin') return '403';
const found = match(methodOf(req), req.path);
return found ? found.r.run(found.params) : '404';
}
// Fixed: the router picks the route first, after the override, and the route's own policy decides.
function fixed(user, req) {
const found = match(methodOf(req), req.path);
if (!found) return '404';
return user.role === found.r.policy ? found.r.run(found.params) : '403';
}
const asha = { name: 'asha', role: 'customer' };
const requests = [
{ method: 'GET', path: '/admin/users', headers: {} },
{ method: 'GET', path: '/Admin/users', headers: {} },
{ method: 'GET', path: '/admin/users/', headers: {} },
{ method: 'POST', path: '/admin/users/7', headers: { 'x-http-method-override': 'DELETE' } },
];
for (const req of requests) {
const shown = `${req.method} ${req.path}${req.headers['x-http-method-override'] ? ' (override DELETE)' : ''}`;
console.log(`asha ${shown.padEnd(37)} weak: ${weak(asha, req).padEnd(23)} fixed: ${fixed(asha, req)}`);
} Output
asha GET /admin/users weak: 403 fixed: 403 asha GET /Admin/users weak: the list of every user fixed: 403 asha GET /admin/users/ weak: the list of every user fixed: 403 asha POST /admin/users/7 (override DELETE) weak: user 7 deleted fixed: 403
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node method_alias.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Only the first request, the one exactly as written in the guard’s list, is refused by the weak app. A capital letter or
a trailing slash returns the list of every user, and the override deletes user 7. The fixed app answers 403 to all
four, because it never compares strings with the request: it asks the route that would run. API5:2023 lists the same
probes as signs of a vulnerable API, changing the HTTP method or guessing the URL, and a check per method, made on the
matched route, closes all of them.
Turn the override off
Press Edit, delete req.headers['x-http-method-override'] ?? from methodOf, and run it again. The weak app now
answers the last request with 404, because no route takes POST; the method-override middleware reads the header
from POST requests only by default, and accepting it on more methods widens the gap.
Checkpoint
Check: Methods, aliases and overrides
Return the fields a role may see, never the row
The second half of this lesson is about what a permitted request gets back. A profile route that serialises the database row sends everything the table holds, and tables hold more than any screen shows: password hashes, reset tokens, internal flags, notes by support staff. OWASP’s API Security Top 10 calls this broken object property level authorization (BOPLA): Risk API3:2023 of the OWASP API Security Top 10: an API returns, or lets a caller change, properties of an object that this caller may not read or set., risk API3:2023, which merged excessive data exposure and mass assignment of the 2019 edition. ASVS 5.0.0 asks for field-level checks in 8.2.3 and, in 15.3.1, that a response return only the fields its caller needs.
The program serves one profile three ways: the row as it is, the row minus a denylist of known secrets, and an output allowlist: The list of fields a response may contain for a given kind of caller. The response is built from the list, so a field nobody listed, including a new column, is never sent. per viewer. Then a migration adds a column, and it serves them again:
// GET /users/:id three ways: the database row as it is, the row minus a denylist of known secrets, and an allowlist
// of fields per viewer. Then a migration adds a column, and we look again. Every secret here is fake.
const row = {
id: 1, name: 'asha', email: 'asha@example.com', city: 'Pune', role: 'customer',
passwordHash: 'argon2id$fake', resetToken: 'fake-reset-token', mfaSecret: 'FAKESECRET', flagged: false,
supportNotes: 'asked about a refund',
};
const asRow = (viewer, r) => ({ ...r });
const denylist = (viewer, r) => {
const { passwordHash, resetToken, mfaSecret, ...rest } = r;
return rest;
};
const FIELDS = {
self: ['id', 'name', 'email', 'city', 'role'],
support: ['id', 'name', 'email', 'city', 'role', 'supportNotes'],
other: ['id', 'name'],
};
const allowlist = (viewer, r) => {
const as = viewer.role === 'support' ? 'support' : viewer.name === r.name ? 'self' : 'other';
return Object.fromEntries(FIELDS[as].filter((f) => f in r).map((f) => [f, r[f]]));
};
const viewers = [
['asha reads her own profile', { name: 'asha', role: 'customer' }],
['kiran reads asha', { name: 'kiran', role: 'customer' }],
['ravi (support) reads asha', { name: 'ravi', role: 'support' }],
];
function show(r) {
for (const [name, project] of [['row', asRow], ['denylist', denylist], ['allowlist', allowlist]]) {
for (const [label, viewer] of viewers) {
if (name !== 'allowlist' && label !== viewers[1][0]) continue; // the row and the denylist ignore the viewer
console.log(` ${name.padEnd(9)} ${label.padEnd(27)} ${Object.keys(project(viewer, r)).join(', ')}`);
}
}
}
console.log('the table as it is:');
show(row);
console.log('after a migration adds dateOfBirth:');
show({ ...row, dateOfBirth: 'fake-value' }); Output
the table as it is: row kiran reads asha id, name, email, city, role, passwordHash, resetToken, mfaSecret, flagged, supportNotes denylist kiran reads asha id, name, email, city, role, flagged, supportNotes allowlist asha reads her own profile id, name, email, city, role allowlist kiran reads asha id, name allowlist ravi (support) reads asha id, name, email, city, role, supportNotes after a migration adds dateOfBirth: row kiran reads asha id, name, email, city, role, passwordHash, resetToken, mfaSecret, flagged, supportNotes, dateOfBirth denylist kiran reads asha id, name, email, city, role, flagged, supportNotes, dateOfBirth allowlist asha reads her own profile id, name, email, city, role allowlist kiran reads asha id, name allowlist ravi (support) reads asha id, name, email, city, role, supportNotes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node users_api.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The row sends Kiran all 10 of Asha’s fields, the password hash and the reset token among them. The denylist removes the
three secrets it knows of and still sends the internal flagged mark and support’s notes, and after the migration it
sends dateOfBirth too, because nobody thought to add it. The allowlist sends Kiran exactly 2 fields, id and name,
before and after, and gives Asha and Ravi in support the lists their roles were given. API3:2023 puts it plainly: avoid
generic serialisers, and pick the specific properties to return.
| Serialiser | Fields Kiran gets | After a new column |
|---|---|---|
| The row | 10, three secrets included | 11, the new one included |
| Row minus a denylist | 7, internal notes included | 8, the new one included |
| Allowlist per viewer | 2 | 2 |
Writes are the mirror image. A route that copies every field of a request body onto a record lets a customer set
role or flagged themselves; ASVS 5.0.0 15.3.3 asks that each handler limit which fields it accepts, and the
integrity module of this track covers that attack, mass assignment, on its own.
Key idea
Build each response from a list of the fields this caller may see, so that a field nobody listed, including one added tomorrow, can never be copied into it.
Checkpoint
Check: Output allowlists per role
Included objects need the same filter
APIs often let a client ask for related records in the same response, with a parameter such as
?include=customer,auditLog or a GraphQL query that walks from an invoice to its customer. Each included object is
another response hiding inside the first, and each needs both checks again: may this caller include this relation at
all, and which of its fields may they see?
// GET /invoices/inv-7?include=... expands related records into the response. The weak handler attaches whatever is
// asked for, as stored. The fixed one allows each include per role and passes every included record through a field
// allowlist of its own, as it would a record at the top level. Every secret here is fake.
const customers = { 1: { id: 1, name: 'asha', email: 'asha@example.com', passwordHash: 'argon2id$fake', resetToken: 'fake-reset-token' } };
const invoice = { id: 'inv-7', total: 1200, customerId: 1 };
const auditLog = [{ by: 'ravi', action: 'refund issued' }];
const related = { customer: (inv) => customers[inv.customerId], auditLog: () => auditLog };
function weak(viewer, include) {
const out = { id: invoice.id, total: invoice.total };
for (const name of include) if (related[name]) out[name] = related[name](invoice);
return JSON.stringify(out);
}
const INCLUDES = { customer: ['customer'], support: ['customer', 'auditLog'] };
const FIELDS = { customer: ['id', 'name', 'email'], auditLog: ['by', 'action'] };
const pick = (record, fields) => Object.fromEntries(fields.filter((f) => f in record).map((f) => [f, record[f]]));
const project = (name, value) => (Array.isArray(value) ? value.map((v) => pick(v, FIELDS[name])) : pick(value, FIELDS[name]));
function fixed(viewer, include) {
const refused = include.filter((name) => !(INCLUDES[viewer.role] ?? []).includes(name));
if (refused.length) return `400 include not available: ${refused.join(', ')}`;
const out = { id: invoice.id, total: invoice.total };
for (const name of include) out[name] = project(name, related[name](invoice));
return JSON.stringify(out);
}
const asha = { name: 'asha', role: 'customer' };
for (const include of [['customer', 'auditLog'], ['customer']]) {
console.log(`asha ?include=${include.join(',')}`);
console.log(` weak: ${weak(asha, include)}`);
console.log(` fixed: ${fixed(asha, include)}`);
} Output
asha ?include=customer,auditLog
weak: {"id":"inv-7","total":1200,"customer":{"id":1,"name":"asha","email":"asha@example.com","passwordHash":"argon2id$fake","resetToken":"fake-reset-token"},"auditLog":[{"by":"ravi","action":"refund issued"}]}
fixed: 400 include not available: auditLog
asha ?include=customer
weak: {"id":"inv-7","total":1200,"customer":{"id":1,"name":"asha","email":"asha@example.com","passwordHash":"argon2id$fake","resetToken":"fake-reset-token"}}
fixed: {"id":"inv-7","total":1200,"customer":{"id":1,"name":"asha","email":"asha@example.com"}}
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node include_param.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The weak handler attaches whatever it is asked for, as stored. Asha, a customer, receives the audit log meant for
support, and her own customer record arrives with its password hash and reset token, even though the profile route
above filtered them. The fixed handler refuses the auditLog include with 400, the same answer as for a relation that
does not exist, and passes the included customer through a field allowlist of its own, as it would a record at the top
level, so only id, name and email arrive.
Filtering the top level only
A serialiser that projects the invoice and then copies invoice.customer as it is leaks every field of the customer.
Apply the same projection to every object a response contains, at every depth, and allow each relation per role.
Checkpoint
Check: Included and nested objects
A contract test for every field of every response
The allowlist protects the serialiser you remembered to change. A contract test: A test that checks the shape of a response rather than its values: here, that every key path at every depth is one the caller's role may see. protects the rest: it lists every key of a response, at every depth, and fails on any path that the role’s contract does not allow. Write the contract as an allowlist too, so a new field fails the test until someone decides who may see it:
# A contract test for field visibility: list every key of a response at every depth, and fail on any key path that
# the role's contract does not allow. An allowlist in the test too, so a new column fails until someone decides.
ALLOWED = {
"customer": {"id", "total", "customer", "customer.id", "customer.name", "customer.email"},
"support": {"id", "total", "customer", "customer.id", "customer.name", "customer.email",
"auditLog", "auditLog[].by", "auditLog[].action"},
}
def key_paths(value, path=""):
if isinstance(value, dict):
for key, inner in value.items():
here = f"{path}.{key}" if path else key
yield here
yield from key_paths(inner, here)
elif isinstance(value, list):
for inner in value:
yield from key_paths(inner, f"{path}[]")
def outside_contract(role, response):
return [p for p in key_paths(response) if p not in ALLOWED[role]]
customer = {"id": 1, "name": "asha", "email": "asha@example.com"}
secrets = {"passwordHash": "argon2id$fake", "resetToken": "fake-reset-token"} # fake values
responses = {
"weak serialiser": {"id": "inv-7", "total": 1200, "customer": customer | secrets},
"weak, after a new column": {"id": "inv-7", "total": 1200, "customer": customer | secrets | {"dateOfBirth": "x"}},
"fixed serialiser": {"id": "inv-7", "total": 1200, "customer": customer},
}
for label, response in responses.items():
failures = outside_contract("customer", response)
print(f"{label:<25} as customer: {len(failures)} key paths outside the contract {failures}") Output
weak serialiser as customer: 2 key paths outside the contract ['customer.passwordHash', 'customer.resetToken'] weak, after a new column as customer: 3 key paths outside the contract ['customer.passwordHash', 'customer.resetToken', 'customer.dateOfBirth'] fixed serialiser as customer: 0 key paths outside the contract []
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 field_contract.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Against the weak serialiser the test finds 2 key paths outside the customer’s contract, customer.passwordHash and
customer.resetToken. After a migration adds a column it finds 3, customer.dateOfBirth included, which is the moment
to decide, not after a customer notices. The fixed serialiser passes with none. Run such tests per role and per route,
from the route table, as the matrix test of the first lesson of this module does for whole routes; API3:2023’s
schema-based response validation is the same idea, enforced on every response.
Checkpoint
Check: Contract tests for fields
Practise: one function and two leaks
The service in this exercise has all three problems of this lesson: an admin export reachable at a second path, a user serialiser that returns the whole record, and an invoice that includes its customer unfiltered. Fix them in JavaScript or Python and run the sample tests, which act as four viewers:
Exercise · Medium · JavaScript, Python
Close a second path to an admin function, and two field leaks
A small service has three problems. A viewer is { name, role } (roles: customer, support, admin); a user record is { id, name, email, role, passwordHash, resetToken } and may gain new fields later.
- Function level. The user export, handler
exportUsers(Python:export_users), is reachable atGET /admin/users/exportand atGET /api/users/export, added for the mobile app. Only the first is protected.decide(viewer, method, path)must answer403to anyone but an admin at every path that runsexportUsers,200to an admin, and404for a path with no route. A route whose handler has no policy answers403. - Property level.
serializeUser(viewer, user)(Python:serialize_user) returns the whole record. Return only: forsupportandadminviewersid,name,email,role(their own record included); for a customer reading their own recordid,name,email; for anyone elseid,name. Nothing else, ever, including fields added to the record later. - Nested objects.
serializeInvoice(viewer, invoice, customer)(Python:serialize_invoice) returns{ id, total, customer }. The included customer must pass through the same rules asserializeUser.
JavaScript · Starter code · service.mjs
export const adminOnly = (viewer) => viewer?.role === 'admin';
export const anyone = () => true;
/** The routes. Two paths run the same user export. */
export const routes = [
{ method: 'GET', path: '/admin/users/export', handler: 'exportUsers', policy: adminOnly },
{ method: 'GET', path: '/api/users/export', handler: 'exportUsers' }, // added for the mobile app
{ method: 'GET', path: '/help', handler: 'help', policy: anyone },
];
/** 200, 403 or 404 for one request. */
export function decide(viewer, method, path) {
const route = routes.find((r) => r.method === method && r.path === path);
if (!route) return 404;
if (route.policy && !route.policy(viewer)) return 403;
return 200;
}
/** The fields of a user record that this viewer may see. */
export function serializeUser(viewer, user) {
return { ...user };
}
/** An invoice with its customer included. */
export function serializeInvoice(viewer, invoice, customer) {
return { id: invoice.id, total: invoice.total, customer };
} The sample tests · service.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { decide, serializeInvoice, serializeUser } from './service.mjs';
const asha = { name: 'asha', role: 'customer' };
const kiran = { name: 'kiran', role: 'customer' };
const ravi = { name: 'ravi', role: 'support' };
const nisha = { name: 'nisha', role: 'admin' };
const record = () => ({ id: 1, name: 'asha', email: 'asha@example.com', role: 'customer', passwordHash: 'fake-hash', resetToken: 'fake-token' });
test('both paths to the user export refuse everyone but an admin', () => {
assert.equal(decide(asha, 'GET', '/admin/users/export'), 403);
assert.equal(decide(asha, 'GET', '/api/users/export'), 403, 'the mobile path runs the same function');
assert.equal(decide(ravi, 'GET', '/api/users/export'), 403);
assert.equal(decide(nisha, 'GET', '/api/users/export'), 200);
assert.equal(decide(asha, 'GET', '/help'), 200);
assert.equal(decide(asha, 'GET', '/nowhere'), 404);
});
test('each viewer gets exactly the fields on their list', () => {
assert.deepEqual(serializeUser(asha, record()), { id: 1, name: 'asha', email: 'asha@example.com' });
assert.deepEqual(serializeUser(kiran, record()), { id: 1, name: 'asha' });
assert.deepEqual(serializeUser(ravi, record()), { id: 1, name: 'asha', email: 'asha@example.com', role: 'customer' });
});
test('a field added to the record later never appears', () => {
const user = { ...record(), dateOfBirth: 'fake-value' };
for (const viewer of [asha, kiran, ravi, nisha]) assert.ok(!('dateOfBirth' in serializeUser(viewer, user)), viewer.name);
});
test('the customer inside an invoice passes through the same rules', () => {
const out = serializeInvoice(kiran, { id: 'inv-7', total: 1200 }, record());
assert.deepEqual(out, { id: 'inv-7', total: 1200, customer: { id: 1, name: 'asha' } });
}); Python · Starter code · service.py
def admin_only(viewer):
return viewer is not None and viewer["role"] == "admin"
def anyone(viewer):
return True
# The routes. Two paths run the same user export.
routes = [
{"method": "GET", "path": "/admin/users/export", "handler": "export_users", "policy": admin_only},
{"method": "GET", "path": "/api/users/export", "handler": "export_users"}, # added for the mobile app
{"method": "GET", "path": "/help", "handler": "help", "policy": anyone},
]
def decide(viewer, method, path):
"""200, 403 or 404 for one request."""
route = next((r for r in routes if r["method"] == method and r["path"] == path), None)
if route is None:
return 404
policy = route.get("policy")
if policy and not policy(viewer):
return 403
return 200
def serialize_user(viewer, user):
"""The fields of a user record that this viewer may see."""
return dict(user)
def serialize_invoice(viewer, invoice, customer):
"""An invoice with its customer included."""
return {"id": invoice["id"], "total": invoice["total"], "customer": customer} The sample tests · test_service.py
from service import decide, serialize_invoice, serialize_user
asha = {"name": "asha", "role": "customer"}
kiran = {"name": "kiran", "role": "customer"}
ravi = {"name": "ravi", "role": "support"}
nisha = {"name": "nisha", "role": "admin"}
def record():
return {"id": 1, "name": "asha", "email": "asha@example.com", "role": "customer", "passwordHash": "fake-hash", "resetToken": "fake-token"}
def test_both_paths_to_the_user_export_refuse_everyone_but_an_admin():
assert decide(asha, "GET", "/admin/users/export") == 403
assert decide(asha, "GET", "/api/users/export") == 403, "the mobile path runs the same function"
assert decide(ravi, "GET", "/api/users/export") == 403
assert decide(nisha, "GET", "/api/users/export") == 200
assert decide(asha, "GET", "/help") == 200
assert decide(asha, "GET", "/nowhere") == 404
def test_each_viewer_gets_exactly_the_fields_on_their_list():
assert serialize_user(asha, record()) == {"id": 1, "name": "asha", "email": "asha@example.com"}
assert serialize_user(kiran, record()) == {"id": 1, "name": "asha"}
assert serialize_user(ravi, record()) == {"id": 1, "name": "asha", "email": "asha@example.com", "role": "customer"}
def test_a_field_added_to_the_record_later_never_appears():
user = record() | {"dateOfBirth": "fake-value"}
for viewer in [asha, kiran, ravi, nisha]:
assert "dateOfBirth" not in serialize_user(viewer, user), viewer["name"]
def test_the_customer_inside_an_invoice_passes_through_the_same_rules():
out = serialize_invoice(kiran, {"id": "inv-7", "total": 1200}, record())
assert out == {"id": "inv-7", "total": 1200, "customer": {"id": 1, "name": "asha"}} A hint
Keep the export's policy with its handler rather than with one of its paths: a small map from handler name to policy, read by decide, protects every path that runs the handler. For the fields, write the three lists of allowed names and build the result from the list, so a field that is on no list can never be copied.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS, Pyodide): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: JavaScript (about 0.6 MB) or Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check yourself, one question at a time:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is the difference between object-level and function-level authorisation?
Object-level authorisation decides whether this caller may use this particular record: may Ravi read note 5? It is checked wherever an id from the request selects a record, and its failure is BOLA, API1:2023. Function-level authorisation decides whether this caller may run this function at all, whatever the record: may a customer export all users, or change someone’s role? It is checked on every path and method that reaches the function, and its failure is BFLA, API5:2023. A route usually needs both: an admin may run the refund function, but only on refunds of their own tenant. Property-level authorisation, API3:2023, then decides which fields of the permitted record the caller sees or changes.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A migration adds a kycDocumentUrl column to the users table. A week later a customer reports that another user's profile in the API shows a link to that user's identity document. Walk through how this happened, and how you make sure that no future column can leak the same way.
-
Senior to staff
Product wants support staff to be able to act as a customer to debug their account. How do you design this impersonation feature so that it cannot be misused quietly, and what can an impersonating agent never do?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why does an admin function need its policy attached to the function and not to one path?
The same function can be mounted at several addresses. A guard on /admin/users/export left /api/users/export open, and Asha got every user's record. Attach the policy to the function or its route group so that every path, including the next one someone adds, inherits it. This is BFLA, API5:2023.
-
Why are a hidden Admin menu, an unlisted admin address and an isAdmin flag in the browser not access controls?
Each changes only what a client shows or knows, and every client is in the user's hands. A single-page app ships its route table to every visitor, and a flag in local storage can be switched on. The functions behind need a server-side policy; the address may be public.
-
Why must a policy run on the route the router matched and not on the raw request string?
Routers accept more than a guard's list: Express treats /Admin/users and a trailing slash as the same route by default, runs GET routes for HEAD, and method-override can turn a POST into a DELETE. In the lesson only the exact string was refused; a policy on the matched route refused all four requests.
-
What is broken object property level authorization (API3:2023), and what is the fix for responses?
A permitted request that gets back, or may set, fields it should not: serialising the database row sends password hashes, reset tokens and internal flags. Build each response from an output allowlist of the fields this caller may see. ASVS 5.0.0 asks for field-level checks in 8.2.3 and for returning only the fields needed in 15.3.1.
-
What happens to a response built from a denylist when a migration adds a column?
The new column is sent by default. In the lesson the raw row sent Kiran all 10 of Asha's fields, the denylist 7, and each sent one more after the migration, while the allowlist sent exactly 2 (id and name) before and after.
-
What extra check do related records requested with ?include= or a GraphQL query need?
Each included object is another response and needs both checks again: may this caller include this relation at all, and which of its fields may they see. A serialiser that projects the invoice but copies invoice.customer whole leaks every field of the customer. Refuse a forbidden include like an unknown one, with 400.
-
What is a contract test for responses, and why write the contract as an allowlist?
It walks every key of a response, at every depth, per role and route, and fails on any path the role's contract does not allow. With an allowlist contract a new field fails the test until someone decides who may see it; the lesson's test found customer.passwordHash and customer.resetToken, and customer.dateOfBirth after a migration.
Key takeaways
- A privileged function needs a policy attached to the function or its route group, so that every path to it, including one added later, inherits the check.
- Hidden buttons, unlisted addresses and flags in the browser change what a client shows, never what the server answers; only a server-side policy decides.
- Decide on the route the router will run, after it has resolved case, trailing slashes,
HEADand method overrides, not on the raw request. - Build every response from an allowlist of the fields its caller may see, at every level of nesting, so a new column never leaks by default.
- Contract tests that walk every key of every response, per role, fail on the first field nobody decided about.
Terms in this lesson
- broken function level authorization (BFLA)
- Risk API5:2023 of the OWASP API Security Top 10: a caller can run a function meant for another group, such as an admin export, because the function does not check the caller's role.
- broken object property level authorization (BOPLA)
- Risk API3:2023 of the OWASP API Security Top 10: an API returns, or lets a caller change, properties of an object that this caller may not read or set.
- contract test
- A test that checks the shape of a response rather than its values: here, that every key path at every depth is one the caller's role may see.
- forced browsing
- Requesting a page or an API address directly, by typing or guessing it, instead of following a link the application showed. A protected function must refuse such requests by itself.
- output allowlist
- The list of fields a response may contain for a given kind of caller. The response is built from the list, so a field nobody listed, including a new column, is never sent.
References
- A01:2025 Broken Access Control (OWASP Foundation)
- API5:2023 Broken Function Level Authorization (OWASP Foundation (API Security Project))
- API3:2023 Broken Object Property Level Authorization (OWASP Foundation (API Security Project))
- ASVS 5.0.0, V8 Authorization (8.2.1 and 8.2.3) (OWASP Foundation)
- ASVS 5.0.0, V15 Secure Coding and Architecture (15.3.1 and 15.3.3) (OWASP Foundation)
- Authorization Cheat Sheet (OWASP Foundation)
- Express 5.x API: application settings and app.METHOD() (Express (OpenJS Foundation))
- method-override middleware (Express (OpenJS Foundation))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress