Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Backend Features/Security

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

  1. Overview
  2. Authentication
  3. Authorization
  4. Input Validation
  5. Output Escaping
  6. CSRF Protection
  7. SQL Injection Prevention
  8. Rate Limiting
  9. API Key Security
  10. System Settings Authorization
  11. Search Result Sanitization
  12. File Upload Security
  13. Environment Management
  14. Security Checklist

Overview

Security is implemented at multiple layers:

LayerProtection
AuthenticationLaravel Breeze, email verification
AuthorizationPolicies, Gates, middleware
InputForm Requests, validation rules
OutputBlade escaping, Content Security Policy
DatabaseEloquent ORM, parameterized queries
TransportHTTPS, 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 features
  • client - 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:

ExtensionReason
.zip, .rar, .7zPotential for ZIP bombs, malicious payloads
.exe, .bat, .cmdExecutable files
.php, .jsServer-side code execution risk
.html, .htmXSS 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

HeaderValuePurpose
Strict-Transport-Securitymax-age=31536000; includeSubDomainsHTTPS only (production)
Content-Security-PolicySee belowPrevent XSS, injection
X-Content-Type-OptionsnosniffPrevent MIME sniffing
X-Frame-OptionsSAMEORIGINPrevent clickjacking
X-XSS-Protection1; mode=blockLegacy XSS filter
Referrer-Policystrict-origin-when-cross-originControl referrer info
Permissions-Policygeolocation=(), 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:

  1. Check browser console for blocked wss:// URLs
  2. Verify REVERB_HOST is set in broadcasting config
  3. Clear config cache: php artisan config:cache
  4. Restart queue workers

See REALTIME.md for detailed troubleshooting.


Production Configuration Checklist

The following environment variables must be set correctly in production:

Required Settings

VariableValuePurpose
APP_ENVproductionEnables production optimizations
APP_DEBUGfalseNever true in production - exposes stack traces, env vars
SESSION_SECURE_COOKIEtrueCookies only sent over HTTPS
SESSION_ENCRYPTtrueEncrypt session data at rest (default is false)
VariableValueTrade-offs
SECURITY_CHECK_SESSION_IPtrueBinds sessions to IP; may cause issues with VPN/mobile users who change IPs frequently
SESSION_DRIVERdatabase or redisMore secure than file driver; enables session management UI
BCRYPT_ROUNDS12Stronger 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

  1. Assess the scope and severity
  2. Contain - Disable affected features if needed
  3. Investigate - Review logs, identify affected data
  4. Remediate - Fix the vulnerability
  5. Notify - Inform affected users if data was exposed
  6. Document - Record the incident and response

Reporting Security Issues

Security vulnerabilities should be reported privately, not via public GitHub issues.