7 Security Holes We Find in Almost Every Laravel Site
IDOR, mass assignment, unguarded Livewire methods, insecure uploads, leaked secrets — how each one looks, why it survives a scan, and how it gets fixed.
We run a Laravel security audit most weeks — sometimes because a client inherited a codebase from a developer who’s gone quiet, sometimes because a site is about to take real customer data for the first time, occasionally because something has already gone wrong. Across those engagements, a short list of issues keeps showing up. Not exotic zero-days. Ordinary defaults and shortcuts that ship fine, pass a normal QA pass, and sit quietly until someone finds them the hard way.
None of these are unique to bad developers. Laravel is a genuinely secure framework by default — most of these holes get introduced when a deadline compresses the “do it properly” step into the “make it work” step, and nobody circles back. Here are the seven we see most, what each one actually looks like in a real codebase, why an automated scanner usually walks right past it, and what the fix looks like.
1. IDOR — broken object-level authorization
How it looks. A route like /invoices/{id} or /orders/{id} that loads the record with Order::find($id) and renders it, with no check that the record belongs to the logged-in user. Change the number in the URL, see someone else’s invoice. We’ve found this on customer dashboards, admin panels that scope by account but forget to enforce it server-side, and API endpoints that assume the frontend will only ever request its own data.
Why scanners miss it. An automated scanner has no concept of “this record belongs to a different customer.” It can’t tell that record 82 and record 83 belong to two different accounts — that requires two authenticated sessions and a human comparing what each one can see. This is business logic, not a signature match, so it’s close to invisible to tooling and only reliably caught by someone reading the authorization path record by record.
The fix. A Policy class per model, checked on every record-scoped route and every Livewire component mount — not just the ones that “seem sensitive.” Route model binding paired with $this->authorize() closes this at the framework level instead of leaving it to per-controller discipline.
2. Mass assignment
How it looks. A model’s $fillable array is wider than the form that’s supposed to populate it — often because a role, status, or is_admin column got added months after the model was first written, and nobody tightened $fillable to match. A request built with an extra field the UI never exposed updates a column the UI never intended to expose either.
Why scanners miss it. This needs a match between the model’s schema and the request’s validation rules — information that isn’t visible from outside the application at all. A black-box scanner sees a working form and a 200 response; it has no way to know a hidden field just got written to a column the form never rendered.
The fix. Default to $guarded on anything role- or status-adjacent, validate every request through a dedicated FormRequest that whitelists exactly the fields the form is allowed to send, and never call ->update($request->all()) on a model that has any sensitive column.
3. Missing authorization on Livewire and API endpoints
How it looks. A Livewire component hides a delete button behind @if($user->isAdmin()) in the template, but the underlying public method — deleteInvoice($id) — has no authorization check of its own. Livewire’s public methods are reachable directly through its own request cycle regardless of what the template renders, so hiding the button hides the trigger, not the endpoint behind it. The same pattern shows up on API routes that get registered without per-record policy checks, sometimes without auth middleware at all on a route added late in a sprint.
Why scanners miss it. Crawlers follow links and buttons; they don’t reconstruct a hidden component’s callable methods from its client-side snapshot data. The action is functionally invisible to link-following tools even though it’s live and callable the moment someone knows — or guesses — the method name.
The fix. Authorize inside the method itself, every time, not just in the template that renders the trigger. Treat every public Livewire method and every API route as its own access-control boundary, checked independently of whatever UI happens to call it.
4. Insecure file uploads
How it looks. A profile photo or document upload field that trusts the client-supplied MIME type, checks only the file extension rather than the actual file content, or preserves the original filename unsanitized on disk. We’ve seen uploads land directly inside a publicly served directory with no separation between “file we serve” and “file that could execute.”
Why scanners miss it. A generic scan might throw one or two obvious payloads at an upload field and move on if the server rejects them. It won’t necessarily catch that validation only happens in JavaScript, that the extension check doesn’t match the actual bytes of the file, or that the storage path is reachable and web-executable — all things that need a look at the actual validation code, not just the upload response.
The fix. Validate file type by inspecting real content, not the extension or a client-reported MIME type. Regenerate filenames on save instead of trusting the original. Store uploads outside the public web root, or behind signed, time-limited URLs, so even a file that shouldn’t have been accepted can’t be executed as a script.
5. Secrets in the repo or an exposed .env
How it looks. A .env file committed early in a project’s life and later .gitignore-d — but never scrubbed from the git history it’s already sitting in. Or APP_DEBUG=true left on in production, which turns any unhandled error into a stack trace with database credentials in it. Or a Stripe or AWS key sitting in a public repository because it was convenient during setup and nobody circled back before the repo went public.
Why scanners miss it. A decent scanner will check whether .env is reachable at the web root and flag it if so — that part’s easy. What it won’t do is crawl the repository’s commit history, and it has no visibility into a private repo that isn’t even the one deployed to production, which is exactly where a lot of these leaks actually live.
The fix. Any exposed credential gets rotated — deleting the file from history is necessary but not sufficient, because the value has already been seen. APP_DEBUG=false in production is a deployment-checklist item, not a one-time setting. A secrets scanner in CI catches new leaks before they merge, which is a lot cheaper than a rotation after the fact.
6. Outdated dependencies with disclosed CVEs
How it looks. A composer.lock frozen since the project shipped, sometimes years behind current Laravel and PHP versions, carrying third-party packages with publicly disclosed vulnerabilities — deserialization issues, authentication bypasses tied to a specific version range, known-broken cryptographic handling. Nobody’s touched it because it still works, and “still works” gets mistaken for “still safe.”
Why scanners miss it. Most external scanners fingerprint what’s visible over HTTP — response headers, error pages, the occasional version string leak. Composer dependency versions aren’t exposed at the network layer at all under normal conditions, so this class of risk is invisible to anything that isn’t actually looking at the lock file against a CVE database.
The fix. Run composer audit as a step in the deploy pipeline, not as a one-off. Schedule dependency reviews quarterly even when nothing’s broken — waiting for a feature that forces an upgrade means the framework and its packages can drift years out of date.
7. Weak session and authentication configuration
How it looks. Default scaffolding left exactly as generated: no rate limiting on login or password-reset attempts beyond whatever Laravel ships with (sometimes removed entirely “to speed up testing” and never restored), password-reset tokens with no meaningful expiry, session cookies missing secure, httponly, or samesite flags in production, no second factor available on admin accounts, or a file-based session driver running across multiple app servers, causing sessions to behave inconsistently under load.
Why scanners miss it. These are configuration choices, not exploitable requests — a scanner sees a login form that works correctly and has nothing to flag. Whether a reset token expires in an hour or never, or whether an admin account has 2FA available at all, only shows up by reading config/auth.php and config/session.php against the actual risk profile of the site, which is a judgment call a tool can’t make.
The fix. Throttle middleware on login and password-reset routes, cookie flags set correctly for a production environment, a second factor available on every admin-level account at minimum, and a session driver (Redis or database, not file) that behaves correctly the moment there’s more than one app server.
The pattern behind the pattern
None of these seven require a sophisticated attacker to matter — most of them are found by someone poking around, not by someone running a targeted campaign. That’s what makes them worth fixing before they’re found rather than after: the fix is usually a few hours of focused work, and the cost of leaving it is almost always higher than the cost of closing it.
If any of these sound familiar in your own codebase, our Laravel security audit covers all seven — plus authentication, injection, and configuration hygiene — with a fixed price and a written, prioritized report in five business days. If you’re not sure Laravel is even the right lens for your stack, our broader website security audit covers the same ground for any PHP application.
Got a similar project in mind?
Tell us what you're building and get a fixed-price quote in 2 business days — no open-ended hourly estimate, no surprise invoice.