Security Guide
Last Updated: 2026-01-18 Status: Active Audience: Developers
This guide documents security best practices and implementations for the Client Portal application.
Table of Contents
- Overview
- Authentication
- Authorization
- Input Validation
- Output Escaping
- CSRF Protection
- SQL Injection Prevention
- Rate Limiting
- API Key Security
- System Settings Authorization
- Search Result Sanitization
- File Upload Security
- Environment Management
- Security Checklist
Overview
Security is implemented at multiple layers:
| Layer | Protection |
|---|---|
| Authentication | Laravel Breeze, email verification |
| Authorization | Policies, Gates, middleware |
| Input | Form Requests, validation rules |
| Output | Blade escaping, Content Security Policy |
| Database | Eloquent ORM, parameterized queries |
| Transport | HTTPS, secure cookies |
Authentication
Configuration
Authentication is handled by Laravel Breeze with the following features:
- Email/password login
- Password reset via email
- Email verification (MustVerifyEmail)
- Remember me functionality
- Session-based authentication
Password Requirements
// config/auth.php or validation rules
use Illuminate\Validation\Rules\Password;
Password::min(8)
->mixedCase()
->numbers()
->symbols()
->uncompromised(); // Check against breached password databases
Session Security
// config/session.php
return [
'driver' => env('SESSION_DRIVER', 'database'),
'lifetime' => 120,
'expire_on_close' => false,
'encrypt' => true,
'secure' => env('SESSION_SECURE_COOKIE', true), // HTTPS only
'http_only' => true, // Not accessible via JavaScript
'same_site' => 'lax', // CSRF protection
];
Login Throttling
Laravel Breeze includes built-in throttling:
// Configured in App\Http\Requests\Auth\LoginRequest
RateLimiter::hit($this->throttleKey());
// 5 attempts per minute by default
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => RateLimiter::availableIn($this->throttleKey()),
]),
]);
}
Authorization
Role-Based Access Control
The application uses two roles:
admin- Full access to all featuresclient- Access to own data only
// app/Models/User.php
public function isAdmin(): bool
{
return $this->role === 'admin';
}
public function isClient(): bool
{
return $this->role === 'client';
}
Middleware Protection
// routes/web.php
// Admin-only routes
Route::middleware(['auth', 'verified', 'admin'])->prefix('admin')->group(function () {
Route::resource('clients', Admin\ClientController::class);
});
// Portal routes (authenticated clients)
Route::middleware(['auth', 'verified'])->prefix('portal')->group(function () {
Route::get('/', [PortalController::class, 'dashboard'])->name('portal.dashboard');
});
Admin Middleware
// app/Http/Middleware/EnsureUserIsAdmin.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next)
{
if (!$request->user() || !$request->user()->isAdmin()) {
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
Policies
Use policies for resource-level authorization:
// app/Policies/ProjectPolicy.php
namespace App\Policies;
use App\Models\Project;
use App\Models\User;
class ProjectPolicy
{
/**
* Admins can do anything.
*/
public function before(User $user, string $ability): ?bool
{
if ($user->isAdmin()) {
return true;
}
return null;
}
public function view(User $user, Project $project): bool
{
return $user->belongsToClient($project->client);
}
public function update(User $user, Project $project): bool
{
return $user->belongsToClient($project->client);
}
public function delete(User $user, Project $project): bool
{
return false; // Only admins can delete
}
}
Using Policies
// In controllers
public function show(Project $project)
{
$this->authorize('view', $project);
return view('portal.projects.show', compact('project'));
}
// In Blade templates
@can('update', $project)
<a href="{{ route('projects.edit', $project) }}">Edit</a>
@endcan
Input Validation
Form Requests
Always use Form Request classes for validation:
// app/Http/Requests/StoreClientRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreClientRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
return [
'company_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:clients,email'],
'phone' => ['nullable', 'string', 'max:20'],
'address' => ['nullable', 'string', 'max:500'],
];
}
public function messages(): array
{
return [
'company_name.required' => 'Company name is required.',
'email.unique' => 'This email is already registered.',
];
}
}
Common Validation Rules
// String validation
'name' => ['required', 'string', 'min:2', 'max:255'],
// Email
'email' => ['required', 'email:rfc,dns', 'max:255'],
// Password
'password' => ['required', 'confirmed', Password::defaults()],
// File uploads
'document' => ['required', 'file', 'mimes:pdf,doc,docx', 'max:10240'],
'image' => ['required', 'image', 'mimes:jpg,png', 'max:2048'],
// Numbers
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
'price' => ['required', 'numeric', 'min:0', 'max:999999.99'],
// Dates
'due_date' => ['required', 'date', 'after:today'],
// Foreign keys
'client_id' => ['required', 'exists:clients,id'],
// Enums
'status' => ['required', Rule::enum(InvoiceStatus::class)],
Sanitization
// In Form Request
protected function prepareForValidation(): void
{
$this->merge([
'email' => strtolower(trim($this->email)),
'phone' => preg_replace('/[^0-9+]/', '', $this->phone),
]);
}
Output Escaping
Blade Escaping
Blade automatically escapes output with {{ }}:
{{-- Safe - HTML entities are escaped --}}
<p>{{ $user->name }}</p>
{{-- Dangerous - Raw output, only use for trusted HTML --}}
<div>{!! $trustedHtml !!}</div>
When to Use Raw Output
Only use {!! !!} for:
- HTML generated by trusted code (e.g., markdown parsers with sanitization)
- Content from trusted admin users
- HTML stored after sanitization
// Sanitize HTML before storing
use Stevebauman\Purify\Facades\Purify;
$cleanHtml = Purify::clean($userInput);
JavaScript Data
{{-- Safe way to pass data to JavaScript --}}
<script>
const data = @json($data);
</script>
{{-- Or in Alpine.js --}}
<div x-data="{ items: {{ Js::from($items) }} }">
CSRF Protection
Form Protection
All forms must include the CSRF token:
<form method="POST" action="{{ route('clients.store') }}">
@csrf
{{-- form fields --}}
</form>
AJAX Requests
Include CSRF token in AJAX headers:
// Using fetch
fetch('/api/endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(data),
});
{{-- Add meta tag in layout --}}
<meta name="csrf-token" content="{{ csrf_token() }}">
Excluding Routes
For API endpoints with token authentication:
// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
'api/*', // API routes use token auth instead
'webhook/*', // Webhooks verified differently
];
SQL Injection Prevention
Use Eloquent ORM
Eloquent automatically uses parameterized queries:
// Safe - Parameters are bound
$client = Client::where('email', $email)->first();
$clients = Client::whereIn('id', $ids)->get();
// Safe - Using query builder
$invoices = DB::table('invoices')
->where('client_id', $clientId)
->where('status', $status)
->get();
Raw Queries (Use Carefully)
When raw queries are necessary, always use bindings:
// Safe - Using bindings
$results = DB::select(
'SELECT * FROM clients WHERE company_name LIKE ?',
['%' . $search . '%']
);
// Dangerous - NEVER do this
$results = DB::select("SELECT * FROM clients WHERE id = $id"); // SQL injection!
Dynamic Column Names
// Safe - Whitelist allowed columns
$allowedColumns = ['name', 'email', 'created_at'];
$sortBy = in_array($request->sort, $allowedColumns) ? $request->sort : 'created_at';
$clients = Client::orderBy($sortBy)->get();
Rate Limiting
Route Rate Limiting
// routes/api.php
Route::middleware(['throttle:api'])->group(function () {
Route::get('/clients', [ClientController::class, 'index']);
});
// Custom rate limiter in RouteServiceProvider
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Stricter limit for sensitive endpoints
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
Custom Rate Limiting
// For sensitive operations
use Illuminate\Support\Facades\RateLimiter;
public function sendInvoice(Invoice $invoice)
{
$key = 'send-invoice:' . auth()->id();
if (RateLimiter::tooManyAttempts($key, 10)) {
$seconds = RateLimiter::availableIn($key);
abort(429, "Too many requests. Try again in {$seconds} seconds.");
}
RateLimiter::hit($key, 60 * 60); // 1 hour decay
// Send invoice...
}
Impersonation Rate Limiting
Admin impersonation is rate limited to prevent abuse:
// AppServiceProvider.php
RateLimiter::for('impersonation', function (Request $request) {
return Limit::perHour(5)->by($request->user()?->id ?: $request->ip());
});
// ImpersonationController.php
$key = 'impersonation:' . $request->user()->id;
if (RateLimiter::tooManyAttempts($key, 5)) {
$seconds = RateLimiter::availableIn($key);
return back()->with('error', "Too many impersonation attempts. Try again in {$seconds} seconds.");
}
// Require detailed reason for audit trail
$request->validate([
'reason' => ['required', 'string', 'min:10', 'max:255'],
]);
API Key Security
Header-Only Authentication
API keys are only accepted via HTTP headers. Query parameter support has been removed to prevent key exposure in server logs and browser history.
// Accepted headers (AuthenticateApiKey middleware)
Authorization: Bearer <api_key>
X-API-Key: <api_key>
// Query parameters are NOT supported (security risk)
// ?api_key=xxx - This will NOT work
System Settings Authorization
System settings have granular authorization via SystemSettingPolicy:
// Protected setting keys (cannot be created via admin UI)
private const PROTECTED_KEYS = [
'auto_qualify_threshold',
'auto_decline_threshold',
'lead_scoring_weight_budget_alignment',
// ... other lead scoring keys
];
// Allowed groups for creating new settings
private const ALLOWED_CREATE_GROUPS = ['general', 'display', 'notifications'];
// Key pattern validation
'key' => ['required', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_]*$/'],
Search Result Sanitization
Search results are sanitized to prevent information disclosure:
// SearchController - Safe subtitle data only
protected function getSubtitle(string $type, int $id): string
{
return match ($type) {
'Client' => Client::find($id)?->stage?->label() ?? 'Client', // No email
'Project' => Project::find($id)?->client?->name ?? '',
'Invoice' => ucfirst(Invoice::find($id)?->status?->value ?? 'invoice'), // No total
default => '',
};
}
Never expose in search results:
- Email addresses
- Phone numbers
- Financial data (invoice totals, payment amounts)
- Internal notes
File Upload Security
Validation
// app/Http/Requests/FileUploadRequest.php
public function rules(): array
{
return [
'file' => [
'required',
'file',
'max:10240', // 10MB
// Note: ZIP files excluded for security (potential malicious payloads, ZIP bombs)
'mimes:pdf,doc,docx,xls,xlsx,png,jpg,jpeg,gif,txt,csv',
],
];
}
Restricted File Types
The following file types are not allowed for security reasons:
| Extension | Reason |
|---|---|
.zip, .rar, .7z | Potential for ZIP bombs, malicious payloads |
.exe, .bat, .cmd | Executable files |
.php, .js | Server-side code execution risk |
.html, .htm | XSS attack vectors |
Secure Storage
// Store in non-public directory
$path = $request->file('document')->store('project-files', 'local');
// Generate unique filename
$filename = Str::uuid() . '.' . $file->getClientOriginalExtension();
$path = $file->storeAs('project-files', $filename, 'local');
Secure Downloads
// app/Http/Controllers/FileController.php
public function download(ProjectFile $file)
{
$this->authorize('download', $file);
$path = Storage::disk('local')->path($file->path);
if (!file_exists($path)) {
abort(404);
}
return response()->download(
$path,
$file->original_filename,
['Content-Type' => $file->mime_type]
);
}
Image Processing
// Validate and process uploaded images
use Intervention\Image\Facades\Image;
$image = Image::make($request->file('avatar'));
// Strip EXIF data (may contain sensitive info)
$image->orientate();
// Resize to safe dimensions
$image->fit(200, 200);
$image->save(storage_path('app/avatars/' . $filename));
Environment Management
Required Variables
# .env.example - Document all required variables
APP_NAME="Client Portal"
APP_ENV=local
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=client_portal
DB_USERNAME=
DB_PASSWORD=
MAIL_MAILER=smtp
MAIL_HOST=
MAIL_USERNAME=
MAIL_PASSWORD=
Production Settings
# Production .env
APP_ENV=production
APP_DEBUG=false # NEVER true in production
SESSION_SECURE_COOKIE=true
SESSION_DRIVER=database # Or redis for better security
# Strong, unique key
APP_KEY=base64:...
Sensitive Data Protection
// Never log sensitive data
Log::info('User logged in', [
'user_id' => $user->id,
'email' => $user->email,
// 'password' => $password, // NEVER log passwords
]);
// Hide sensitive config values
// config/app.php
'key' => env('APP_KEY'), // Never commit actual keys
.gitignore
# Never commit these
.env
.env.local
.env.*.local
.env.production
storage/app/private/*
Security Checklist
Authentication
- Passwords hashed with bcrypt (Laravel default)
- Password minimum requirements enforced
- Login throttling enabled
- Session timeout configured
- Secure cookie settings enabled
- Email verification required
Authorization
- Admin middleware protecting admin routes
- Policies defined for all resources
- Users can only access their own data
- Sensitive actions require re-authentication
- System settings have authorization policy
- Impersonation requires reason and is rate-limited
API Security
- API keys only accepted via headers (not query params)
- Rate limiting on all API endpoints
- Proper authorization on chunked uploads
Input/Output
- All user input validated with Form Requests
- Output escaped in Blade templates
- File uploads validated and sanitized
- CSRF protection on all forms
- Search results sanitized (no sensitive data exposure)
File Upload
- ZIP files restricted (potential security risk)
- MIME type validation
- File size limits enforced
- Files stored in non-public directory
Database
- Using Eloquent/Query Builder (not raw SQL)
- Sensitive data encrypted at rest
- Database credentials not in code
Infrastructure
- HTTPS enforced in production
- Security headers configured
- Debug mode disabled in production
- Error details hidden from users
- Regular dependency updates
Monitoring
- Failed login attempts logged
- Suspicious activity alerts
- Regular security audits
- Impersonation actions audited
Security Headers
Security headers are configured in app/Http/Middleware/SecurityHeaders.php:
Headers Applied
| Header | Value | Purpose |
|---|---|---|
| Strict-Transport-Security | max-age=31536000; includeSubDomains | HTTPS only (production) |
| Content-Security-Policy | See below | Prevent XSS, injection |
| X-Content-Type-Options | nosniff | Prevent MIME sniffing |
| X-Frame-Options | SAMEORIGIN | Prevent clickjacking |
| X-XSS-Protection | 1; mode=block | Legacy XSS filter |
| Referrer-Policy | strict-origin-when-cross-origin | Control referrer info |
| Permissions-Policy | geolocation=(), camera=()... | Disable unused APIs |
Content Security Policy (CSP)
The CSP is dynamically built based on environment:
$directives = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'" . $viteUrls . $cdnUrls,
"style-src 'self' 'unsafe-inline'" . $viteUrls . $fontUrls,
"img-src 'self' data: blob:" . $viteUrls,
"font-src 'self' data:" . $fontUrls,
"connect-src 'self'" . $viteUrls . $wsUrls . $cdnUrls . $appDomains,
"frame-ancestors 'self'",
"form-action 'self'" . $appDomains,
"base-uri 'self'",
"object-src 'none'",
];
WebSocket CSP Requirements
Important: WebSocket connections require wss:// URLs in the connect-src directive. The middleware dynamically adds these based on Reverb configuration:
private function getReverbWsUrls(): string
{
$host = config('broadcasting.connections.reverb.options.host');
if (! $host) {
return '';
}
return " wss://{$host} ws://{$host}";
}
If WebSocket connections fail with CSP violations:
- Check browser console for blocked
wss://URLs - Verify
REVERB_HOSTis set in broadcasting config - Clear config cache:
php artisan config:cache - Restart queue workers
See REALTIME.md for detailed troubleshooting.
Production Configuration Checklist
The following environment variables must be set correctly in production:
Required Settings
| Variable | Value | Purpose |
|---|---|---|
APP_ENV | production | Enables production optimizations |
APP_DEBUG | false | Never true in production - exposes stack traces, env vars |
SESSION_SECURE_COOKIE | true | Cookies only sent over HTTPS |
SESSION_ENCRYPT | true | Encrypt session data at rest (default is false) |
Recommended Settings
| Variable | Value | Trade-offs |
|---|---|---|
SECURITY_CHECK_SESSION_IP | true | Binds sessions to IP; may cause issues with VPN/mobile users who change IPs frequently |
SESSION_DRIVER | database or redis | More secure than file driver; enables session management UI |
BCRYPT_ROUNDS | 12 | Stronger password hashing (default is 10); slight increase in login time |
Verification
After deployment, verify production settings:
# Check debug mode is off
php artisan tinker --execute="echo config('app.debug') ? 'WARNING: DEBUG ON' : 'OK: debug off';"
# Check session security
php artisan tinker --execute="echo config('session.secure') ? 'OK' : 'WARNING: cookies not secure';"
php artisan tinker --execute="echo config('session.encrypt') ? 'OK' : 'WARNING: sessions not encrypted';"
Incident Response
If a Security Issue is Found
- Assess the scope and severity
- Contain - Disable affected features if needed
- Investigate - Review logs, identify affected data
- Remediate - Fix the vulnerability
- Notify - Inform affected users if data was exposed
- Document - Record the incident and response
Reporting Security Issues
Security vulnerabilities should be reported privately, not via public GitHub issues.