OWASP’s Top 10:2025 keeps broken access control at number one: a server doing what a request asks without checking that the caller may. What the list leaves out is which entry points of a builder-generated app need that check. In June and July 2026 I audited 21 third-party apps, and 11 of them ran unauthenticated endpoints doing privileged work.

What is broken access control? The variants, with a builder-app example of each

Broken access control is a server answering a request without checking that the caller may make it. A route, API endpoint or server action that reads another customer’s record, or runs an admin function, because the request asked is a broken access control vulnerability. The fix is a check on the server, on every request.

Those 11 apps come from the 21 third-party apps I audited in June and July 2026. I picked them, so they are a selected set of audited apps, not a random sample, and the count is not a rate for AI-built apps in general. The server-side check this page covers is one line of the authentication checklist for an AI-built app, next to sign-in, sessions and roles.

In the OWASP Top 10, broken access control is A01:2025, and OWASP’s A01:2025 Broken Access Control page says “Access control enforces policy such that users cannot act outside of their intended permissions.” The same page reports that “100% of the applications tested were found to have some form of broken access control” and maps 40 CWEs to the category.

Broken access control examples from a builder app, one per variant, with OWASP’s own name where its list gives one:

VariantThe access control attack: what the caller changesWhat they getThe check that was missing
Insecure direct object references, called BOLA in APIs (what an IDOR vulnerability is, and the two-account test for it)The id in the URL or request body, swapped for another customer’sThat customer’s invoice, file or recordAn object check: does this record belong to the caller
Horizontal privilege escalationAnother user’s account or profile id, at the same roleA peer’s data or settingsThe same object check, on user records
Vertical privilege escalation (OWASP: “Elevation of privilege”)A direct call to an admin function from a member accountThe admin action runsA role check inside the handler
Force browsingAn unlinked URL typed into the address barA page or route that still answersA signed-in check and a role check on the route itself
Metadata manipulationA role or plan value in a cookie, hidden field, token or request bodyAdmin or paid access the server never grantedThe server reads role and plan from its own records
Misconfigured access rightsNothing at all: a storage rule or bucket policy already lets the request throughFiles or rows open to anyone who asksA rule that refuses unless it grants

The last row is the same failure written into a storage rule instead of a handler. The wider category, misconfigurations as a vulnerability class, is a separate entry in OWASP’s list, A02 Security Misconfiguration.

The rest of the numbers from the same audits: 7 of the 21 third-party apps had confirmed cross-user or cross-tenant authorization failures, where a logged-in user could read or write another customer’s data. Client trust was more common still: 10 of the 21 third-party apps trusted the client, and the server accepted whatever the browser asserted. Across the 14 third-party apps scored on it, the Authorization pillar of my scoring averages 42.1 out of 100.

Enforcing permissions on every protected route, API endpoint and server action is deliverable 1.3 of the Production Hardening Sprint.

What “authorization” means in a codebase, with worked examples

Authorization in a codebase is the check that runs after sign-in and before the query: may this user do this action to this object. Authentication proves who is calling; authorization decides what they may touch, and one never stands in for the other.

Here is an authentication and authorization example in three requests, all from a member of workspace A in an invoicing app. First, GET /invoices/:id for an invoice in workspace A: the session is valid, the invoice’s workspace_id matches the member’s, and the invoice comes back. Second, the same request with the id of an invoice in workspace B: the session is still valid, and the object check refuses, because the invoice belongs to another workspace. Third, a POST to the admin refund action: the session is valid, and the role check refuses, because the caller is a member and refunds are an admin action. Sign-in passed all three times; only the second check decided anything.

The different types of authorization people mean come down to three kinds of check. The wording in the table is mine, not a vendor’s:

Type of checkThe question it asksA builder-app example
Role checkDoes the caller’s role allow this action?Only an admin may run the refund action
Ownership checkDoes this record belong to the caller or their workspace?A member reads invoices whose workspace_id is theirs
Attribute or relationship checkDo the caller’s attributes, or their link to the record, allow it?A document opens for the people it was shared with; a paid feature opens on an active plan

A signed token proves who sent the request and says nothing about what they may do to a given record, so the check stays in the handler; JWT security: expiry, refresh rotation and revocation is about identity. The role checks read from a written matrix of who may do what; RBAC examples: the role and permission matrix is its own topic, along with the access-control models behind it. Single sign-on changes who signs in, not what they may do afterwards: OpenID Connect vs SAML when a customer asks for SSO is a choice about sign-in, and every check on this page stays in place.

What goes wrong without it

Hiding a button does not secure the API: the button lives in the browser, and the endpoint behind it answers whoever sends the request. Each of these access control attacks is one ordinary request:

What the builder didWhat a direct request doesWhat the customer sees
Hid the admin button from membersCalls the admin endpoint from a member account, or with no session, and it runsRecords changed by someone who was never an admin
Put the paywall in the browser onlyCalls the paid feature’s endpoint from a free accountPaid features used by accounts that never paid
Put the record id in the URLChanges the id and receives another customer’s rowOne customer’s data on another customer’s screen
Checked the admin role in the layout, not the handlerCalls the handler directly, so the layout never runsAn admin action that works for anyone who can reach it

The paywall row is the whole subject of your app gives away paid access for free. The record-id row, across workspaces, is multi-tenant data isolation, and the layout row belongs to the admin panel security checklist. On Next.js the layout row has a documented cause: the framework’s authentication guide says “A layout also does not control whether the rest of the route renders.”

In one app I audited, a sports-analytics app, the route table labeled a route “Protected” in a comment, and the route had no guard. The lesson I take from it: a comment that says protected is documentation, and only a check in the handler protects the route.

My reading of why this keeps happening: the gate gets built with the screen and the server check does not, so the app works for the one person who tests it.

How to enforce permissions on every route, endpoint and server action

Permissions are enforced by listing every entry point the backend has, giving each a signed-in check and an object check inside the handler, writing the rule once where every handler calls it, and testing 3 callers: anonymous, another customer and a lower role.

The object check comes first when you enforce permissions on API routes: for an object reference, the fix is a query scoped to the caller, such as where id = ? and owner_id = current_user, so another customer’s id finds nothing. MDN’s IDOR defenses call server-side access control checks for each object “the most important mitigation for IDOR attacks”, and say that more complex IDs such as UUIDs only reduce the likelihood of guessing valid IDs and do not replace the need for proper access control. Underneath both sits the deny-by-default item from OWASP’s prevention list, with public resources as the only exception.

The access control best practices that hold up on a builder app are the three below: an inventory of every entry point, one place for the rule, and the database’s own policies where the browser talks to it. Together with the verify section after them, they are the broken access control checklist I’d work through on an existing app.

The route inventory: every way a request reaches your backend

A route inventory lists every way a request reaches the backend: page and API routes, server actions, serverless functions, database RPC functions, storage buckets and webhooks. Each of those 6 kinds of entry point gets its own row and caller rule, and every one that touches customer data gets an object check.

The “how to list them” column comes from each vendor’s docs. The last two columns are my rule for a small SaaS, not a vendor’s:

Entry pointHow to list themWho may callWhich object check
Page and API routesNext.js route handlers: the route.js or route.ts files under app/, each exporting GET, POST and the other methods; listing page files is not stated in that docs pageSigned-in users, except pages meant to be publicThe record’s owner or workspace matches the caller
Server actionsNext.js: the functions under a 'use server' directive, as in the authentication guide’s example; the guide says to treat them like public-facing API endpointsSigned-in users with the role the action needsThe record the action changes belongs to the caller
Edge or serverless functionsSupabase: Edge Functions, .ts files that export a handler, deployed through the Dashboard, CLI or MCP; where a project’s list lives is not stated on Supabase’s Edge Functions overviewA signed-in user, or a provider for a webhookSame as routes, once the function reads customer data
Database RPC functionsSupabase database functions: Postgres functions the app calls with rpc(), and by default any role can run one; how to list them is not stated on that pageOnly the roles granted executeThe query filters by the caller, never by an id the caller passes
Storage bucketsSupabase Storage access control: RLS policies on the storage.objects table, while public buckets are already publicly accessible; Firebase: Security Rules for Cloud StorageThe file’s owner, unless the bucket is meant to be publicThe file’s path or folder matches the caller
WebhooksNext.js: a route handler can receive them, so they sit with the route filesThe provider, proved by its signature, never a userThe event’s account matches a record you hold

Each row needs two checks: is anyone signed in, and may this user touch this object. Middleware to check authentication answers the first question and never the second. The Next.js authentication guide, which calls that layer Proxy, says to read only the session from the cookie there for optimistic checks, and that “The majority of security checks should be performed as close as possible to your data source”. Its advice for server actions matches the table’s second row: check the user inside the action itself, with the same security care the guide asks for public-facing API endpoints.

Authorization in code: policy engines, framework policies, and keeping the rules in one place

An authorization rule lives in one of three places: a framework policy class such as a Laravel policy or a Pundit policy, a policy engine such as Open Policy Agent that the app queries, or the database’s own row policies. Wherever it lives, the rule is written once and every handler calls it.

The tool descriptions here come from each project’s own documentation; I have not moved an app onto a policy engine myself. In Laravel’s authorization docs, “Policies are classes that organize authorization logic around a particular model or resource”, generated with php artisan make:policy PostPolicy, and a controller calls Gate::authorize('update', $post), which Laravel turns into a 403 response when the user is not allowed. In Rails, Pundit works the same way: plain Ruby policy classes, which its README suggests putting in app/policies, each named after its model with a Policy suffix, and an authorize @post call in the controller.

A policy engine moves the rule out of the handler. Open Policy Agent’s documentation describes OPA as “an open source, general-purpose policy engine”, says your software “queries OPA and supplies structured data (e.g., JSON) as input”, and writes policies in a declarative language called Rego. Two Open Policy Agent alternatives cover the same ground: Cerbos describes itself as “an authorization management platform” that produces “fine-grained, contextual access decisions based on policies you define”, and Casbin’s overview calls Casbin “an efficient, open-source access control library” that supports roles (RBAC), attributes (ABAC) and other patterns.

The “fits when” column is my working rule, not a vendor’s:

ApproachWhere the rule livesFits whenExample
Framework policy classA class per model in the app’s code: Laravel’s app/Policies, Pundit’s suggested app/policiesA handful of roles, one tenant per rowLaravel: return $user->id === $post->user_id; behind Gate::authorize('update', $post); Pundit: authorize @post
Policy enginePolicy files the engine evaluates: OPA runs as a server the app queries with JSON input, or as a library inside Go programs; Casbin runs as a library inside the appPast about five roles, per-tenant rules, or a need to log every decisionOPA policies in Rego; Casbin policies in the { subject, object, action } form
Database row policiesInside the database: Postgres RLS policies; Firebase Security Rules on database pathsThe browser or a client SDK talks to the database directlyA Supabase policy comparing the row’s owner with the signed-in user

The one rule that matters more than the tool: every handler calls the same function with the user and the object, so the rule exists in one place. OWASP’s prevention list puts it as implementing access control mechanisms once and reusing them throughout the application.

Supabase and Firebase: when the database is the API

When the browser talks to the database directly, the object check is the row policy or the security rule, and a missing one is the same vulnerability under another name. In those audits, 9 of the 21 third-party apps had row-level security gaps. That count comes from the same June and July 2026 set of 21 third-party apps, which I chose, so it says nothing about Supabase projects in general.

Supabase’s row-level security guide describes it this way: “Postgres Row Level Security (RLS) gives you granular authorization rules that run inside the database.” It adds that “A table in an exposed schema without RLS is readable and writable by any role with a grant on it.” Firebase Security Rules “stand between your data and malicious users”, matching a pattern against database paths and applying conditions that allow access at those paths.

What a policy can and cannot do is set out in what a Supabase row-level security policy decides, and what it cannot. Owner-only rules and their emulator tests are in Firebase security rules examples and tests.

How to verify it: test that an unauthorized request is rejected

Server-side authorization is verified by calling every entry point directly as 3 callers, anonymous, another customer and a lower role, and expecting a refusal with no data each time. The loop runs in CI, so an entry point that loses its check fails the run, and each new route gets a row.

When you test an API endpoint as an unauthorized user, a correct build refuses in the form its kind of entry point uses, so the test accepts each form. For an API route, the refusal is a 401, 403 or 404 status with no record in the body: MDN defines 401 Unauthorized as a request that “lacks valid authentication credentials”, 403 Forbidden as one the server “understood” but “refused to process”, and notes that server owners may send a 404 instead of a 403 when acknowledging that a resource exists is not desired.

The Next.js guide’s route handler example returns 401 with no session and 403 without the role; its server action example returns null early for a caller without the role, and its session helper redirects a caller with no session to /login. A page route sends the anonymous caller to sign-in and shows the other two a not-found or refusal page with none of the object’s data; a signed-in caller is not sent to sign-in, so a check that expects that redirect for them fails a correct build. Where the database is the API, a row that a policy filters out raises no error: the read returns no rows and an update or delete matches zero rows, so Supabase’s docs say: “Matching no rows is not proof on its own. Pair every denied write with a check that the row it targeted is intact.”

  1. 01 For every row of the inventory, call the entry point directly, never through the UI
  2. 02 Call it as an anonymous caller with no session, and expect a refusal and no data
  3. 03 Call it signed in as another customer, aimed at a record that customer does not own, and expect a refusal and no data
  4. 04 Call it as a lower role, such as a member on an admin action, and expect a refusal with nothing changed
  5. 05 Run the plan gates through the same loop: a free account calling a paid feature is one more lower caller
  6. 06 Keep the evidence: the inventory with a test id per row, and the CI run with its date

Treat that list as your paywall security checklist too; the full set of paid-access checks stays in the entitlement article from the failure table above.

The browser-only first rung needs no code. In Chrome, open DevTools and the Network panel, right-click the request under the Name column, hover over Copy and choose Copy as fetch. Paste it into the console of a tab on the same app, signed in as a second account. If the copied call carries an Authorization header, swap its token for the second account’s first, or delete the header for the anonymous call; otherwise the replay still runs as the first account and proves nothing. Chrome’s Network reference does not say what the copied call includes, so read it before you paste. The manual two-account walkthrough, with what a failed response looks like, is in the API security checklist for AI-built apps; this page keeps the automated version.

The automated version is one table-driven test that loops over the inventory and the three callers:

for (const row of inventory) {                       // one row per entry point
  for (const caller of ["anonymous", "otherCustomer", "lowerRole"]) {
    const res = await callDirectly(row, caller);       // no UI, own session per caller
    expect(isRefusal(res, row.kind)).toBe(true);       // 401/403/404, redirect, early return or zero rows
    expect(res.data).toBeEmpty();                      // no record of the target in the reply
    if (row.writes) expect(await targetIntact(row)).toBe(true);  // re-read the targeted row
  }
}

That is how to unit test authorization rules without writing a separate test for each one. Kept in CI, it turns a check that goes missing from any listed entry point into a failed run. A passing run proves the inventory as written on that date: a new route is tested only once it has a row, so my rule is to add the row in the same change as the route.

A failed run stops a merge only where the branch requires the check to pass. GitHub’s docs say protected branches “are available in public repositories with GitHub Free and GitHub Free for organizations” and “in public and private repositories with GitHub Pro, GitHub Team, GitHub Enterprise Cloud, and GitHub Enterprise Server”, and once required status checks are on, all of them must pass before collaborators can merge. So a small private repository on the free plan gets the red run but no block. A host that deploys every push also ships the change whatever the run says, unless its deploy waits for the check.

Deliverable 1.3 of the sprint is verified this way: call protected actions directly as unauthorized and underprivileged users; confirm rejection.

Where the sprint does this

In the Production Hardening Sprint, deliverable 1.3 is the control on this page, delivered and verified as the first section and the verify section describe. The result is recorded in the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Formal third-party certifications and independent audit opinions are separate from these engineering deliverables. The full list is the sprint’s 123 deliverables.

Common questions about server-side authorization

How to fix broken access control?

List every entry point, add a signed-in check and an object check inside each handler, move the rule into one function that every handler calls, then test each entry point as an anonymous caller, another customer and a lower role. The fix holds when every one of those calls comes back refused with no data.

What is the best foundation to prevent broken access control?

Deny by default is the foundation, in OWASP’s words “Except for public resources, deny by default”, with the rule written once in a place every request passes through. A route whose check was forgotten then refuses instead of answering.

What are the two types of privilege escalation?

Horizontal and vertical. Horizontal privilege escalation reaches another user’s data at the same role, such as a member opening a peer’s profile by changing its id; vertical privilege escalation reaches a higher role’s functions, such as a member running an admin action. OWASP’s list names the second “Elevation of privilege”. My working rule: the object check stops the first, and the role check stops the second.

What is the difference between broken authentication and broken access control?

Broken authentication is a failure in proving who you are; broken access control is a failure in limiting what you may do once the app knows. OWASP lists them as separate categories in its 2025 list: A01 Broken Access Control and A07 Authentication Failures.

Which scenario best represents broken access control?

A signed-in member calls the admin refund action directly and it runs, or a customer changes an id in a request and receives another customer’s invoice. Both succeed because the server never asked whether that caller may.

What comes first, authorization or authentication?

Authentication comes first, then authorization, on every request. The server establishes who is calling, then decides whether that caller may do this action to this object; a valid session settles only the first question.