The Dev Log › Technology
Web Security Essentials for Laravel Apps: Access Control, CSP and Rate Limits
By Jezer Niel Blanca, Full Stack Developer ·
·
6 min read
The security habits I apply to every Laravel app: policies, scoped queries, safe SQL, XSS and a Content Security Policy, rate limiting, secrets management and dependency audits.
Laravel gives you a lot of security for free: CSRF protection, hashed passwords, escaped Blade output and parameter binding in every query. That's a great foundation, but it isn't the whole building. Most security problems I see in web apps don't come from exotic attacks. They come from a missing authorization check, a debug flag left on, a secret committed to Git, or a login form with no rate limit. In this post I'll go through the essentials I apply to every Laravel app, loosely following the risk areas in the OWASP Top 10, with code you can use today.
Access Control Is Where Apps Really Break
Broken access control sits at the top of the OWASP list for good reason. The classic bug looks like this: a route loads a record by ID, and nothing checks whether the current user is allowed to see it. Change the number in the URL and you're reading someone else's invoice.
Use Policies for Every Model Action
Laravel policies keep authorization rules in one place per model:
<?php
namespace App\Policies;
use App\Models\Invoice;
use App\Models\User;
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $invoice->team_id === $user->current_team_id;
}
public function update(User $user, Invoice $invoice): bool
{
return $this->view($user, $invoice) && $user->can_manage_billing;
}
}
Then check them in the controller before doing anything else:
use Illuminate\Support\Facades\Gate;
public function show(Invoice $invoice): Response
{
Gate::authorize('view', $invoice);
return Inertia::render('Invoices/Show', ['invoice' => $invoice]);
}
Gate::authorize throws a 403 automatically when the check fails.
Scope Queries by Default
For listing pages, don't fetch everything and filter in PHP. Scope the query itself to the user or team, for example $request->user()->invoices()->latest()->paginate(). When queries start from the relationship, it's much harder to leak data by accident.
Every route that loads a record by ID needs an answer to one question: who is allowed to see this? If the code doesn't answer it, the answer is "anyone".
Injection and Mass Assignment
Eloquent and the query builder use parameter binding, which protects you from SQL injection as long as you use them properly. The danger comes back when you build raw SQL with user input:
// Unsafe: user input concatenated into SQL
User::query()->whereRaw("name = '{$request->input('name')}'")->get();
// Safe: bindings are escaped by the database driver
User::query()->whereRaw('name = ?', [$request->input('name')])->get();
Column names can't be bound, so if users can choose a sort column, check it against an allowlist before using it.
Mass Assignment
Never pass the whole request into create() or update(). Use validated data from a form request and define $fillable on your models, so a user can't sneak in fields like is_admin:
$invoice->update($request->validated());
Cross-Site Scripting and a Content Security Policy
Blade's {{ }} and Vue's template interpolation both escape output, so XSS mostly appears where developers bypass that. The two places to watch are Blade's unescaped {!! !!} syntax and Vue's v-html directive. Only use them with content you control or have sanitised with a proper HTML sanitiser.
Adding Security Headers
A Content Security Policy is your second line of defence. It tells the browser which scripts may run, so even if some HTML slips through, injected scripts are blocked. Laravel's Vite integration can generate a nonce for each request, which makes a strict policy practical:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class AddSecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
Vite::useCspNonce();
$response = $next($request);
$response->headers->set(
'Content-Security-Policy',
"script-src 'nonce-".Vite::cspNonce()."' 'strict-dynamic'; object-src 'none'; base-uri 'self'",
);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
return $response;
}
}
Register it for web routes in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
\App\Http\Middleware\AddSecurityHeaders::class,
]);
})
Any inline scripts on your pages also need the nonce, so roll the policy out carefully. I usually start with the Content-Security-Policy-Report-Only header, check the browser console for violations, and switch to enforcing once it's clean.
Rate Limiting Logins and Sensitive Endpoints
Without limits, a login form invites password guessing, and an expensive endpoint invites abuse. Define named limiters in a service provider's boot method:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->input('email').'|'.$request->ip());
});
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
Then apply them to routes:
Route::post('/login', [LoginController::class, 'store'])->middleware('throttle:login');
I rate-limit logins, password resets, contact forms, anything that sends email or SMS, and anything that calls a paid third-party API such as an AI model.
Secrets, Configuration and Dependencies
A surprising number of incidents come from configuration, not code.
Keep Secrets Out of Git
- Never commit
.env. It should be in .gitignore from the first commit.
- Rotate anything that leaks. If a key ever lands in Git history, treat it as compromised and replace it, even after deleting the commit.
- Read config, not env. Use
config('services.stripe.secret') in code. Only config files should call env().
- Consider encrypted env files.
php artisan env:encrypt lets you store an encrypted environment file in the repository for deployments.
Production Settings
Two settings matter above all: APP_ENV=production and APP_DEBUG=false. With debug on, an error page can reveal environment variables, file paths and queries to anyone who triggers an exception. Also make sure sessions and cookies are sent over HTTPS only in production.
Audit Your Dependencies
Vulnerable packages are a real risk. Run these regularly, ideally in CI:
composer audit
npm audit
A Few More Habits Worth Building
These don't fit neatly into one category, but I apply them everywhere:
- Encrypt sensitive columns. The
encrypted cast stores values like API tokens encrypted at rest.
- Use signed URLs for links in emails, such as unsubscribe or download links, so they can't be tampered with.
- Validate uploaded files by type and size, and store them outside the public directory unless they're meant to be public.
- Be careful with user-supplied URLs. If your server fetches a URL a user provides, restrict it so it can't reach internal addresses.
- Log security events such as failed logins and permission denials, so you can spot patterns.
Wrapping up
Laravel secures a lot by default, but real safety comes from habits: policies on every model action, scoped queries, validated input, careful use of raw SQL and v-html, a Content Security Policy, rate limits on sensitive routes, secrets kept out of Git and debug mode off in production. Pick the two items from this list your app is missing and fix them this week. If you want a Laravel product built with security in mind from the first commit, I'd love to build it with you and my team.
Tags: Security, Laravel, OWASP, CSP