Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Securing a Laravel App: A Practical 12-Point Checklist Before You Go Live

About Post

Laravel gives you a lot of security for free. CSRF tokens, escaped Blade output, hashed passwords, parameter binding in the query builder: all switched on before you write a line of code.

That's exactly why Laravel apps get breached in boring ways. The framework covers the defaults, and the holes come from the five-minute decisions we make around it: a debug flag left on, a $request->all() that saves too much, a controller that checks you're logged in but not whether the record is yours.

This is the checklist I run through before a Laravel 12 app goes live, and again whenever an app has been running long enough that nobody remembers how it was set up. It's grouped by where the problem lives.

Part 1: the environment

1. Debug mode is off in production

With APP_DEBUG=true, any exception renders a detailed error page: stack traces, file paths, queries and request details. That is a gift to anyone poking at your app. Production should have APP_ENV=production and APP_DEBUG=false, and it's worth a line in your deploy script that refuses to continue if it doesn't.

2. The APP_KEY is secret, unique and never committed

The app key encrypts your cookies and session data, signs URLs, and backs any encrypted casts on your models. If it leaks, someone can forge what your app trusts. Every environment gets its own key, it lives in the server's environment or secret store, and it never appears in Git.

If you ever need to rotate it, Laravel 11 and 12 support APP_PREVIOUS_KEYS, so existing encrypted values can still be decrypted while new ones use the new key. Users don't all get logged out at once.

3. The web server points at public/, and nothing else

The document root must be the public folder. If it's the project root, your .env, storage/logs and composer.json may be one URL away. It sounds obvious. It still happens on shared hosting and hastily configured servers.

Part 2: the code

4. Mass assignment is explicit

This line looks harmless:

$user->update($request->all());

Now imagine the model has $guarded = [] and a column called is_admin. Anyone can send is_admin=1 in the request body and promote themselves. The fix is two layers: list $fillable on the model, and only ever save validated data.

$user->update($request->validated());

A nice extra: call Model::preventSilentlyDiscardingAttributes(! app()->isProduction()) in a service provider. In development, Laravel will throw when you try to fill an attribute that isn't fillable, instead of silently ignoring it.

5. Every input is validated, including the ones you "control"

Hidden fields, select boxes and query strings are all user input. A form request or $request->validate() on every write endpoint, with rules for type, length and allowed values. Two cases deserve special attention:

  • Sort and filter columns. Column names can't be bound as parameters, so orderBy($request->input('sort')) deserves a whitelist (Rule::in(['created_at', 'amount'])).
  • Raw SQL. whereRaw("name = '$name'") is SQL injection with extra steps. Use bindings: whereRaw('name = ?', [$name]).

6. Authorization checks ownership, not just login

This is the most common serious bug I see in code reviews, in any framework. The route has auth middleware, so it feels protected:

public function show(int $id)
{
    return Contract::findOrFail($id); // any logged-in user, any contract
}

Change the ID in the URL and you're reading someone else's data. That's an IDOR (insecure direct object reference), and it's why policies exist. Write a ContractPolicy and check it:

public function show(Contract $contract)
{
    Gate::authorize('view', $contract);

    return $contract;
}

Small Laravel 11+ gotcha: the default base controller no longer includes the AuthorizesRequests trait, so $this->authorize() won't exist unless you add it back. Gate::authorize() works everywhere.

7. Blade output stays escaped

{{ $value }} escapes HTML. {!! $value !!} does not. Every {!! !!} in your views should have a reason you can say out loud, and user-provided content is never that reason.

8. Uploads are treated as hostile

Validate type and size ('file' => 'required|file|mimes:pdf,jpg,png|max:5120'), store sensitive files on a private disk rather than public, and let Laravel generate the file name. Serve private files through a controller that checks authorization, or a temporary signed URL.

Part 3: the edges of the app

9. CSRF protection has no lazy exceptions

Laravel validates CSRF tokens on web routes by default. The risk is the exception list: someone adds a wildcard to make a form "just work" and forgets about it. Webhooks are a legitimate exception, but they should be verified by signature instead.

10. Sensitive routes are rate limited

Login, password reset, OTP verification and anything that sends an SMS or email. Define a named limiter in AppServiceProvider::boot():

RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by($request->input('email').'|'.$request->ip());
});

Then attach ->middleware('throttle:login') to the route. Keying on email plus IP slows down password guessing without letting one attacker lock out a real user from everywhere.

11. Security headers are set

A small middleware covers the basics:

public function handle(Request $request, Closure $next): Response
{
    $response = $next($request);

    $response->headers->set('X-Content-Type-Options', 'nosniff');
    $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
    $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
    $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');

    return $response;
}

Register it in bootstrap/app.php with $middleware->append(SecurityHeaders::class). Only send HSTS once you're sure every subdomain is on HTTPS. A Content Security Policy is the next step up, but it needs testing against your real scripts, so roll it out in report-only mode first.

12. Dependencies are audited and updated

Your app is your code plus a few hundred packages. Run composer audit and npm audit in CI, keep Laravel on a supported version, and apply patch releases regularly. Small, frequent updates are far less scary than one giant upgrade after two years.

The rule behind all twelve: Laravel protects the defaults. Your job is to protect every place where you stepped away from them: a raw query, an unescaped echo, a CSRF exception, a $guarded = []. Search your codebase for those first.

The checklist, short version

  • APP_DEBUG=false, unique APP_KEY per environment, document root on public/
  • $fillable plus $request->validated(), never $request->all()
  • Validation on every write, whitelists for sort columns, bindings for raw SQL
  • Policies that check ownership, not just authentication
  • Escaped Blade, private storage for private uploads
  • No lazy CSRF exceptions, throttled auth routes, security headers
  • composer audit in CI and regular updates

The authorization docs are worth a reread even if you've used policies for years. And I'm curious: which item on this list do you most often find missing when you open an inherited Laravel project?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close