Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · step by step Two paths, one admin function Step through Asha's requests to both paths of the user export, with the check on the /admin/ path and then on the function.
Two paths, one admin function, step 6 of 6. Showing Asha, /admin/users/export (403 to Asha), /api/users/export (403 to Asha), export_users() (policy: admin only). In focus: /admin/users/export, /api/users/export, export_users().Ashaa customer/admin/users/export403 to Asha/api/users/export403 to Ashaexport_users()policy: admin only

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

One admin function, two paths, two ways to check Python · groups/function_level.py
# 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

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

  1. Question 1 of 2 In function_level.py, why does the weak app send Asha the user export at /api/users/export?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Its only check is a rule for the /admin/ prefix, and the second path does not start with it

    A privilege belongs to the function, so its policy must travel with the function. The fixed app registers export_users with its policy, and both paths refuse Asha.

  2. Question 2 of 2 A team keeps admin rules as a list of paths in the gateway, /admin/* for admins. A new route for support, /support/users/export, calls the same export function. What should change so this cannot happen again?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Attach the policy to the export function, or to an admin route group in the app, so any path that reaches it inherits the check

    API5:2023 warns against deciding by URL path and asks that admin controllers inherit their checks from a shared base. A policy on the function or its group is the same idea.

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

  1. One question A team moves its admin panel to /ops-7f3a9/ and removes the panel's link from the menu. What has that changed for a customer who tries the address?

    Choose one answer.

    Show the answer: One question

    Answer: Nothing, once the address is known from the app's JavaScript, a log or a browser history; the functions behind it still need a policy

    Unlisted addresses and hidden menus shape what a client shows. Only a server-side policy on each admin function refuses the request, whoever knows the address.

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:

Interactive · message by message The guard reads one request, the router runs another Step through two of Asha's requests, one with a capital letter in its path and one with a method override, then the fixed order.
The guard reads one request, the router runs another, step 6 of 6. Messages so far: Asha's client to Guard: GET /Admin/users; Guard to Router: not protected; Asha's client to Guard: POST, override DELETE; Guard to Router: POST passes.Asha's clienta customerGuardnot usedRouterroute first, then policyGET /Admin/usersnot protectedPOST, override DELETEPOST passes

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 in front of the router, and a policy on the route JavaScript · methods/method_alias.mjs
// 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

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

  1. Question 1 of 2 Why did GET /Admin/users reach the admin handler in the weak app of method_alias.mjs?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The guard compared the raw string with GET /admin/users and found no match, while the router ignores case and ran the admin route

    Decide on the route the router will run, not on the request as it arrived. The fixed app matches first and then runs the matched route's policy, so every spelling gets the same answer.

  2. Question 2 of 2 A POST with X-HTTP-Method-Override: DELETE deleted user 7 in the weak app. Which change fixes the whole class of bug, not just this request?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Resolve the method and the path first, then run the policy of the route that will actually run

    Overrides, aliases and case-insensitive matching all change which route runs after a guard has looked. One rule closes them all: authorise the matched route, per method.

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:

A profile three ways, before and after a new column JavaScript · fields/users_api.mjs
// 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

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

  1. Question 1 of 2 A migration adds dateOfBirth to the users table. Which serialiser in users_api.mjs keeps it out of Kiran's response, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The allowlist, because it builds the response from the fields it lists, and dateOfBirth is on no list

    An allowlist fails closed when the table grows; a denylist fails open. That is why API3:2023 asks you to pick the properties to return rather than relying on generic serialisers.

  2. Question 2 of 2 In users_api.mjs, which fields does Ravi in support receive with the allowlist?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: id, name, email, city, role and supportNotes

    Each role gets the list its work needs. Even trusted staff never need a password hash or a reset token in a response.

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?

An invoice with its related records included JavaScript · includes/include_param.mjs
// 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

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

  1. One question The profile route filters Asha's record, yet ?include=customer on an invoice returned her password hash. Why?

    Choose one answer.

    Show the answer: One question

    Answer: The invoice handler copied the related customer as stored, without passing it through a field allowlist

    Filter every object of a response, at every depth, through the allowlist of its kind, and decide per role which relations may be included at all.

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 that walks every key of a response Python · contract/field_contract.py
# 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

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.

JSON Diff & Compare Compare a role's real response with the contract's example, key by key.

Checkpoint

Check: Contract tests for fields

  1. One question Why does field_contract.py list the allowed key paths, instead of the forbidden ones?

    Choose one answer.

    Show the answer: One question

    Answer: A new field fails the test until someone adds it to a contract, as customer.dateOfBirth did

    A denylist test passes on a field it has never heard of. An allowlist test turns every new field into a decision, made before the field reaches a customer.

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.

  1. Function level. The user export, handler exportUsers (Python: export_users), is reachable at GET /admin/users/export and at GET /api/users/export, added for the mobile app. Only the first is protected. decide(viewer, method, path) must answer 403 to anyone but an admin at every path that runs exportUsers, 200 to an admin, and 404 for a path with no route. A route whose handler has no policy answers 403.
  2. Property level. serializeUser(viewer, user) (Python: serialize_user) returns the whole record. Return only: for support and admin viewers id, name, email, role (their own record included); for a customer reading their own record id, name, email; for anyone else id, name. Nothing else, ever, including fields added to the record later.
  3. Nested objects. serializeInvoice(viewer, invoice, customer) (Python: serialize_invoice) returns { id, total, customer }. The included customer must pass through the same rules as serializeUser.

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.

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.

  1. Question 1 of 12 What is broken function level authorization (API5:2023)?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: 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

    Function level asks "may this caller run this at all?"; object level asks "on this record?"; property level asks "which fields?". A route usually needs all three.

  2. Question 2 of 12 POST /support/impersonate/:userId lets staff act as a customer. Which policy fits a function this powerful?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Its own permission, separate from the support role, checked on the function, with the reason logged on every use

    The most dangerous functions get the narrowest permission. Give impersonation its own grant, check it on the function, and log who used it, for whom and why.

  3. Question 3 of 12 What does this code protect?

    Read the code, then choose one answer.

    if (localStorage.getItem('isAdmin') === 'true') {
      showAdminMenu();
    }
    Show the answer: Question 3 of 12

    Answer: Nothing on the server; it only decides whether this browser draws the menu

    A flag in the browser is a display choice. The role that decides is the one the server reads from its own records on every request.

  4. Question 4 of 12 Why can a single-page app not keep its admin API addresses secret?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: The app's JavaScript, which every visitor downloads, contains the addresses it calls

    Assume every route of your API is known. Its policy, not its obscurity, decides who may call it.

  5. Question 5 of 12 In Express, an app registers app.get('/reports/:id', ...) and no HEAD route. What runs for HEAD /reports/7?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: The GET route's handlers, with no body sent

    This is right when the policy is attached to the route, because HEAD then gets the same check. It is a gap when a guard protects GET by comparing method names.

  6. Question 6 of 12 A guard protects the exact string "DELETE /admin/users/:id". The router ignores case and a trailing slash and applies method overrides. Which requests from a customer can reach the delete handler? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 6 of 12

    Answer:

    • POST /admin/users/7 with X-HTTP-Method-Override: DELETE
    • DELETE /Admin/users/7
    • DELETE /admin/users/7/

    Each variant is a different string for the same route. Check the route the router resolves, after the override, and every variant meets the same policy.

  7. Question 7 of 12 What is the weakness of a serialiser that removes a list of known secret fields?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: It sends any field nobody added to the list, including every column added later

    A denylist must be updated for every new field, forever. An allowlist needs a decision only when someone wants a new field shown.

  8. Question 8 of 12 What can a customer do with this profile-update handler?

    Read the code, then choose one answer.

    app.patch('/me', (req, res) => {
      Object.assign(req.user, req.body);
      save(req.user);
      res.sendStatus(204);
    });
    Show the answer: Question 8 of 12

    Answer: Set any field of their own record, role or flagged included, by sending it in the body

    Writes need an allowlist too: ASVS 5.0.0 15.3.3 asks each handler to limit which fields it accepts. Copy only the fields this route may change, such as name and email.

  9. Question 9 of 12 An API accepts ?include=customer,auditLog. Which two decisions does each include need?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Whether this caller may include the relation at all, and which of its fields they may see

    Each include is a response inside the response. Allow relations per role, and pass every included object through a field allowlist, as you would a record at the top level.

  10. Question 10 of 12 A GraphQL query asks for invoice(id: 7) { total customer { email passwordHash } }. The schema exposes passwordHash on the Customer type for an internal tool. What is the fix?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Remove passwordHash from the public schema, and authorise each field and relation in its resolver or serialiser

    GraphQL reopens property-level problems because any path to a type exposes all its fields. Keep secrets out of public types, and authorise fields and relations where they are resolved.

  11. Question 11 of 12 What does the contract test in field_contract.py check?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: That every key path of a response, at every depth, is in the role's allowed set

    Walking every key path catches leaks in nested objects and lists, such as customer.passwordHash, that a check of the top level would miss.

  12. Question 12 of 12 A team writes contract tests for the five routes it remembers. Three months later a new route leaks a field. How should the tests be organised so this cannot happen?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Generate them from the route table, per role, so a route without a contract fails the build

    Read the route table, look up each route's contract per role, and fail when one is missing, exactly as the authorisation matrix fails for a route without a row.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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, HEAD and 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.