AI & Security / Guide
Broken Access Control in AI-Written PHP: Make the Check Impossible to Forget
Broken access control is the first entry in the OWASP Top 10, in the 2021 edition and again in 2025. It rarely looks like a bug. It looks like nothing: a route with no permission check, a query with no owner filter. Coding agents make that kind of mistake easily, because the check usually lives somewhere other than the code they are writing. This guide shows how Semitexa moves the access decision onto the route itself, so that a missing check stops being an empty space and becomes a word you can read in the diff.
The short answer
Broken access control means someone can call what they should not. An endpoint any visitor can reach when only admins should, or a record of one customer that another customer can read by changing an id in the URL (IDOR, also called BOLA in API security).
AI agents miss these checks because a missing line does not show up anywhere. The agent writes the route it was asked for; the middleware group, the policy call or the owner filter it did not write leaves no trace in the diff for a reviewer to question.
In Semitexa a route cannot exist without an answer to "who may call it". The attribute that turns a class into an endpoint is the attribute that names its access class: public, protected or service. Contradictory combinations are refused, lint:access checks every route before it serves anyone, and tenant-scoped data refuses to be read without a tenant.
Why agents forget the check
In most PHP applications the route, the authentication and the permission check are three separate decisions in three places. A route file lists the URL, a middleware group around it requires a login, and a policy call inside the controller checks the role. All three are good tools. The weak point is that the second and the third are opt-in for every new route.
Here is the typical shape of the mistake. The example is illustrative, not taken from a real project:
// routes.php
$router->group(['middleware' => ['auth', 'role:admin']], function ($r) {
$r->get('/admin/users', [UserController::class, 'index']);
});
// added later, at the end of the file, for "export users as CSV"
$router->get('/admin/users/export', [UserController::class, 'export']);
The new route works, its test passes, and the CSV of every user is open to anyone who guesses the URL. An agent produces this for ordinary reasons: it appends where appending is easy, it copies the nearest example, and nothing in the request told it the group existed. A reviewer then reads a diff of two lines that are both correct. The problem is the line that is not there, and a diff cannot show what is not there.
This is the same failure we wrote about in cognitive surrender: a change that looks right is easy to approve. The fix is not a sterner prompt. It is a design where the absence of a decision is impossible or loud.
Two kinds of missing check
Access control fails at two levels, and it helps to keep them apart, because the defences differ:
| Level | The question | Typical failure |
|---|---|---|
| Function level | May this caller use this endpoint at all? | An admin route without the admin check; a signed webhook route that also accepts a user session. |
| Object level (IDOR, BOLA) | May this caller touch this particular record? | /orders/42 returns order 42 to whoever asks, including another customer or another tenant. |
Semitexa closes the first level by construction and the cross-tenant part of the second level in the ORM. Ownership inside one tenant (is order 42 yours?) is still application code, and the limits section says so plainly.
The route attribute is the access decision
A Semitexa endpoint is a payload class: it describes the request, and a handler processes it (From URL to HTML follows one through the whole pipeline). The class becomes a route through an attribute, and there are exactly three such attributes. Each one is an access class:
| Access class | Attribute and who may call it | May add |
|---|---|---|
| Public | #[AsPublicPayload]Anyone, with no authentication. | Nothing. A grant on a public route is refused. |
| Protected | #[AsProtectedPayload]An authenticated user: session, admin or customer. | #[RequiresCapability]#[RequiresPermission] |
| Service | #[AsServicePayload]A machine: signed webhook, machine token, partner API. | #[RequiresCapability] only. Permissions belong to users. |
There is no fourth, neutral way to declare a route. The path, the methods and the response are arguments of the same attribute that sets the access class, so writing the route means writing the decision:
use Semitexa\Authorization\Attribute\AsProtectedPayload;
use Semitexa\Authorization\Attribute\RequiresPermission;
#[RequiresPermission('users.manage')]
#[AsProtectedPayload(
path: '/admin/users',
methods: ['GET'],
responseWith: UserListResource::class,
)]
final class UserListPayload
{
}
That changes what an agent and a reviewer see. The agent cannot add an endpoint without choosing one of three words, and the word sits on the class it creates, not in a file it did not open. The reviewer sees #[AsPublicPayload] in the diff and can ask "should this really be public?", which is a question about a line that exists. And the whole public surface of an application is one search away:
grep -rn "#\[AsPublicPayload" src/
The rules between the attributes are strict. A payload must carry exactly one access attribute across its whole class hierarchy; two are an error, not an override. #[AsPublicPayload] with a capability or a permission is refused, because a public route has no principal to check them against. #[AsServicePayload] with #[RequiresPermission] is refused, because a machine is authorized by capabilities, never by a user's permissions. Several #[RequiresPermission] on one payload are combined with AND. The authorization attribute reference lists every signature.
One distinction the docs insist on is worth repeating: publicly reachable is not public. A webhook receiver is reachable from the whole internet, but it is authenticated by a signature, so it is a service payload. Declaring it public would turn off the check that makes it safe.
Inside Semitexa: what runs before your handler
The attributes are read by two pieces of the request pipeline in semitexa/authorization, a package every Semitexa project gets. Both run before the handler, so a handler never sees a request it should not have received.
PreHydrationAuthGateruns before the request body is parsed. It validates the payload's access metadata and rejects a guest on a protected route straight away. It exists because a security audit of the pipeline found that the body used to be hydrated and validated before authorization, which let anonymous clients make the server do that work on every protected route.AuthorizationListenerthen evaluates the full policy. On a non-public route authentication is mandatory. A token from the wrong domain is rejected: a user session does not open a service route, and a machine token does not open a user route. Capabilities are checked first, then permissions.
Every outcome has a defined answer. A missing login is a 401, a missing capability or permission is a 403, and contradictory metadata is a 500 that names the class, because that is a developer error and should look like one. Denials are sent to an audit listener, and a failing audit never replaces the denial itself.
The listener also fails closed. If a project has no authorizer registered, nothing can evaluate a capability or a permission. In that case a route that declares one does not quietly let every signed-in user through: it answers 403 with the message "This route requires capabilities or permissions, but no authorizer is registered to evaluate them."
Who holds which permission is the application's data. semitexa/rbac resolves roles and grants for a subject and caches the decision for the request; the application supplies where role assignments are stored.
The lesson behind lint:access
For a while our documentation said the access rules were enforced when the server boots. In a review on October 16, 2026 we found that the code enforced them on a route's first request. The difference matters: a route with contradictory attributes would pass every test that did not call it, ship, and answer 500 to its first real caller in production.
We did not move the check into boot. We added a command that runs the same rules over every discovered route ahead of time:
$ bin/semitexa lint:access
lint:access → 162 route payload(s), every one declares valid access metadata.
That is the real output for the project that serves this site. A violation prints the route path, the payload class and the reason, and the command exits with an error. It also fails when it finds no routes at all, because "nothing checked" is not the same as "everything fine". bin/semitexa ai:verify, the command an agent runs after each change, schedules lint:access whenever a payload file is touched and prints the reason the lint exists when it fails. An agent that adds a route gets the verdict in the same loop as its tests, which is what harness engineering means in practice: the rule is a sensor, not a sentence in the prompt.
Wrong tenant, right role
Function-level checks do not stop object-level leaks. A clerk of one company is allowed to open the orders screen; the question is whose orders it shows. In a multi-tenant application the worst version of IDOR is crossing the tenant line, and that part Semitexa handles in the ORM rather than in each handler.
A resource model marked #[TenantScoped] names its tenant column, and from then on the ORM treats the tenant as part of every read and write:
#[FromTable(name: 'orders')]
#[TenantScoped(column: 'tenantId')]
final readonly class OrderResourceModel
{
public function __construct(
#[PrimaryKey(strategy: 'uuid')]
#[Column(type: MySqlType::Varchar, length: 36)]
public string $id,
#[Column(name: 'tenant_id', type: MySqlType::Varchar, length: 64)]
public string $tenantId,
) {}
}
- Reads get a
tenant_id = :tenant_scopecondition, and the condition follows eager-loaded relations, each with its own tenant column. - A query with no tenant context does not fall back to "all rows". It throws: "Query for tenant-scoped resource model … requires tenant context. Call forTenant() or withoutTenantScope()."
- A write to a row of another tenant throws
TenantScopeViolationExceptioninstead of updating it. - Reading across tenants on purpose, for a report or a migration, needs
withoutTenantScope()with aSystemScopeToken. It is not a secret, but it is explicit and easy to find in a review.
Live pages get one more guard. A resource that feeds a live view over SSE without a tenant scope would stream the same rows to every tenant, so ai:verify requires every live-bound resource to declare its posture: #[TenantScoped], or #[TenantExempt] with a reason in words. An exemption nobody can justify in one sentence is a leak waiting for real data. The tenant also has to travel with background work, which The Tenant Must Travel with the Work covers.
What this does not do
It does not decide whether order 42 belongs to the user who asked for it. Inside one tenant, ownership is a business rule, and the handler or the domain model has to check it. Semitexa makes the route's access class and the tenant boundary impossible to skip; it does not know your ownership model.
It does not choose the right access class for you. An agent can still write #[AsPublicPayload] on an admin export. The difference is that the mistake is now a visible word in the diff and a line in a search, not an absence.
The metadata rules run on a route's first request. lint:access and ai:verify catch them earlier, but only if they run, so keep them in the loop your agent and your CI actually use. And SystemScopeToken is a marker, not a lock: any code can issue one, so treat every withoutTenantScope() in a diff as a question for the reviewer.
Common questions
What is broken access control?
A failure to enforce who may do what. Users can reach functions or data outside their role: an admin page without an admin check, another user's record by id, or a service endpoint that accepts a user session. It is the first category of the OWASP Top 10.
What is the difference between IDOR and broken access control?
IDOR (insecure direct object reference), called BOLA (broken object level authorization) in API security, is one kind of broken access control: the caller may use the endpoint, but not on that particular object. Broken access control also covers function-level failures, where the caller should not reach the endpoint at all.
Why do AI coding agents introduce access control bugs?
Because the check usually lives in a different place from the code they write, and a missing check produces no error, no failing test and no visible line in the diff. Agents append routes where it is easy and copy the nearest example; reviewers approve what they see.
How do I prevent broken access control in PHP?
Deny by default, put the access decision next to the route rather than in a separate group, check object ownership in the domain, scope multi-tenant queries in the data layer, and add an automated check that fails when a route's access rules are missing or contradictory. Run that check in the same loop as your tests.
Is deny by default enough?
It closes the function level. It does not tell you whether a signed-in user may touch a particular record, so object-level checks and tenant scoping are still needed.
Why a convincing patch is easy to approve, and how to keep your judgment: Your AI Agent Sounds Right. Do You Know Why?