When we talk about website security, most developers immediately think about SQL injection, XSS, password attacks or insecure APIs.
But there is another problem that I feel many developers still underestimate.
Access control.
And this is not a small problem.
In the OWASP Top 10:2025, Broken Access Control is still ranked at number one. OWASP reported that all applications tested in its contributed data had some form of access-control issue.
As someone working with Laravel, SaaS applications, APIs and multi-tenant systems, I think this is one area where developers need to think beyond simply asking:
"Is the user logged in?"
The more important question is:
"Is this user actually allowed to access this particular data or perform this particular action?"
These two questions are completely different.
Authentication is not authorization
This is probably the first thing we need to understand.
Authentication means:
"Who are you?"
Authorization means:
"What are you allowed to do?"
Suppose I have an ERP application.
Two users successfully log in.
Both users are authenticated.
But one is an administrator and another one is an accountant.
The accountant may be allowed to view invoices.
But he may not be allowed to delete invoices.
So checking:
if (auth()->check()) {
// allow
}is not enough.
We also need to check whether that user is actually authorized for that resource and action.
Laravel already provides Gates and Policies for this type of authorization logic.
But having the framework feature is not enough.
We need to use it correctly.
The dangerous ID problem
One of the easiest ways to create an access-control vulnerability is exposing database IDs directly through URLs or APIs.
For example:
/api/invoices/1001A user requests invoice 1001.
Everything works.
Then the user changes it to:
/api/invoices/1002and suddenly another customer's invoice is returned.
This is a classic example of insecure direct object access.
The application checked:
"Is this user logged in?"
But it did not check:
"Does invoice 1002 belong to this user or this user's company?"
That second check is the important one.
This becomes much more serious in SaaS
For a normal single-company application, access control is already important.
For a multi-tenant SaaS application, it becomes critical.
Imagine Xnoll has:
Company A
company_id = 10and
Company B
company_id = 20Company A creates invoice ID 5000.
Now a request comes from Company B:
GET /api/invoices/5000If our query is simply:
Invoice::findOrFail($id);we have a problem.
The query is technically correct.
But the authorization is wrong.
A safer approach is to scope the resource to the current tenant:
Invoice::where('company_id', auth()->user()->company_id)
->findOrFail($id);Now the invoice must belong to the authenticated user's company.
This looks like a small change.
But in a SaaS application, this type of check can protect the data of thousands of customers.
Don't depend only on frontend permissions
This is another common mistake.
Suppose the frontend hides the Delete button for normal users.
That does not mean the user cannot delete the record.
A user can directly call the API.
For example:
DELETE /api/invoices/5000The frontend may hide the button.
But the backend still has to reject the request.
I always treat frontend permissions as a user-interface feature.
The backend authorization is the actual security boundary.
Laravel Policies are very useful here
For applications with many resources, I prefer keeping authorization logic in Policies instead of scattering permission conditions everywhere.
For example:
class InvoicePolicy
{
public function delete(User $user, Invoice $invoice): bool
{
return $user->company_id === $invoice->company_id
&& $user->can('delete_invoice');
}
}Then the controller can authorize the action.
Laravel's Policy system is specifically designed to organize authorization rules around resources and actions.
This makes the application easier to understand.
More importantly, it reduces the chance that one controller forgets an important authorization check.
APIs make this problem easier to miss
Modern applications are heavily API-driven.
We have:
- Web applications
- Mobile applications
- Desktop applications
- Third-party integrations
- Internal services
- Background jobs
That means the same business data can be accessed from many different entry points.
Sometimes developers protect the web route correctly but forget the API.
Sometimes the API is protected but a background job bypasses the same business rules.
This is why authorization should ideally be part of the application's business layer instead of being treated only as a controller-level problem.
Admin is not the same as unlimited access
I also don't like the idea of:
if ($user->is_admin) {
return true;
}everywhere.
An administrator may have broader permissions, but that does not mean every piece of data should automatically be accessible from every part of the application.
A better permission model can be based on:
- Role
- Permission
- Company
- Branch
- Department
- Resource ownership
- Action
For example:
A company administrator may manage users.
A branch manager may manage branch invoices.
An accountant may create invoices.
A sales employee may only view customers assigned to them.
This is much closer to how real business applications work.
Access control must exist at the data level
This is especially important in ERP and SaaS applications.
Let's say we have:
companies
users
customers
invoices
products
orders
paymentsChecking only the user role is often not enough.
We also need to understand resource ownership.
For example:
User → Company → Customer → InvoiceA user may have permission to view invoices.
But only invoices belonging to their company should be returned.
This means authorization is often a combination of:
permission + ownership + tenant scope
This is one of the biggest lessons I have learned while working on business software.
Be careful with route model binding
Laravel route model binding is very convenient.
For example:
Route::get('/invoices/{invoice}', ...);Then Laravel automatically gives us the Invoice model.
But convenience should not make us forget authorization.
Finding the model is not the same thing as proving that the current user can access it.
Something like:
public function show(Invoice $invoice)
{
$this->authorize('view', $invoice);
return $invoice;
}is much safer than assuming that because Laravel successfully found the model, the user can see it.
The same problem exists with update and delete
Developers sometimes protect GET requests but forget write operations.
For example:
GET /customers/100may be protected.
But what about:
PUT /customers/100or:
DELETE /customers/100The authorization rules must cover every sensitive action.
View.
Create.
Update.
Delete.
Export.
Download.
Share.
Approve.
Restore.
Even operations like changing status can be sensitive.
Don't forget exports
One interesting area is data export.
Suppose a user cannot directly view another company's customers.
But the application has:
Export Customersand the export query forgets the tenant condition.
Now the user cannot see the data on screen, but can download the complete dataset.
This is why security cannot be designed only around pages and buttons.
We have to think about the complete data flow.
File downloads have the same problem
Another common example is private files.
Imagine:
/storage/invoices/5000.pdfIf someone guesses:
/storage/invoices/5001.pdfthey should not automatically receive another customer's invoice.
Private file access needs authorization too.
The same applies to:
- Documents
- Profile files
- Business certificates
- Reports
- Attachments
- Backups
- Generated exports
Logs can also leak sensitive information
Sometimes we protect the database but accidentally expose sensitive information in logs.
For example, an application may log:
User 101 requested invoice 5000That may be acceptable.
But logging:
Authorization token = ...
Customer password = ...
Private API key = ...is a completely different problem.
Security is not only about blocking attackers.
It is also about controlling where sensitive information exists.
Multi-tenant applications need a strong mindset
When building SaaS, I think about tenant isolation from the beginning.
For example, every important query should have a clear answer to:
"Which company does this data belong to?"
That can mean:
->where('company_id', $companyId)or a dedicated tenant scope, repository, service, middleware or architecture that guarantees the same behavior.
The exact implementation can change.
The principle cannot.
One tenant should never be able to cross the data boundary of another tenant.
Security should not be added at the end
Many teams treat security as something they will handle before production.
I don't think this works well.
When an application becomes large, authorization logic can exist in:
- Controllers
- Policies
- Services
- Jobs
- Commands
- APIs
- Webhooks
- Exports
- Scheduled tasks
Adding security at the end becomes expensive.
It is much better to design resource ownership and permission rules while designing the module itself.
What I would check in a Laravel SaaS application
Whenever I build or review a module, I would ask questions like:
Can this user access another user's record?
Can this user access another company's record?
Can the API access data that the web application cannot?
Can a normal user call the endpoint directly?
Can an exported report contain data from another tenant?
Can a deleted record still be downloaded?
Can a background job bypass authorization?
Can changing an ID expose someone else's data?
Can an authenticated user perform an action that their role should not allow?
These questions are often more valuable than simply checking whether the login page is secure.
The biggest lesson
A secure login system does not automatically mean a secure application.
The real security boundary begins after login.
For me, the mindset is simple:
Authentication tells me who the user is.
Authorization tells me what the user can do.
Tenant isolation tells me whose data the user can touch.
When these three are designed properly, the application becomes much safer.
And in modern SaaS applications, I believe access control deserves the same attention as database performance, API design and application architecture.
Because a slow query gives us a performance problem.
A broken authorization check can give us a data breach.
That difference is huge.