{"title":"Test API Authorization Beyond Happy-Path Tokens","excerpt":"A valid token answers who is calling and nothing else. This guide separates the object, property and function questions the way OWASP's 2023 edition does, reads the security declarations most OpenAPI descriptions get wrong, and builds a deny-by-default grid whose denials are proved by comparison against a fabricated ID and whose rejected writes are read back by an identity that can actually see the field.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/api-authorization-testing-object-function-property","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["api-contracts","api-security","authorization","owasp","openapi","playwright"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-15T13:30:00.000Z","dateModified":"2026-09-07T11:52:05.514Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/api-authorization-testing-object-function-property.webp","alt":"Dark technical illustration reading left to right. A small green panel on the left holds one bright pill above two grey ones, and a line leaves it for a row of four dashed vertical gates, each containing a ringed node — the first node larger than the other three. Green arrows carry the path from gate to gate. From the second gate an orange line curves down to a crossed-out circle near the bottom of the frame, from the third an orange line curves up to a crossed-out circle near the top, and from the fourth another curves down to a third crossed-out circle. Past the last gate a green arrow reaches a panel holding a four-by-five lattice of small rounded cells: three are lit bright green and the remaining seventeen are dim grey."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> A token answers one question — who is calling. Three more decide whether the request is allowed: which object, which property, and which function. Build a grid keyed on all four, plus workflow state where it changes the verdict, and default every cell to deny so that permitted access is the exception you have to justify. Prove a denial by comparing the response to the one a nonexistent object produces, and prove a rejected write by reading the field back as an identity that can actually see it.</div>\n\n<p>Read what your authorization tests actually assert. If each one acquires a token, calls an endpoint and expects 200, with a partner case expecting 401 when the token is absent, then the suite covers authentication and has authorization in its file names.</p>\n\n<p>Nothing in that shape can see the two failures that matter most. An invoice PDF fetched by any logged-in account that guesses the ID, and a customer setting <code>creditLimit</code> on their own account by including it in an ordinary profile update, both arrive carrying a valid token belonging to a real user. Every assertion in that suite calls them authorized, because every assertion only ever asked who was calling.</p>\n\n<h2 id=\"four-questions-one-request-must-answer\">Four questions a single authorized request has to answer</h2>\n\n<p>OWASP's 2023 edition splits these into separate categories because they fail independently, and the boundaries are worth stating precisely rather than approximately.</p>\n\n<p><strong>Who is calling</strong> is authentication. A missing or expired token is the only thing a no-token test covers.</p>\n\n<p><strong>Which object</strong> is object-level authorization, API1:2023. The category text is explicit that the caller is <em>supposed</em> to be able to reach the endpoint: \"it's by design that the user will have access to the vulnerable API endpoint/function. The violation happens at the object level, by manipulating the ID.\" It also warns that comparing the session user ID against the ID in the request \"isn't a sufficient solution\", because it only covers the case where the object belongs directly to the caller.</p>\n\n<p><strong>Which function</strong> is API5:2023, and OWASP draws the line for you: if an attacker reaches an endpoint or function they should not have access to at all, that is broken function-level authorization rather than object-level. The category names two probes worth stealing directly — whether a user can perform a sensitive action by changing only the HTTP method, and whether an administrative function can be reached by guessing a URL. It also warns against assuming an endpoint is administrative from its path, since admin operations frequently live under the same prefix as ordinary ones.</p>\n\n<p><strong>Which property</strong> is API3:2023, which merged two categories that used to be separate: excessive data exposure on the read side, where a response carries properties this caller should not see, and mass assignment on the write side, where a caller sets a property they should not control. One grid cell per operation cannot hold both, because they need different evidence.</p>\n\n<h2 id=\"the-description-names-untested-cells\">The description already names cells you have not tested</h2>\n\n<p>Before generating anything, read the <code>security</code> declarations in the OpenAPI description. Three of its rules are routinely misread, and each produces a real gap.</p>\n\n<p>The first is that the two ways of writing \"optional\" mean different things. An empty Security Requirement Object, <code>{}</code>, indicates anonymous access is supported, so <code>security: [{}, {oauth: [...]}]</code> means authenticated or anonymous. An empty <em>array</em> on an operation removes the top-level declaration entirely, so <code>security: []</code> means no scheme applies at all. One is a deliberate public endpoint; the other is usually a leftover from local development.</p>\n\n<p>The second is that nesting decides AND against OR. Multiple schemes inside one Security Requirement Object must all be satisfied. Multiple Security Requirement Objects in the array are alternatives, and only one needs to be satisfied to authorize the request.</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\">/v1/accounts/{id}/credit-limit:\n  patch:\n    security:\n      # OR: either entry alone authorizes the request. The API key path\n      # reaches this operation without the admin scope ever being checked.\n      - oauth2: [accounts:admin]\n      - internalApiKey: []\n/v1/invoices/{id}:\n  get:\n    security: []        # removes the root declaration: no scheme at all\n/v1/status:\n  get:\n    security:\n      - {}              # anonymous access is supported, alongside the root\n    </code></pre>\n\n<p>The third is that role names carry no weight outside OAuth. For an <code>oauth2</code> or <code>openIdConnect</code> scheme the array holds required scope names; for other scheme types the specification says the array may contain role names \"which are required for the execution, but are not otherwise defined or exchanged in-band\". A description showing <code>internalApiKey: [admin]</code> documents an intention, and nothing in the request conveys it. Every operation of that shape needs a test, because the description will not fail a validator.</p>\n\n<p>One caveat on using the description as the inventory: OAS permits security filtering, where a served description omits paths or operations the reader cannot access. The copy you fetched may be smaller than the API. Treat it as a lower bound and reconcile it against the router, which is the same reason OWASP lists inventory management as its own category.</p>\n\n<h2 id=\"generate-the-grid-and-default-it-to-deny\">Generate the grid, then default every cell to deny</h2>\n\n<p>The grid's key is the four questions plus state, and the default matters more than the contents: if unlisted cells are treated as untested, the grid grows quietly stale. Make an unlisted cell mean <em>deny</em>, so adding an operation immediately produces failing allow-tests that somebody has to justify.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">// contracts/authorization-grid.ts\ntype Verdict = 'allow' | 'deny';\n\n// Actors are relationships to the object, not job titles. \"Owner\" and\n// \"same-tenant colleague\" differ in the code path; \"manager\" and \"lead\"\n// usually do not.\nexport const actors = ['owner', 'sameTenantOther', 'otherTenant', 'tenantAdmin', 'anonymous'] as const;\n\n// Only the cells that are ALLOWED are listed. Everything else denies.\nexport const allowed: Record&lt;string, readonly (typeof actors)[number][]&gt; = {\n  'GET    /v1/invoices/{id}':                 ['owner', 'tenantAdmin'],\n  'PATCH  /v1/invoices/{id}':                 ['owner'],\n  'POST   /v1/invoices/{id}/approve':         ['tenantAdmin'],\n  'DELETE /v1/invoices/{id}':                 ['tenantAdmin'],\n  'GET    /v1/accounts/{id}/credit-limit':    ['tenantAdmin'],\n  'PATCH  /v1/accounts/{id}/credit-limit':    ['tenantAdmin'],\n};\n\nexport const verdictFor = (cell: string, actor: string): Verdict =&gt;\n  (allowed[cell] ?? []).includes(actor as never) ? 'allow' : 'deny';</code></pre>\n\n<p>Two details keep this from exploding. Actors are defined by their relationship to the object rather than by role name, because that is what the handler branches on — an API with nine job titles usually has four distinct code paths. And one representative object per relationship is enough; a second invoice owned by the same user exercises no new branch.</p>\n\n<p>Note what the <code>DELETE</code> row buys you. It is the method-swap probe OWASP describes, and it is the row people leave out because no client sends it. An endpoint whose GET is correctly guarded and whose DELETE was never wired to a check will pass every test written from the client's behaviour.</p>\n\n<h2 id=\"a-denial-is-proven-by-what-is-not-said\">A denial is proven by what the response does not say</h2>\n\n<p>Asserting a status code is not enough for a deny cell, because the status itself can leak. RFC 9110 permits an origin server that wants to hide a forbidden resource to answer 404 instead of 403, which means 403 on a real ID and 404 on a fabricated one together confirm which IDs exist. The assertion that holds is equality between the two responses.</p>\n\n<p>Three codes with three different meanings are in play, and one of them carries a requirement almost every API violates. A 401 says the request lacks valid credentials for the target resource, and RFC 9110 states that a server generating 401 <strong>MUST</strong> send a <code>WWW-Authenticate</code> header containing at least one challenge. A 403 says the server understood and refuses. A 404 says no representation was found, or the server is unwilling to disclose that one exists.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">import { test, expect, type APIRequestContext } from '@playwright/test';\nimport { actors, verdictFor } from '../contracts/authorization-grid';\n\nasync function assertDenied(\n  ctx: APIRequestContext,\n  send: (c: APIRequestContext) =&gt; Promise&lt;import('@playwright/test').APIResponse&gt;,\n  control: (c: APIRequestContext) =&gt; Promise&lt;import('@playwright/test').APIResponse&gt;,\n) {\n  const [actual, fabricated] = await Promise.all([send(ctx), control(ctx)]);\n\n  expect(actual.status()).toBe(fabricated.status());\n  // Identical bodies: a message naming the owner, the tenant, or the reason\n  // discloses the object's existence even when both statuses match.\n  expect(await actual.text()).toBe(await fabricated.text());\n\n  if (actual.status() === 401) {\n    // RFC 9110 15.5.2 makes this mandatory, and it is usually missing.\n    expect(actual.headers()['www-authenticate']).toBeDefined();\n  }\n  expect([401, 403, 404]).toContain(actual.status());\n}</code></pre>\n\n<p>Response timing deserves a mention and not a test. A lookup that returns quickly for an unknown ID and slowly for a forbidden one is a real disclosure channel, and it is also the assertion most likely to flake in CI. Measure it during a security review with enough samples to mean something, and keep it out of the suite that gates merges.</p>\n\n<h2 id=\"property-cells-need-two-probes\">Property cells need a write probe as well as a read probe</h2>\n\n<p>The read side is the easy half: fetch the object as each actor and assert that restricted properties are absent rather than empty. A <code>null</code> in the response tells the caller the field exists and that they cannot see it, which is a smaller leak than the value but a leak all the same.</p>\n\n<p>The write side is where suites go wrong, because the obvious assertion is invalid. Sending <code>{\"creditLimit\": 500000}</code> as an ordinary user and asserting the response does not echo <code>creditLimit</code> proves nothing: the field is filtered out of that user's view by the read rule, whether or not the write landed. The value has to be read back by an identity permitted to see it.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test('a customer cannot raise their own credit limit', async ({ playwright }) =&gt; {\n  const customer = await playwright.request.newContext({ extraHTTPHeaders: tokenFor('owner') });\n  const admin = await playwright.request.newContext({ extraHTTPHeaders: tokenFor('tenantAdmin') });\n\n  const before = await (await admin.get(`/v1/accounts/${accountId}/credit-limit`)).json();\n\n  const attempt = await customer.patch(`/v1/accounts/${accountId}`, {\n    data: { displayName: 'Ravi K', creditLimit: 500_000 },\n  });\n\n  // Either answer can be correct: reject the request, or accept it and ignore\n  // the field. What is never correct is the value changing.\n  expect([200, 403, 422]).toContain(attempt.status());\n\n  const after = await (await admin.get(`/v1/accounts/${accountId}/credit-limit`)).json();\n  expect(after.creditLimit).toBe(before.creditLimit);\n\n  // The legitimate part of the same request must still have applied, or the\n  // API is rejecting the whole update and the test proves the wrong thing.\n  const profile = await (await customer.get(`/v1/accounts/${accountId}`)).json();\n  expect(profile.displayName).toBe('Ravi K');\n});</code></pre>\n\n<p>The last assertion is the one that catches a lazy fix. Rejecting any request containing an unrecognised property makes this test pass and breaks every client that sends a field the server does not care about, so verify that the permitted half of the update still applied.</p>\n\n<h2 id=\"state-belongs-in-the-key\">State changes the answer, so state belongs in the key</h2>\n\n<p>Some cells are not constant. An invoice owner may edit a draft and may not edit the same invoice after approval; a support agent may refund an order only while it is unshipped. When that is true, the pair (actor, operation) is not a cell — (actor, operation, state) is, and a grid keyed on the pair will record whichever verdict the fixture happened to produce.</p>\n\n<p>The economical way to handle it is to expand only the operations where a stakeholder can name a state that flips the answer, and to write both halves as one test so the contrast is visible:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test('the owner may edit a draft invoice and not an approved one', async ({ request }) =&gt; {\n  const draft = await seedInvoice(request, { owner: 'owner', state: 'DRAFT' });\n  const approved = await seedInvoice(request, { owner: 'owner', state: 'APPROVED' });\n\n  expect((await request.patch(`/v1/invoices/${draft.id}`, { data: { note: 'x' } })).status()).toBe(200);\n  expect((await request.patch(`/v1/invoices/${approved.id}`, { data: { note: 'x' } })).status()).toBe(403);\n});</code></pre>\n\n<p>Watch for the code that answers 403 here for the wrong reason. An implementation that denies edits to approved invoices by checking the state and forgetting ownership will pass this test and still let a different tenant edit a draft, which is why the state expansion sits on top of the ownership grid rather than replacing it.</p>\n\n<h2 id=\"what-the-grid-costs\">What the grid costs, and where to spend less</h2>\n\n<p>Five actors across forty operations is two hundred cells, most of them denials, and running all of them on every pull request is not free. The reductions worth making are the ones that drop cells the code cannot distinguish: collapse roles that share a code path into one actor, use a single representative object per relationship, and run the full grid nightly while pull requests run only the allow cells plus the denials on operations that changed.</p>\n\n<p>The reduction not worth making is dropping deny cells because they are boring. They are the entire point; the allow cells mostly re-test the happy path other suites already cover. If pressure comes to shrink the suite, shrink the allow side.</p>\n\n<p>The honest cost is fixture weight. Every actor needs a real identity with a real relationship to a real object, and that setup is more fragile than the assertions it supports. Seeding through the API as a privileged actor keeps it truthful at the price of slower tests; seeding directly into the database is faster and will eventually drift from what the application would have created.</p>\n\n<h2 id=\"how-an-authorization-suite-goes-quiet\">Three ways an authorization suite goes quiet</h2>\n\n<h3 id=\"one-admin-identity\">Every test runs as the same privileged identity</h3>\n\n<p>A shared fixture token with broad permissions makes setup easy and makes every deny cell unreachable. The symptom is a suite where no test ever expects a 403. Grep for the expected statuses across the directory: if 403 and 404 do not appear, the grid has one actor no matter how many the file lists.</p>\n\n<h3 id=\"denied-by-the-gateway\">The denial comes from the gateway, not the service</h3>\n\n<p>An edge policy blocking the route produces a correct-looking 403 while the handler behind it has no check at all. The discriminating test is to issue the same request inside the network boundary, or against a preview environment where that policy is absent, and see whether the verdict survives. It usually does not, and the finding is worth more than the rest of the grid combined, because any path that bypasses the edge — an internal caller, a misrouted header, a new ingress — bypasses authorization entirely.</p>\n\n<h3 id=\"the-object-id-was-never-foreign\">The object under test was never really someone else's</h3>\n\n<p>Fixtures that create every object with the same seeding call often attach them all to the same tenant, so the \"other tenant\" actor is reading its own data and getting a legitimate 200. Assert the setup as well as the outcome: before the deny case runs, confirm as an administrator that the object's owner is not the actor under test. A deny test whose fixture is wrong reports the safest possible result.</p>\n\n<h2 id=\"questions-once-the-grid-exists\">Questions that come up once the grid exists</h2>\n\n<h3 id=\"faq-403-or-404\">Should a forbidden object return 403 or 404?</h3>\n\n<p>Pick one per resource type and be consistent, because consistency is what removes the signal. 404 everywhere hides existence and costs debuggability; 403 everywhere is clearer and tells a caller that an ID they guessed is real. The middle option leaks the most: 403 for objects that exist and 404 for those that do not turns any endpoint into an existence oracle. Whichever you choose, the test asserts equality with the fabricated-ID response rather than a literal code, so the suite survives the decision changing.</p>\n\n<h3 id=\"faq-scopes-enough\">We check OAuth scopes on every endpoint. Is that function-level authorization?</h3>\n\n<p>It covers the function question and none of the others. A scope says this token may call this operation; it says nothing about which invoice. It is also worth confirming the scope is enforced rather than declared — the description's scope list is documentation, and for non-OAuth schemes the specification is explicit that role names in that array are not exchanged in the request at all. Test it by calling the operation with a token deliberately issued without the scope.</p>\n\n<h3 id=\"faq-graphql\">Does this apply to a GraphQL API?</h3>\n\n<p>The four questions do; the grid key changes. Operations become root fields and mutations, and the property question gets sharper rather than softer, because the client chooses which fields to request. OWASP's own API3:2023 example is a GraphQL mutation that returns properties of a reported user the caller should not see. Key the grid on (actor, field path) for reads and on (actor, mutation, argument) for writes, and remember that a nested field may be resolved through a path that skips the parent's check.</p>\n\n<h3 id=\"faq-generated-matrix\">Can a model expand the policy into the matrix?</h3>\n\n<p>It expands the shape well and must not decide any cell. Given the actor list and the operation list it will produce the full cross product and reasonable test scaffolding, which is genuinely useful because the cross product is tedious and easy to leave incomplete. The verdict in each cell has to come from the written policy or the person who owns it. A model asked to guess will fill cells with what is conventional, and conventional is exactly how the customer got a credit limit field.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP API1:2023 — Broken Object Level Authorization</a>: the definition of object-level checks, the statement that reaching a forbidden endpoint is BFLA rather than BOLA, and the warning that comparing the session user ID against the request ID addresses only a small subset of cases</li>\n<li><a href=\"https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP API3:2023 — Broken Object Property Level Authorization</a>: the merge of the former excessive-data-exposure and mass-assignment categories, and the GraphQL example used above</li>\n<li><a href=\"https://owasp.org/API-Security/editions/2023/en/0xa5-broken-function-level-authorization/\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP API5:2023 — Broken Function Level Authorization</a>: the method-swap and guessed-URL probes, and the warning not to infer that an endpoint is administrative from its path</li>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#section-15.5.2\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110 §15.5.2, §15.5.4 and §15.5.5</a>: the requirement that a 401 response MUST carry a <code>WWW-Authenticate</code> challenge, the meaning of 403, and the permission to answer 404 instead of 403 to hide a resource's existence</li>\n<li><a href=\"https://spec.openapis.org/oas/v3.1.1.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.1.1</a>: the Security Requirement Object (§4.8.30) for AND within an object against OR across the array, <code>{}</code> meaning anonymous access, and role names in non-OAuth schemes not being exchanged in-band; the Operation Object (§4.8.10.1) for <code>security: []</code> removing the top-level declaration; and Security Filtering (§4.10) for descriptions that legitimately omit what the reader cannot access</li>\n<li><a href=\"https://playwright.dev/docs/api/class-apirequest\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — APIRequest and APIRequestContext</a>: <code>newContext</code> with per-identity <code>extraHTTPHeaders</code>, which is how each actor in the grid gets its own client</li>\n</ul>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Open the OpenAPI description and search it for <code>security: []</code> and for security arrays with more than one entry. The first list is operations with no scheme at all; the second is operations where the weakest alternative is the one that decides. Both are testable today and neither needs a grid.</p>\n\n<p>Then take the single most sensitive object in your API and write four tests for it: the same-tenant colleague reading it, the other tenant reading it, the owner calling its administrative action, and the owner setting one property they should not control. Assert the denials by equality against a fabricated ID, and read the property back as an administrator. Record three things from that run: the actor list with the code path each one exercises, the response pair behind every denial, and the identity that performed each read-back. If a deny cell was proved by a response the actor under test could have produced for a legitimate reason, it is not proved.</p>\n"}