[ Blog ] → Consulting

What we check in a web app security audit

A website security audit reviews code, configuration, dependencies and infrastructure for weaknesses. This is the list we work through, in order.

Rainer Rodriguez

Co-founder, Nerdify

Aug 18, 2026 · 7 min read

A website security audit is a structured review of a web application's code, configuration, dependencies and hosting to find the weaknesses an attacker would use, and to rank them so the worst get fixed first. For a custom web app, the most serious findings are often in authorization (users reaching data that isn't theirs), followed by leaked secrets and debug settings, outdated dependencies, and unsafe handling of input and file uploads. An automated scanner finds some of these. The authorization problems need someone reading the code.

This is the list we work through when a client asks us to audit a Laravel or PHP application, in the order we do it. Most of it applies to any stack.

Agree on scope before starting

We start by writing down what is in the audit: which applications, which environments, whether we get the source code, and whether we are allowed to test against staging or production. A code review with staging access finds far more than a black-box test against a URL, and it is what we recommend for apps the client owns.

We also ask for a list of user roles and a test account for each one. Most of the audit is about what each role can reach, and we can't check that without logging in as them.

Configuration and secrets

We check these first because they are fast to find and the damage is immediate.

APP_DEBUG must be false in production. With debug on, a single error page shows the stack trace, environment variables and sometimes database credentials to whoever triggered it. php artisan about on the server shows the environment and debug mode in one command.

The .env file must not be reachable from the web. On a correct setup the web server's document root is public/, so the file sits outside it. We request /.env anyway, along with /.git/config and /storage/logs/laravel.log, because misconfigured servers still exist.

Then we look at the repository history. A secret committed once and deleted in the next commit is still in git, and anyone with read access to the repository has it. We run a scanner like gitleaks over the full history, and any key it finds gets rotated, not only removed.

Authorization, route by route

Broken access control is first on the OWASP Top 10, and scanners rarely catch it, because the response looks normal. A user changes /invoices/1041 to /invoices/1042 and sees another company's invoice. Nothing errors.

We go through every route and controller action and ask two questions: who is allowed to call this, and where does the code check it. In Laravel the check should be close to the route, in a policy, and visible:

php
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])
    ->middleware('can:view,invoice');
php
final class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): bool
    {
        return $user->team_id === $invoice->team_id;
    }
}

We look for the gaps: an API endpoint added later without the middleware, a Livewire or Inertia action that updates a record by ID from the request, a bulk export that filters by the team in the UI but not in the query, an admin route protected only by hiding the link. IDs in request bodies get the same treatment. If a form posts customer_id, the validation has to check that the customer belongs to the user's account.

When we find a gap, we write a failing test for it before suggesting the fix:

php
it('does not let a user view another team\'s invoice', function (): void {
    $invoice = Invoice::factory()->create();
    $outsider = User::factory()->create();

    $this->actingAs($outsider)
        ->get("/invoices/{$invoice->id}")
        ->assertForbidden();
});

The test stays in the suite after the fix, so the hole can't reopen silently. A 403 is fine for a web page. On an API used by outsiders we prefer a 404, so the response doesn't confirm that the record exists.

A user changes one number in the URL and sees another company's invoice. Nothing errors.

Authentication and sessions

We check that login and password reset are rate-limited, that reset tokens expire and work once, and that a password change or reset logs out other sessions. Admin accounts should have two-factor authentication.

For session cookies we check SESSION_SECURE_COOKIE=true in production, http_only on, and a same_site value of lax or strict. For API tokens issued with Sanctum, we check whether they expire (the expiration setting in config/sanctum.php is null by default, which means never), whether they use abilities to limit what each token can do, and whether users can see and revoke their tokens.

Input, queries and output

Laravel's query builder and Eloquent use parameter binding, so SQL injection comes from the places where developers bypass it. We search the codebase for DB::raw, whereRaw, selectRaw, orderByRaw and DB::statement, and check every one for interpolated input:

php
// Vulnerable: input goes straight into SQL
$users = User::query()->whereRaw("email = '{$request->input('email')}'")->get();

// Safe: the value is bound
$users = User::query()->whereRaw('email = ?', [$request->input('email')])->get();

Sort columns deserve a separate look. orderBy($request->input('sort')) lets the user pick any column name, so we check for an allow-list.

For mass assignment, a model with protected $guarded = []; combined with $model->update($request->all()) lets a user set any column, including is_admin or team_id. We look for $request->all() passed to create or update, and recommend $request->validated() from a form request.

For output, Blade's {{ }} escapes by default, so we search for {!! !!}. In React the equivalent is dangerouslySetInnerHTML. Each use needs either trusted content or a sanitizer.

File uploads

Uploads are a common way in. We check that validation restricts type and size by content, not only by extension, that files land on a private disk outside the web root, and that downloads go through a controller that checks authorization or a temporary signed URL:

php
$request->validate([
    'document' => ['required', 'file', 'mimes:pdf,jpg,png', 'max:10240'],
]);

$path = $request->file('document')->store("teams/{$team->id}/documents", 's3');

We also check that the app never uses the original filename as the stored path, and that image processing libraries are current, since they parse untrusted files.

Dependencies and framework version

We run composer audit and npm audit and read the results instead of pasting them. A vulnerable package that only runs in development is a lower priority than one in the request path.

The framework version matters too. Each Laravel major version gets security fixes for two years after its release. An app on an unsupported version gets no patches for new vulnerabilities, and the upgrade becomes part of the remediation plan. We moved a US nonprofit's two platforms from Laravel 10 to Laravel 12, one major version at a time, and our Laravel upgrade service does the same for other apps.

HTTP headers and transport

We check that HTTPS is enforced, with HSTS once the client is sure every subdomain supports it. Then Content-Security-Policy, X-Content-Type-Options: nosniff, Referrer-Policy, and protection against framing with frame-ancestors in the CSP or X-Frame-Options. Scanners like ZAP or MDN's HTTP Observatory check these well, and we use them for this part.

Logs, monitoring and backups

We check that passwords, tokens and full card numbers never reach the logs. PHP's #[\SensitiveParameter] attribute keeps a parameter's value out of stack traces, which is useful on methods that receive credentials.

We check that errors reach an engineer through monitoring, that admin actions are recorded in an audit trail, and that backups exist, are encrypted, live outside the main hosting account, and have been restored at least once.

The report

Each finding in our report has a severity, the affected code or URL, how to reproduce it, the fix, and, for authorization issues, the test that proves it. We sort by what to fix this week, this month and this quarter. We send critical findings as soon as we confirm them instead of holding them for the full report, and we retest every fix before closing the finding.

Read also · Laravel

API design best practices we use on every Laravel project

Oct 13, 2026 · 9 min read

[ Read → ]

If you want an independent review of your application's security, or a team to fix what an audit found, see our consulting services.

Written by

Rainer Rodriguez

Co-founder, Nerdify

Rainer co-founded Nerdify in Managua, Nicaragua, in 2016 and works with the team on the client products it builds and maintains.

Service · Consulting

Need a team that works like this?

More from the blog

[ All articles → ]
  • Consulting

    How to choose a technology stack

    Choose the stack your team can build and maintain for years, that fits what the product does, and that you can hire for. How we decide, with examples.

    Carlos Saldaña

    Sep 8, 2026 · 7 min read

  • Nearshore

    Nearshore vs. offshore: What changes for your team

    Nearshore teams work in or near your time zone. Offshore teams are far away, often half a day ahead. What that changes for cost, speed and how you work.

    Carlos Saldaña

    Oct 20, 2026 · 7 min read