Tools & Reviews 9 min read Markdown

Reading a Laravel Security Scan Report Without Wasting a Week

A scan comes back with 47 findings. Three of them matter this afternoon, most of the rest matter eventually, and a few are noise you should close as accepted. Here is how to sort them, and the sorting mistakes that cost teams the most time.

Matt King
Matt King
August 28, 2026
Last updated: August 28, 2026
Reading a Laravel Security Scan Report Without Wasting a Week

The first scan report a team runs almost always produces the same reaction: a number in the forties, a scroll through a long list, and a quiet decision to deal with it later. Later does not arrive, and the report becomes a thing that exists rather than a thing that is used.

The problem is not the findings. It is that reports are ordered for completeness and read as though they were ordered for action.

Finding counts tell you nearly nothing

Two applications, both reporting 47 findings.

The first has 46 header and cookie-attribute issues and one exposed .env file. The second has 47 header issues and nothing else. Identical counts, and the first application is in serious trouble while the second has a tidy afternoon's work.

This is why scores and counts make poor targets. They are averages over things that are not comparable, and optimising them rewards clearing large numbers of trivial items while a single serious one sits in the same list. In the State of Laravel Security data, missing headers appeared in 72% of applications and were the least dangerous common finding, while debug mode appeared in 18% and was by far the most dangerous. A count treats those the same. Do not.

Three questions, in order

For each finding, in this sequence. The order matters more than any scoring rubric.

Can someone with no account reach it? Unauthenticated and externally reachable is the dividing line that matters most. An attacker with no relationship to your application is a much larger population than an attacker who has signed up, and automated scanning finds unauthenticated issues at internet scale within hours.

What does exploiting it yield? Sort into information, credentials, or code execution. Information is bad. Credentials are worse, because they compose with everything else. Code execution ends the conversation. A finding that yields APP_KEY is a credentials finding regardless of how the scanner labelled the route that leaked it, and in Laravel that key leads further than most teams expect, as we traced in your Laravel APP_KEY is on GitHub.

How long is the fix? Not part of the risk, but decisive for sequencing. A ten-minute fix on a medium finding should usually go before a three-week fix on a high one, because the medium is closed today and the high needs planning either way.

Run those three questions and a 47-item list resolves into three or four things for this week and a backlog with an honest shape.

The four buckets

Now. Unauthenticated, externally reachable, yields credentials or execution. In a Laravel context this is a readable .env, debug mode enabled in production, an exposed Telescope or Horizon instance, or a known-vulnerable framework version with a public advisory against it. These are almost always configuration rather than code, which means they are minutes of work and there is no good reason to schedule them.

This sprint. Real findings with a real fix that needs a little thought. Missing CSP, permissive CORS, a dependency upgrade that touches behaviour, authorisation gaps on a specific route. Genuine work, not emergencies.

Scheduled. Correct findings whose fix is a project. An unsupported framework branch is the classic example. These need a date and an owner or they become permanent, and the honest move is to attach them to work that has product value rather than arguing for them on security grounds alone.

Accepted. Findings you are consciously not fixing. This bucket is the one teams skip, and skipping it is what kills the whole practice. If a finding is not applicable, or the mitigation costs more than the risk, close it with a written reason and a date to revisit. An unread backlog of 200 open items is worse than an empty one, because the next genuinely urgent finding arrives into a list nobody opens.

Where triage goes wrong

Fixing by list order. Reports are usually ordered by category or by scan sequence, and working top to bottom means an alphabetically unlucky critical finding waits behind twelve header issues.

Treating severity as priority. Severity is the worst case in isolation. A high-severity finding on an admin route restricted to a VPN carries less risk than a medium on your public login form. The scanner cannot know about the VPN. You can.

Assuming the scanner sees everything. External scanning is very good at configuration and exposure and cannot see your business logic. A perfect external report says nothing about whether one tenant can read another's data, which is a whole class of serious bugs that no external scan will ever surface. We wrote about that gap specifically in multi-tenant Laravel and the isolation bugs scanners miss.

Fixing the symptom. A finding that debug mode is on gets fixed by setting APP_DEBUG=false. Fine. The better question is why production was serving a configuration nobody intended, because whatever allowed that will allow it again. Usually it is an incident where someone flipped it to diagnose something and the change outlived the incident.

Verify from outside, always

The most common false comfort in this whole process is fixing something in the repository and marking it done.

Configuration is layered. A value in .env, a value in a cached config file, a value in the container environment, and a value baked into an image can all disagree, and the one that wins is not always the one you edited. php artisan config:clear exists precisely because this happens.

So confirm the fix the same way the finding was made, from outside:

curl -sI https://example.com | grep -i "content-security-policy\|strict-transport"
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/.env
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/telescope

A 404 or 403 on the last two is what you want. A 200 means the fix did not reach production regardless of what your repository says, and this is a genuinely common outcome rather than a theoretical one.

Read the trend, not the snapshot

A single report is a photograph. What tells you whether your practice is working is the shape over months: critical findings returning to zero quickly after each deploy, the accepted bucket staying deliberate rather than growing by neglect, and the scheduled bucket actually shrinking.

The failure pattern that matters most is a critical finding that reappears. That is not a fix that did not work, it is a process that reintroduces the same misconfiguration, and no amount of re-fixing will address it. Usually the cause is deploy tooling or an incident habit rather than anything in the codebase.

StackShield scans externally on a schedule and weights findings by exploitability rather than by count, which is most of what this post is arguing for. Run a free scan to get the first report, and the security checks reference documents what each finding means and how to clear it.

Free security check

Is your Laravel app exposed right now?

34% of Laravel apps we scan have at least one critical issue, and most teams do not find out until something breaks. The free scan checks your live app in 60 seconds. Then StackShield re-runs every check after each deploy, so a fix you ship today does not quietly regress next week.

18% have debug mode on
72% missing security headers
12% have exposed .env
Scan My App Free No signup for the scan. Continuous monitoring on a 14-day trial, no card.

Frequently Asked Questions

How do you prioritise security scan findings?

Sort by exploitability rather than by severity label, using three questions in order. Can this be reached by someone with no account? Does exploiting it yield credentials or code execution rather than information? And how long does the fix take? A finding that is externally reachable, unauthenticated, and yields secrets goes first regardless of what the report calls it. Severity labels are a starting point, not a ranking.

Why do scanners report so many low severity findings?

Because scanners are built to be comprehensive and cannot know your context. A missing Permissions-Policy header is a genuine finding that a scanner is right to report and that may be irrelevant to your application. The volume is a property of the tool being thorough, not a measure of your risk, which is why counting findings is a poor proxy for security posture.

Should you fix every security finding?

No, and pretending otherwise is how teams end up ignoring reports entirely. Some findings are not applicable, some have a cost out of proportion to their risk, and some are already mitigated by a control the scanner cannot see. The important discipline is to close those explicitly as accepted with a written reason, rather than leaving them open and unread, because an unread backlog hides the findings that do matter.

What is the difference between severity and risk?

Severity describes the worst case if a vulnerability is exploited, in isolation and without context. Risk is severity combined with how likely exploitation is in your specific deployment. A critical finding on an endpoint reachable only from an internal network carries less risk than a medium finding on your public login page. Scanners can measure severity but only you can assess risk, which is the work triage actually consists of.

How often should you scan a Laravel application?

Continuously, or at least on every deploy, because your attack surface changes with each release rather than each quarter. The value of frequent scanning is less about catching new vulnerability classes and more about catching the configuration drift that reintroduces old ones, such as a debug flag flipped during an incident and never flipped back.

Stay Updated on Laravel Security

Get actionable security tips, vulnerability alerts, and best practices for Laravel apps.