Laravel 12 Security Best Practices: How to Protect Your SaaS from Hackers in 2026

By LaraSpeed Team · · 19 min read

1. Why SaaS Security Is Non-Negotiable in 2026

In 2025, data breaches cost companies an average of $4.88 million per incident (IBM Cost of a Data Breach Report). For SaaS applications, the stakes are even higher — your users trust you with their data, their payment information, and their business operations. One breach can destroy that trust overnight.

Laravel is one of the most secure PHP frameworks out of the box. It protects against SQL injection, XSS, CSRF, and mass assignment by default. But defaults aren't enough. Every raw query, every file upload, every API endpoint you add is a potential attack surface.

This guide covers 15 security best practices specifically for Laravel SaaS applications. Whether you're launching your first product or hardening an existing one, each section includes real code examples and common mistakes we've seen in production apps.

Let's make your Laravel SaaS bulletproof.

2. SQL Injection & Query Safety

Laravel's Eloquent ORM and Query Builder use PDO parameter binding by default, which means most of your queries are safe from SQL injection. But developers routinely bypass this protection without realizing it.

The Dangerous Pattern

Never interpolate user input into raw queries:

// DANGEROUS - SQL injection vulnerability
$users = DB::select("SELECT * FROM users WHERE email = '$request->email'");

// DANGEROUS - Raw column names from user input
$users = User::orderBy($request->sort_column)->get();

// DANGEROUS - Raw whereRaw without bindings
$users = User::whereRaw("name LIKE '%{$request->search}%'")->get();

The Safe Pattern

// SAFE - Parameter binding
$users = DB::select("SELECT * FROM users WHERE email = ?", [$request->email]);

// SAFE - Whitelist allowed columns
$allowed = ['name', 'email', 'created_at'];
$column = in_array($request->sort_column, $allowed) ? $request->sort_column : 'created_at';
$users = User::orderBy($column)->get();

// SAFE - whereRaw with bindings
$users = User::whereRaw("name LIKE ?", ['%' . $request->search . '%'])->get();

// EVEN BETTER - Use Eloquent where clause
$users = User::where('name', 'like', '%' . $request->search . '%')->get();

Watch Out for JSON Columns

JSON column queries can also be vulnerable if you pass user input as the column path:

// DANGEROUS - User controls the JSON path
$value = User::where("settings->{$request->key}", true)->get();

// SAFE - Whitelist allowed JSON paths
$allowedKeys = ['notifications', 'theme', 'language'];
if (in_array($request->key, $allowedKeys)) {
    $value = User::where("settings->{$request->key}", true)->get();
}

3. Cross-Site Scripting (XSS) Prevention

Blade's {{ }} syntax automatically escapes output using htmlspecialchars(). This protects you from most XSS attacks. The danger comes when you bypass this on purpose.

Never Trust {!! !!} with User Input

// DANGEROUS - Renders raw HTML from user input
{!! $user->bio !!}

// DANGEROUS - JavaScript event handlers
<div data-value="{{ $userInput }}"> // Safe in HTML context
<a href="{{ $userInput }}">Link</a> // DANGEROUS if $userInput = "javascript:alert(1)"

// SAFE - Always escape, sanitize if HTML is needed
{{ $user->bio }}

// If you MUST allow HTML (e.g., rich text editor), sanitize it:
{!! clean($user->bio) !!} // Using mews/purifier package

Install HTML Purifier for Rich Content

composer require mews/purifier

// In your model or service
use Mews\Purifier\Facades\Purifier;

public function setBioAttribute($value)
{
    $this->attributes['bio'] = Purifier::clean($value);
}

Protect JavaScript Context

// DANGEROUS - Injecting into JavaScript
<script>
    let userName = "{{ $user->name }}"; // XSS if name contains quotes
</script>

// SAFE - Use @json directive
<script>
    let userName = @json($user->name);
</script>

Pro tip: Add a Content Security Policy header (covered in section 10) to prevent inline scripts from executing, even if an attacker manages to inject them.

4. CSRF Protection & SPA Pitfalls

Laravel includes CSRF protection out of the box via the VerifyCsrfToken middleware. Every POST, PUT, PATCH, and DELETE request must include a valid CSRF token. But there are common mistakes that weaken this protection.

Don't Over-Exclude Routes

// DANGEROUS - Excluding too many routes from CSRF
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'api/*',          // Fine if using Sanctum tokens
    'webhook/*',      // Fine - webhooks can't use CSRF
    'payment/*',      // DANGEROUS - if this serves browser forms
    'settings/*',     // DANGEROUS - never exclude user-facing routes
];

Only exclude routes that genuinely cannot send a CSRF token — like incoming webhooks from Stripe or GitHub.

SPA + Sanctum CSRF Flow

If you're building a SPA (React, Vue, Inertia) with Laravel Sanctum, you need to fetch the CSRF cookie before making requests:

// In your SPA, before any POST/PUT/DELETE request:
await fetch('/sanctum/csrf-cookie', { credentials: 'include' });

// Then your subsequent requests will include the XSRF-TOKEN cookie
await fetch('/api/settings', {
    method: 'POST',
    credentials: 'include',
    headers: {
        'Content-Type': 'application/json',
        'X-XSRF-TOKEN': getCookie('XSRF-TOKEN'),
    },
    body: JSON.stringify(data),
});

Make sure SANCTUM_STATEFUL_DOMAINS in your .env matches your SPA's domain exactly. A misconfigured stateful domain is one of the most common Sanctum bugs.

5. Authentication Hardening & 2FA

Authentication is the front door to your SaaS. A weak door means everything behind it is exposed.

Enforce Strong Passwords

use Illuminate\Validation\Rules\Password;

// In your registration/password change request
$request->validate([
    'password' => [
        'required',
        'confirmed',
        Password::min(8)
            ->mixedCase()
            ->numbers()
            ->symbols()
            ->uncompromised(), // Checks against Have I Been Pwned API
    ],
]);

The uncompromised() rule checks the password against the Have I Been Pwned database using k-anonymity (your password is never sent in full). This prevents users from setting passwords that have already appeared in data breaches.

Add Two-Factor Authentication (TOTP)

2FA dramatically reduces account takeover risk. Here's a minimal implementation using the pragmarx/google2fa-laravel package:

composer require pragmarx/google2fa-laravel
composer require bacon/bacon-qr-code

// Generate secret for user
use PragmaRX\Google2FA\Google2FA;

$google2fa = new Google2FA();
$secret = $google2fa->generateSecretKey();

// Store $secret encrypted in user record
$user->update([
    'two_factor_secret' => encrypt($secret),
]);

// Verify OTP during login
$valid = $google2fa->verifyKey(
    decrypt($user->two_factor_secret),
    $request->one_time_password
);

if (! $valid) {
    throw ValidationException::withMessages([
        'one_time_password' => 'The provided code is invalid.',
    ]);
}

Session Security

// config/session.php - Recommended production settings
'lifetime' => 120,         // Session expires after 2 hours
'expire_on_close' => false,
'encrypt' => true,         // Encrypt session data
'secure' => true,          // Cookie only sent over HTTPS
'http_only' => true,       // Not accessible via JavaScript
'same_site' => 'lax',     // Prevents CSRF from external sites

// Regenerate session ID after login to prevent session fixation
Auth::login($user);
$request->session()->regenerate();

Important: Always call $request->session()->regenerate() after authentication and $request->session()->invalidate() on logout. This prevents session fixation attacks.

6. Authorization: Policies, Gates & Multi-Tenancy Leaks

The #1 security vulnerability in SaaS applications isn't SQL injection or XSS — it's broken authorization. Also known as IDOR (Insecure Direct Object Reference), this is when User A can access or modify User B's data simply by changing an ID in the URL.

Always Use Policies

// DANGEROUS - No authorization check
public function show(Invoice $invoice)
{
    return view('invoices.show', compact('invoice'));
    // User A can view User B's invoice by guessing the ID!
}

// SAFE - Policy-based authorization
public function show(Invoice $invoice)
{
    $this->authorize('view', $invoice);
    return view('invoices.show', compact('invoice'));
}

// app/Policies/InvoicePolicy.php
public function view(User $user, Invoice $invoice): bool
{
    return $user->team_id === $invoice->team_id;
}

Multi-Tenancy: Global Scopes Prevent Data Leaks

In a multi-tenant SaaS, every query must be scoped to the current team. A global scope ensures you never accidentally leak data across tenants:

// app/Models/Scopes/TeamScope.php
class TeamScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (auth()->check()) {
            $builder->where('team_id', auth()->user()->current_team_id);
        }
    }
}

// In your model
class Project extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new TeamScope);

        // Also enforce on create
        static::creating(function (Project $project) {
            $project->team_id ??= auth()->user()->current_team_id;
        });
    }
}

With this pattern, Project::all() automatically returns only the current team's projects. No developer can accidentally forget to add a where clause.

7. Rate Limiting & Brute-Force Protection

Without rate limiting, attackers can brute-force passwords, scrape data, and exhaust your API. Laravel makes rate limiting easy with the RateLimiter facade.

Define Rate Limiters

// bootstrap/app.php or AppServiceProvider
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

// General API rate limit
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

// Strict limit for login attempts
RateLimiter::for('login', function (Request $request) {
    return [
        Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip()),
        Limit::perHour(30)->by($request->ip()),
    ];
});

// Strict limit for password reset (prevent email bombing)
RateLimiter::for('password-reset', function (Request $request) {
    return Limit::perHour(3)->by($request->input('email') . '|' . $request->ip());
});

// Webhook endpoints - higher limit for legitimate services
RateLimiter::for('webhooks', function (Request $request) {
    return Limit::perMinute(200)->by($request->ip());
});

Apply to Routes

// routes/web.php
Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

Route::post('/forgot-password', [PasswordResetController::class, 'send'])
    ->middleware('throttle:password-reset');

// routes/api.php - All API routes get the default limiter
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    Route::apiResource('projects', ProjectController::class);
});

Pro tip: Return 429 Too Many Requests with a Retry-After header. Laravel does this automatically when you use the throttle middleware.

8. API Security with Sanctum & Token Scoping

If your SaaS exposes an API, Laravel Sanctum is the go-to authentication package. But token management has its own set of pitfalls.

Scope Tokens to Specific Abilities

// When creating API tokens, always scope them
$token = $user->createToken('deployment-bot', [
    'projects:read',
    'deployments:create',
    // Don't give 'billing:manage' to a deployment bot!
]);

// In your controller, check the ability
public function store(Request $request)
{
    if (! $request->user()->tokenCan('deployments:create')) {
        abort(403, 'Token does not have permission to create deployments.');
    }

    // ... create deployment
}

Set Token Expiration

// config/sanctum.php
'expiration' => 43200, // Tokens expire after 30 days (in minutes)

// For short-lived tokens (e.g., CI/CD)
$token = $user->createToken(
    'ci-pipeline',
    ['deployments:create'],
    now()->addHours(1) // Expires in 1 hour
);

Never Expose Internal IDs in API Responses

// DANGEROUS - Exposes auto-increment IDs (makes enumeration easy)
// GET /api/users/1, /api/users/2, /api/users/3...

// SAFE - Use UUIDs or ULIDs for public-facing resources
use Illuminate\Database\Eloquent\Concerns\HasUuids;

class Project extends Model
{
    use HasUuids;
}

// Now: GET /api/projects/01HQ3K5X... (not guessable)

9. Encryption, Hashing & Secrets Management

Laravel uses AES-256-CBC encryption (via APP_KEY) and Bcrypt hashing by default. Here's how to use them correctly.

Encrypt Sensitive Data at Rest

use Illuminate\Database\Eloquent\Casts\Attribute;

class User extends Model
{
    // Automatic encryption/decryption with Eloquent casts
    protected function casts(): array
    {
        return [
            'api_key' => 'encrypted',
            'two_factor_secret' => 'encrypted',
            'ssn' => 'encrypted',
        ];
    }
}

// Now these fields are automatically encrypted in the database
// and decrypted when accessed via the model
$user->api_key = 'sk_live_abc123'; // Stored encrypted
echo $user->api_key; // Returns 'sk_live_abc123'

Never Store Secrets in Code

// DANGEROUS - Hardcoded secrets
$stripe = new StripeClient('sk_live_abc123');

// SAFE - Use environment variables
$stripe = new StripeClient(config('services.stripe.secret'));

// .env (NEVER committed to git)
STRIPE_SECRET=sk_live_abc123

// .gitignore
.env
.env.production
*.pem
*.key

Rotate APP_KEY Without Breaking Data

If your APP_KEY is compromised, you need to rotate it. Laravel 12 supports graceful key rotation:

# Add new key as primary, keep old key for decryption
APP_KEY=base64:NEW_KEY_HERE
APP_PREVIOUS_KEYS=base64:OLD_KEY_HERE

# Laravel will encrypt with the new key and can still
# decrypt data encrypted with previous keys

10. Security Headers & HTTPS Enforcement

Security headers tell browsers how to handle your content. Without them, you're leaving the door open to clickjacking, MIME-type attacks, and unauthorized embedding.

Add Security Headers via Middleware

// app/Http/Middleware/SecurityHeaders.php
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('X-XSS-Protection', '1; mode=block');
        $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; preload'
        );
        $response->headers->set(
            'Content-Security-Policy',
            "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'"
        );

        return $response;
    }
}

// Register in bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->append(SecurityHeaders::class);
})

Force HTTPS Everywhere

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    if ($this->app->environment('production')) {
        URL::forceScheme('https');
    }
}

// Also set in .env
APP_URL=https://yourdomain.com

// Redirect HTTP to HTTPS in Nginx
server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$server_name$request_uri;
}

11. Secure File Uploads

File uploads are a common attack vector. An attacker can upload a PHP file disguised as an image and execute arbitrary code on your server.

Validate Everything

$request->validate([
    'avatar' => [
        'required',
        'file',
        'image',                    // Only allow image MIME types
        'mimes:jpg,jpeg,png,webp',  // Whitelist extensions
        'max:2048',                 // Max 2MB
        'dimensions:max_width=2000,max_height=2000',
    ],
    'document' => [
        'required',
        'file',
        'mimes:pdf,doc,docx',
        'max:10240',               // Max 10MB
    ],
]);

Store Outside the Public Directory

// DANGEROUS - Storing in public directory
$path = $request->file('avatar')->store('avatars', 'public');

// SAFER - Store in non-public disk, serve via controller
$path = $request->file('document')->store('documents', 's3');

// Serve via signed URL (expires after 5 minutes)
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(5));

// Or serve via controller with authorization
public function download(Document $document)
{
    $this->authorize('download', $document);
    return Storage::disk('s3')->download($document->path);
}

Critical: Never let users control the storage path. Always generate filenames server-side with $file->hashName() or Str::uuid().

12. Audit Logging & Intrusion Detection

You can't respond to a breach you don't know about. Audit logging tracks who did what, when, and from where in your application.

Track Critical Actions

// app/Services/AuditLogger.php
class AuditLogger
{
    public static function log(string $action, ?Model $model = null, array $extra = []): void
    {
        AuditLog::create([
            'user_id' => auth()->id(),
            'team_id' => auth()->user()?->current_team_id,
            'action' => $action,
            'model_type' => $model ? get_class($model) : null,
            'model_id' => $model?->id,
            'ip_address' => request()->ip(),
            'user_agent' => request()->userAgent(),
            'extra' => $extra,
        ]);
    }
}

// Usage in controllers
AuditLogger::log('team.member.removed', $member, [
    'removed_by' => auth()->user()->name,
    'reason' => $request->reason,
]);

AuditLogger::log('billing.plan.changed', $subscription, [
    'from' => 'pro',
    'to' => 'team',
]);

AuditLogger::log('user.login.failed', null, [
    'email' => $request->email,
]);

Detect Suspicious Behavior

// Listen for too many failed login attempts
// app/Listeners/DetectBruteForce.php
class DetectBruteForce
{
    public function handle(Failed $event): void
    {
        $recentFailures = AuditLog::where('action', 'user.login.failed')
            ->where('ip_address', request()->ip())
            ->where('created_at', '>=', now()->subMinutes(15))
            ->count();

        if ($recentFailures >= 10) {
            // Alert your team via Slack, email, or PagerDuty
            Notification::route('slack', config('services.slack.security_channel'))
                ->notify(new SuspiciousLoginActivity(request()->ip(), $recentFailures));
        }
    }
}

13. Dependency Scanning & Updates

Your app is only as secure as its weakest dependency. Outdated packages with known vulnerabilities are low-hanging fruit for attackers.

Audit Regularly

# Check PHP dependencies for known vulnerabilities
composer audit

# Check npm dependencies
npm audit

# Run both in CI/CD (GitHub Actions example)
# .github/workflows/security.yml
name: Security Audit
on:
  schedule:
    - cron: '0 8 * * 1'  # Every Monday at 8 AM
  push:
    branches: [main]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: composer audit --format=json
      - run: npm audit --audit-level=high

Keep Laravel & Dependencies Updated

Subscribe to Laravel security advisories and update promptly when patches are released. Use Dependabot or Renovate to automate dependency updates:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "composer"
    directory: "/"
    schedule:
      interval: "weekly"
    labels: ["dependencies", "php"]
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    labels: ["dependencies", "js"]

14. Production Security Checklist

Run through this checklist before every launch and audit it quarterly:

  1. APP_DEBUG=false — Never expose stack traces, query dumps, or environment variables in production.
  2. APP_KEY is unique & secret — Never share it, never commit it, rotate if compromised.
  3. HTTPS is enforced — SSL certificate installed, HTTP redirects to HTTPS, HSTS header set.
  4. Security headers are set — CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy.
  5. Authentication is hardened — Strong password rules, 2FA available, session config tightened.
  6. Rate limiting is active — Login, API, password reset, and registration routes are throttled.
  7. Authorization checks exist — Every controller action checks policies. No IDOR vulnerabilities.
  8. Multi-tenancy is scoped — Global scopes prevent cross-tenant data access.
  9. SQL queries use bindings — No raw user input in queries. whereRaw always uses ? placeholders.
  10. XSS is prevented — No {!! !!} with unsanitized user input. @json used for JS context.
  11. File uploads are validated — MIME type, extension, and size validated. Stored outside public directory.
  12. Sensitive data is encrypted — API keys, tokens, and PII use the encrypted cast.
  13. Dependencies are auditedcomposer audit and npm audit run in CI.
  14. Audit logging is active — Login attempts, permission changes, and data exports are logged.
  15. .env is not in git — Verify with git log --all -- .env (if it was ever committed, rotate all secrets).
  16. Error tracking is live — Sentry/Flare captures exceptions without exposing details to users.
  17. Backups are encrypted — Database backups are encrypted and stored offsite.

15. Conclusion

Security isn't a feature you add once — it's a practice you maintain. Laravel gives you excellent defaults, but every line of code you write is a chance to introduce a vulnerability. The patterns in this guide — parameter binding, policy-based authorization, tenant scoping, rate limiting, encryption at rest, security headers, and audit logging — form the security foundation every SaaS needs.

Start with the checklist in section 14. Fix what's missing. Then schedule a quarterly review. Security is a moving target, and the apps that stay safe are the ones that treat it as an ongoing process, not a one-time task.

LaraSpeed ships with these security practices built in. Two-factor authentication, encrypted sensitive fields, rate-limited endpoints, tenant-scoped queries, security headers middleware, and audit logging are all configured out of the box. You get a secure foundation from day one so you can focus on building features, not patching vulnerabilities.

Launch your SaaS on a secure foundation

LaraSpeed gives you a production-ready Laravel SaaS with 2FA, encryption, rate limiting, tenant isolation, audit logging, and security headers — all wired together and battle-tested.

Get LaraSpeed — Starting at $49

Ready to Ship Your SaaS?

Skip the weeks of boilerplate setup. Get a production-ready Laravel SaaS foundation with full source code, one-time purchase.

Get LaraSpeed — Starting at $49

One-time purchase · 14-day money-back guarantee