These are the Laravel security best practices I build into every custom application from the first commit, not bolted on before launch. Laravel gives you good defaults: escaped Blade output, CSRF protection, prepared statements in Eloquent, strong password hashing. Most real Laravel vulnerabilities come from the places where a developer steps around those defaults, or from production configuration nobody double-checked. This post is the playbook I follow, with the code I actually write: configuration, validation, authorization, queries, uploads, secrets, headers, dependencies and deployment. It is also the baseline behind my Laravel development service.
1. Production configuration: APP_DEBUG, .env and APP_KEY
The single most damaging Laravel mistake I see is a production site running with debug mode on. With APP_DEBUG=true, any exception renders a detailed error page with stack traces, file paths, and, depending on the version, environment variables and database credentials. It has also been a direct path to remote code execution: CVE-2021-3129 abused the Ignition error page's debug-mode features in older Ignition releases to execute code on the server. Every production .env I ship starts like this:
APP_ENV=production
APP_DEBUG=false
APP_URL=https://app.example.com
LOG_LEVEL=warningTwo more rules about the .env file itself:
- Never commit it. It is in
.gitignoreby default; I keep it that way and commit a.env.examplewith placeholder values instead. If a real.envever reached a repository, I treat every secret in it as leaked and rotate them, because removing it from history does not un-leak it. - Never let the web server serve it. The web root must be the
public/directory, so.envsits outside it. More on that in the deployment section.
APP_KEY is the key Laravel uses for encryption, encrypted cookies and signed data. Generate it once per environment with php artisan key:generate, never share it between staging and production, and never reuse a key from a tutorial or template. If it leaks, rotate it. Since Laravel 11 you can do that without logging everyone out or breaking existing encrypted values: move the old key to APP_PREVIOUS_KEYS and Laravel will decrypt with the previous keys while encrypting new data with the current one:
APP_KEY=base64:NEW_KEY_HERE
APP_PREVIOUS_KEYS=base64:OLD_KEY_HEREThis does not re-encrypt stored data on its own, so for a leaked key I also re-save encrypted columns with the new key and then drop the old one from APP_PREVIOUS_KEYS. Password hashes are not affected by APP_KEY.
2. Validation with Form Requests and safe mass assignment
Every write endpoint gets a Form Request. It keeps validation out of the controller, and it has an authorize() method, so the request is checked before any business logic runs:
class UpdateInvoiceRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('invoice'));
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:200'],
'due_date' => ['required', 'date', 'after:today'],
'amount' => ['required', 'numeric', 'min:0'],
];
}
}In the controller I only ever pass $request->validated() to the model, never $request->all(). That matters because of Laravel mass assignment. If a model has protected $guarded = []; and the controller does $user->update($request->all()), anyone can add is_admin=1 or team_id=7 to the form and the model will happily save it. I use an explicit allow-list instead:
class Invoice extends Model
{
protected $fillable = ['title', 'due_date', 'amount'];
// never: protected $guarded = [];
}
public function update(UpdateInvoiceRequest $request, Invoice $invoice)
{
$invoice->update($request->validated());
return redirect()->route('invoices.show', $invoice);
}Fields such as user_id, role or status are set explicitly in code, not taken from input. In development I also turn on strict mode in AppServiceProvider, so an attribute that is silently dropped by $fillable throws an exception instead of being ignored:
public function boot(): void
{
Model::shouldBeStrict(! $this->app->isProduction());
}3. Authorization: Policies, Gates and scoped bindings
Insecure direct object references (IDOR) are the bug I look for first in any custom app. The pattern: /invoices/42 works for the owner, and changing it to /invoices/43 shows someone else's invoice because the controller only checked that the user was logged in. Authentication is not authorization. Every model a user can act on gets a Policy:
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->hasRole('billing');
}
}Then I enforce it at the route or in the controller, so it is impossible to forget in one method:
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])
->middleware('can:view,invoice');
// or inside the controller
Gate::authorize('view', $invoice);For nested resources I use scoped route model binding, which makes Laravel resolve the child through the parent's relationship. A comment ID that does not belong to that post returns 404 instead of loading another post's comment:
Route::scopeBindings()->group(function () {
Route::get('/posts/{post}/comments/{comment}', [CommentController::class, 'show']);
});List endpoints need the same care. Invoice::all() in an index action is an IDOR across the whole table; I always query through the user's scope, for example $request->user()->currentTeam->invoices()->paginate(), or a global scope for multi-tenant apps.
4. SQL injection: bindings everywhere, allow-lists for columns
Eloquent and the query builder use parameter binding, so where('email', $email) is safe. The risk comes back the moment raw SQL meets user input. These are the patterns I reject in code review:
// Vulnerable: input interpolated into raw SQL
User::whereRaw("email = '$email'")->first();
DB::select("SELECT * FROM orders WHERE status = '" . $request->status . "'");
Order::select(DB::raw("sum(total) as t, $request->group"))->get();
// Safe: bindings
User::whereRaw('email = ?', [$email])->first();
DB::select('SELECT * FROM orders WHERE status = ?', [$request->status]);Column and direction names cannot be bound as parameters, so a sortable table that passes ?sort= straight into orderBy() is asking for trouble. I map input to a fixed allow-list:
$sortable = ['created_at', 'amount', 'due_date'];
$column = in_array($request->query('sort'), $sortable, true) ? $request->query('sort') : 'created_at';
$direction = $request->query('dir') === 'asc' ? 'asc' : 'desc';
$invoices = $team->invoices()->orderBy($column, $direction)->paginate(25);The same applies to anything dynamic in a query: column names in filters, JSON paths and table names all come from code, never directly from the request.
5. XSS and CSRF
Blade's {{ $value }} escapes output with htmlspecialchars. {!! $value !!} prints raw HTML, and every use of it is a potential stored XSS. I only allow it for HTML the application generated itself, or user HTML that has gone through a proper HTML sanitizer library first. Two less obvious cases:
- URLs. Escaping does not stop
href="{{ $user->website }}"from beingjavascript:alert(1). I validate URLs with theurlrule and restrict them tohttpandhttps. - Data for JavaScript. I pass it with
@json($data)orJs::from($data)rather than echoing into a script block by hand.
CSRF protection is on by default for the web middleware group. Every form includes @csrf, and state-changing actions are never GET routes. The only routes I exclude from CSRF checks are third-party webhooks, and those verify the provider's signature instead. In Laravel 11 and later the exclusion lives in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['webhooks/stripe']);
})6. Authentication, password hashing and rate limiting
I do not write authentication from scratch. For server-rendered apps I start from a first-party starter kit or Fortify; for a single-page app or mobile client on the same top-level domain, Sanctum's cookie-based SPA authentication; for third-party or mobile API clients, Sanctum personal access tokens with abilities scoped to what the client needs:
$token = $user->createToken('reporting-app', ['invoices:read'])->plainTextToken;
// later, in a controller or policy
abort_unless($request->user()->tokenCan('invoices:read'), 403);I also set a token expiration in config/sanctum.php so leaked tokens do not live forever. Passwords always go through the Hash facade (bcrypt by default, Argon2 available) or the hashed model cast, never a custom scheme and never md5 or sha1:
$user->password = Hash::make($request->validated('password'));
if (! Hash::check($request->password, $user->password)) {
// failed login
}Login, password reset and 2FA endpoints are rate-limited per account and per IP, so credential stuffing and brute force hit a wall quickly:
// AppServiceProvider::boot()
RateLimiter::for('login', function (Request $request) {
return [
Limit::perMinute(5)->by(Str::lower($request->input('email')) . '|' . $request->ip()),
Limit::perMinute(20)->by($request->ip()),
];
});
// routes/web.php
Route::post('/login', [LoginController::class, 'store'])->middleware('throttle:login');API routes get their own throttle limiter too, sized to what a legitimate client actually needs.
7. File uploads: validate, store privately, serve through authorization
Uploads are where custom apps most often end up with a web shell. My rules: validate type and size, never trust the client's file name, store outside the public web root, and serve files through a route that checks permission.
$request->validate([
'document' => ['required', 'file', 'mimes:pdf,jpg,png', 'max:5120'], // max in kilobytes
]);
// store() generates a random file name; 'private' is a disk under storage/app, not public/
$path = $request->file('document')->store("teams/{$team->id}/documents", 'private');
$team->documents()->create([
'path' => $path,
'original_name' => $request->file('document')->getClientOriginalName(),
]);The original name is kept only as display data, escaped on output. To download, the user hits a controller that authorizes against the owning model and streams the file. When I want a shareable link that expires, I use a temporary signed route, or Storage::temporaryUrl() when the files live on S3:
Route::get('/documents/{document}/download', [DocumentController::class, 'download'])
->name('documents.download')
->middleware(['auth', 'signed', 'can:view,document']);
$url = URL::temporarySignedRoute('documents.download', now()->addMinutes(10), ['document' => $document]);
// DocumentController::download()
return Storage::disk('private')->download($document->path, $document->original_name);Signed URLs prove the link was generated by the app and has not expired or been altered; they do not replace the authorization check, which is why the route keeps can:view. For images that users upload and other users view, I also configure the web server so nothing in any upload directory can ever execute as PHP.
8. Secrets, encrypted casts and logging
Secrets live in environment variables, read in config/*.php files, and accessed in code with config(). Data that is sensitive at rest, such as third-party API tokens a customer connects or 2FA secrets, gets an encrypted cast so the database holds ciphertext:
protected function casts(): array
{
return [
'api_token' => 'encrypted',
'integration_cfg' => 'encrypted:array',
'password' => 'hashed',
];
}Encrypted columns cannot be searched with WHERE, and they depend on APP_KEY, which is one more reason to handle key rotation as described above. Logging is the other place secrets leak. I never log $request->all(), full headers or third-party API responses wholesale; I log IDs and the fields I actually need. Laravel's exception handler already avoids flashing password fields back into sessions, and on PHP 8.2+ I mark sensitive parameters with #[\SensitiveParameter] so their values are hidden from stack traces.
9. Queues and jobs: do not trust the payload blindly
Jobs run outside the HTTP request, with no logged-in user and no middleware. That leads to two habits:
- Authorize before dispatching, and pass model instances or IDs rather than raw request arrays. Inside the job, re-check state that could have changed while it was queued, such as whether the invoice is still unpaid or the user still belongs to the team.
- Protect sensitive payloads. Jobs that carry personal data or tokens implement
ShouldBeEncrypted, so the payload is encrypted in Redis or the database queue. The queue backend itself is private: Redis bound to localhost or a private network with a password, never exposed to the internet.
Webhook handlers that dispatch jobs verify the sender's signature first, because a forged webhook becomes a trusted job one step later.
10. Security headers and dependency hygiene
Laravel does not set security headers for you, so I add a small middleware to the web group:
class SecurityHeaders
{
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', 'DENY');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
return $response;
}
}
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [\App\Http\Middleware\SecurityHeaders::class]);
})A Content-Security-Policy is worth adding too, but it has to be written for the specific app's scripts and third-party services, so I build and test it per project rather than copy one. Cookies are set with secure and http_only in config/session.php (SESSION_SECURE_COOKIE=true behind HTTPS).
Dependencies are the other half. Composer can check the lock file against known advisories, and I run it in CI so a vulnerable package fails the build:
composer audit
composer install --no-dev --optimize-autoloader # in production
npm audit --omit=dev # front-end build dependenciesI also keep the Laravel major version within its supported window. Running a framework version that no longer receives security fixes quietly undoes everything else in this list.
11. Deployment: web root, permissions and caches
The web server's document root points at public/, never the project root. Otherwise .env, storage/logs and composer.json can end up downloadable. In Nginx:
server {
server_name app.example.com;
root /var/www/app/current/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php-fpm.sock;
}
location ~ /\.(?!well-known) {
deny all;
}
}Code is owned by a deploy user and not writable by PHP; only storage/ and bootstrap/cache/ are writable by the PHP-FPM user. No 777, ever. Each deploy then runs the production caches:
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cacheOne side effect worth knowing: once config is cached, env() returns null outside the config files, which is another reason to only read environment variables in config/*.php. I automate all of this in a pipeline, as in my guide to deploying to a server with GitHub Actions, and harden the server underneath using my Linux server hardening checklist.
The Laravel security checklist
APP_ENV=productionandAPP_DEBUG=falsein production..envnever committed and outside the web root; secrets rotated if it ever leaked.- Unique
APP_KEYper environment; rotation viaAPP_PREVIOUS_KEYS. - Form Requests on every write; only
validated()data reaches models. $fillableallow-lists; no$guarded = [].- A Policy for every user-owned model, enforced on every route; scoped bindings for nested resources.
- No user input interpolated into
whereRaw,DB::raworDB::select; column names from allow-lists. {!! !!}only for trusted or sanitized HTML.- CSRF on all web routes; webhooks verified by signature.
- Sanctum or a starter kit for auth;
Hashfor passwords; login and API rate limits. - Uploads validated, randomly named, stored privately and served through authorization.
- Encrypted casts for sensitive columns; no secrets in logs.
- Jobs authorized at dispatch, state re-checked in the job, sensitive jobs encrypted.
- Security headers middleware and secure session cookies.
composer auditin CI; supported Laravel version.- Web root is
public/; onlystorage/andbootstrap/cache/writable; config, routes and views cached.
How I build a secure Laravel application on custom projects
The checklist catches common mistakes, but the bugs that hurt custom apps are usually specific to their business rules: who can approve what, which tenant owns which record, what a refund is allowed to change. So on every custom software development project I work in this order:
- Threat model up front. Before writing code I list the roles, the data each role may see and change, the external inputs (forms, APIs, webhooks, uploads, imports) and what an attacker would want. That list becomes the Policies.
- Authorization tests, not just feature tests. For every sensitive route I write a test that another user or another tenant gets a 403 or 404. These are cheap and catch IDOR regressions forever:
public function test_user_cannot_view_another_teams_invoice(): void
{
$invoice = Invoice::factory()->create(); // belongs to some other team
$user = User::factory()->withTeam()->create();
$this->actingAs($user)
->get(route('invoices.show', $invoice))
->assertForbidden();
}- Security-focused code review. Every change that touches raw queries,
{!! !!}, uploads, auth or a new route gets reviewed against the checklist above before it merges. - Hardened hosting. The application is only as safe as the server it runs on, so I set up firewalls, SSH, TLS and updates as part of the handover, or through my server security hardening service for existing servers.
If you need a new Laravel application built this way, or want an existing one reviewed against this list, that is what my Laravel development service covers. Small reviews and fixes are billed at $20/hour; larger builds get a fixed price agreed in writing.
Frequently asked questions
Is Laravel secure by default?
Laravel's defaults are good: Blade escapes output, forms are CSRF-protected, Eloquent uses parameter binding and passwords are hashed with bcrypt. Most Laravel security problems come from bypassing those defaults, such as raw SQL with user input, {!! !!} output, $guarded = [] or missing authorization checks, and from production misconfiguration like APP_DEBUG=true.
Why is APP_DEBUG=true dangerous in production?
Debug mode shows detailed error pages with stack traces, file paths and potentially configuration values and credentials. In older Ignition versions it also enabled a remote code execution vulnerability, CVE-2021-3129. Production should always run with APP_ENV=production and APP_DEBUG=false.
Should I use $fillable or $guarded in Laravel models?
I use $fillable, an explicit allow-list of attributes that can be mass assigned, and pass only $request->validated() data to create and update. $guarded = [] allows every attribute, so a user who adds is_admin or team_id to a request can change fields they should never control.
How do I prevent IDOR in a Laravel app?
Create a Policy for every model users can access, enforce it with the can middleware, Gate::authorize or a Form Request's authorize method, use scoped route model binding for nested resources, and query lists through the user's or tenant's relationship instead of the whole table. Back it with tests that another user gets a 403 or 404.
What happens if my Laravel APP_KEY leaks?
Anyone with the key can decrypt your encrypted values and cookies and forge signed data. Generate a new key, move the old one to APP_PREVIOUS_KEYS (Laravel 11 and later) so existing data can still be decrypted, re-encrypt sensitive stored values with the new key, then remove the old key. Password hashes do not depend on APP_KEY.