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:
| Variant | The access control attack: what the caller changes | What they get | The 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’s | That customer’s invoice, file or record | An object check: does this record belong to the caller |
| Horizontal privilege escalation | Another user’s account or profile id, at the same role | A peer’s data or settings | The same object check, on user records |
| Vertical privilege escalation (OWASP: “Elevation of privilege”) | A direct call to an admin function from a member account | The admin action runs | A role check inside the handler |
| Force browsing | An unlinked URL typed into the address bar | A page or route that still answers | A signed-in check and a role check on the route itself |
| Metadata manipulation | A role or plan value in a cookie, hidden field, token or request body | Admin or paid access the server never granted | The server reads role and plan from its own records |
| Misconfigured access rights | Nothing at all: a storage rule or bucket policy already lets the request through | Files or rows open to anyone who asks | A 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 check | The question it asks | A builder-app example |
|---|---|---|
| Role check | Does the caller’s role allow this action? | Only an admin may run the refund action |
| Ownership check | Does this record belong to the caller or their workspace? | A member reads invoices whose workspace_id is theirs |
| Attribute or relationship check | Do 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 did | What a direct request does | What the customer sees |
|---|---|---|
| Hid the admin button from members | Calls the admin endpoint from a member account, or with no session, and it runs | Records changed by someone who was never an admin |
| Put the paywall in the browser only | Calls the paid feature’s endpoint from a free account | Paid features used by accounts that never paid |
| Put the record id in the URL | Changes the id and receives another customer’s row | One customer’s data on another customer’s screen |
| Checked the admin role in the layout, not the handler | Calls the handler directly, so the layout never runs | An 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 point | How to list them | Who may call | Which object check |
|---|---|---|---|
| Page and API routes | Next.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 page | Signed-in users, except pages meant to be public | The record’s owner or workspace matches the caller |
| Server actions | Next.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 endpoints | Signed-in users with the role the action needs | The record the action changes belongs to the caller |
| Edge or serverless functions | Supabase: 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 overview | A signed-in user, or a provider for a webhook | Same as routes, once the function reads customer data |
| Database RPC functions | Supabase 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 page | Only the roles granted execute | The query filters by the caller, never by an id the caller passes |
| Storage buckets | Supabase Storage access control: RLS policies on the storage.objects table, while public buckets are already publicly accessible; Firebase: Security Rules for Cloud Storage | The file’s owner, unless the bucket is meant to be public | The file’s path or folder matches the caller |
| Webhooks | Next.js: a route handler can receive them, so they sit with the route files | The provider, proved by its signature, never a user | The 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:
| Approach | Where the rule lives | Fits when | Example |
|---|---|---|---|
| Framework policy class | A class per model in the app’s code: Laravel’s app/Policies, Pundit’s suggested app/policies | A handful of roles, one tenant per row | Laravel: return $user->id === $post->user_id; behind Gate::authorize('update', $post); Pundit: authorize @post |
| Policy engine | Policy 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 app | Past about five roles, per-tenant rules, or a need to log every decision | OPA policies in Rego; Casbin policies in the { subject, object, action } form |
| Database row policies | Inside the database: Postgres RLS policies; Firebase Security Rules on database paths | The browser or a client SDK talks to the database directly | A 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.”
- 01 For every row of the inventory, call the entry point directly, never through the UI
- 02 Call it as an anonymous caller with no session, and expect a refusal and no data
- 03 Call it signed in as another customer, aimed at a record that customer does not own, and expect a refusal and no data
- 04 Call it as a lower role, such as a member on an admin action, and expect a refusal with nothing changed
- 05 Run the plan gates through the same loop: a free account calling a paid feature is one more lower caller
- 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.
The checks in this guide show you where the app is open. The sprint below closes those gaps, tests the result and writes the evidence down.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase