Laravel security starts before the controller

How tenant boundaries, policies, state transitions, queues and negative tests become verifiable security invariants.

Laravel protects many technical defaults. It rotates sessions, verifies CSRF, escapes Blade output and binds query values. The framework does not know the rules of a product: which user may see an invoice, whether an approval may be reversed or what should happen when a queued job runs after a role change.

The relevant security property is not “the route has authentication middleware”. It is “no user can reach data or actions outside their tenant and current permission”.

That property must mean the same thing in the data model, application code and tests. Security then becomes verifiable instead of depending on one developer remembering every edge case.

From feature to security invariant

A feature usually describes the desired success. A security invariant adds what must never happen, even with manipulated requests, stale jobs or concurrent workflows.

Product function Plausible abuse Security invariant Evidence
View invoice replace the object ID current tenant and permitted role only cross-tenant feature test
Approve quotation approve one's own draft four-eyes rule and permitted state policy and transition test
Build export role revoked after dispatch authorise again during job execution queue integration test
Download document reuse an old signed link current policy still applies test after access revocation
Process webhook replay the event provider ID causes at most one mutation replay and concurrency test

Security boundaries of a Laravel platform

The application is reachable through more than controllers. Every connection to the core needs an explicit trust decision and a negative test.

Tenant isolation is a system property

A tenant_id check in a controller is not sufficient. The same boundary applies everywhere data is loaded, cached or delivered:

  • HTTP and API routes
  • policies and nested relationships
  • queue jobs, commands and scheduled tasks
  • exports, downloads and temporary links
  • cache keys, search and broadcast channels
  • administrative tools

Plain Route Model Binding resolves an object but does not authorise access. The policy therefore represents the complete business decision:

final class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): bool
    {
        return $user->tenant_id === $invoice->tenant_id
            && $user->is_active
            && $user->can('invoice.read');
    }
}

public function show(Invoice $invoice): InvoiceResource
{
    Gate::authorize('view', $invoice);

    return InvoiceResource::make($invoice);
}

The important test does not merely prove the allowed request. It deliberately submits a valid ID from another tenant:

it('does not expose an invoice from another tenant', function () {
    $user = User::factory()->for($tenantA)->create();
    $invoice = Invoice::factory()->for($tenantB)->create();

    $this->actingAs($user)
        ->getJson(route('invoices.show', $invoice))
        ->assertForbidden();
});

Authorisation names actions, not merely roles

Admin and editor rarely describe a business permission precisely enough. Useful policies use product language: approve, cancel, refund, publish or export.

One decision may depend on several conditions:

  • Does the object belong to the current tenant?
  • Does the user hold this exact capability?
  • May the user approve their own change?
  • Is the object in a permitted state?
  • Has a deadline or amount threshold been exceeded?

A hidden button answers none of these questions. The interface explains a decision; the server-side policy enforces it.

State transitions are the real attack surface

Many business-logic vulnerabilities appear between two individually valid states. An order may be editable but must not jump from draft to paid. An approval may be allowed only from pending and must not be performed by its author.

From Action To Additional condition
draft submit pending required data is complete
pending approve approved a different authorised person
approved execute completed approved version is unchanged
completed correct new operation no silent reverse mutation

State verification and mutation belong in one atomic operation:

DB::transaction(function () use ($invoice, $user): void {
    $locked = Invoice::query()->lockForUpdate()->findOrFail($invoice->id);

    Gate::forUser($user)->authorize('approve', $locked);
    throw_unless($locked->status === InvoiceStatus::Pending, DomainException::class);

    $locked->update([
        'status' => InvoiceStatus::Approved,
        'approved_by' => $user->id,
        'approved_at' => now(),
    ]);
});

lockForUpdate() is not a general security switch. It locks this row only within an appropriate database transaction. Business rules and database constraints remain necessary.

Input reaches the system through many paths

Form Requests provide a useful HTTP boundary, but data also arrives through webhooks, CSV imports, queue payloads, CLI commands and internal tools. Every source remains untrusted until its structure and relationship to the current context have been verified.

final class UpdateProfileRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:120'],
            'timezone' => ['required', Rule::in(DateTimeZone::listIdentifiers())],
        ];
    }
}

// Only validated fields for this use case reach Eloquent.
$user->update($request->safe()->only(['name', 'timezone']));

Status, prices, roles, tenant_id and approval fields do not come from this request. They are derived by the already authorised application action.

A queued job does not inherit a trusted user context

A job runs later without the original HTTP request. Users may be suspended, permissions revoked or objects moved between dispatch and execution. The job therefore carries identifiers, rebuilds context and authorises the current situation.

final class BuildInvoiceExport implements ShouldQueue
{
    public function __construct(
        public readonly int $tenantId,
        public readonly int $requestedBy,
        public readonly int $exportId,
    ) {}

    public function handle(): void
    {
        $tenant = Tenant::findOrFail($this->tenantId);
        $user = User::whereBelongsTo($tenant)->findOrFail($this->requestedBy);
        $export = Export::whereBelongsTo($tenant)->findOrFail($this->exportId);

        Gate::forUser($user)->authorize('build', $export);

        // Load data and build the export only now.
    }
}

At dispatch, verify whether a job may be started. At execution, verify whether it may still act.

Files and external destinations need separate boundaries

Uploads and outgoing HTTP requests connect the application to parsers, filesystems and remote networks. Ordinary field validation does not cover those risks completely.

Uploads Outgoing requests
size and detected MIME type server-selected hosts
generated storage name DNS and IP validation
private storage no automatic redirects
authorisation on every download connection and total timeout
isolation for risky parsers response-size and egress limits
$document = $request->validate([
    'document' => ['required', 'file', 'mimetypes:application/pdf', 'max:10240'],
])['document'];

$path = $document->store("tenants/{$tenant->id}/documents", 'local');

$response = Http::baseUrl(config('services.billing.url'))
    ->connectTimeout(2)
    ->timeout(5)
    ->withoutRedirecting()
    ->get('/v1/status');

Laravel's mimes and mimetypes rules derive a detected type from the content. This is not malware scanning or isolated parsing of complex documents. Likewise, withoutRedirecting() only stops redirects. User-selectable targets still need host, DNS, IP and network controls.

Repetition and concurrency are normal operations

Webhooks, queued jobs and user actions can arrive again or at the same time. Exactly-once delivery is not a safe default assumption. The application must process repetition safely.

  • A provider event has a unique key backed by a unique constraint.
  • Business mutation and state verification happen atomically.
  • A competing insert is treated as an already received event.
  • External side effects receive their own idempotency key.
  • A concurrency test proves behaviour under parallel execution.

A transaction alone provides neither replay protection nor idempotency. It defines only the atomic boundary in which this particular invariant is checked and changed.

Audit logs document decisions, not secrets

A useful record answers who performed which business action on what object, when, from which state to which state and with what result.

{
  "event": "invoice.approved",
  "actor_id": 481,
  "tenant_id": 27,
  "subject_id": 8842,
  "from": "pending",
  "to": "approved",
  "request_id": "req_01J..."
}

Passwords, session cookies, complete tokens and unnecessary document content do not belong in the log. Traceability comes from deliberate metadata, not from copying the request.

The negative matrix is part of the definition of done

Every critical action should include at least these counter-proofs:

  • unauthenticated user is rejected
  • wrong role is rejected
  • correct permission in the wrong tenant is rejected
  • correct user in a prohibited object state is rejected
  • additional privileged fields are ignored or rejected
  • old link fails after access is revoked
  • repeated and concurrent requests preserve the invariant
  • queue, API, export and download enforce the same rule

These tests are not a separate security suite beside the product. They document the feature's boundaries and run with every refactor.

How a robust review starts

  1. Identify valuable data, critical actions and roles.
  2. Define the realistic abuse case and invariant for each action.
  3. Trace every entry point: HTTP, API, queue, broadcast, file and integration.
  4. Review policy, state model, database boundaries and operations together.
  5. Reproduce the attack and preserve it as a negative regression test.

The result is not the longest possible findings list. It is a prioritised map of reachable risks, shared root causes and the tests that prevent recurrence.

Conclusion

Laravel provides strong security primitives. The decisive work starts where framework features end: tenants, business actions, states and asynchronous data paths. Expressing these boundaries as invariants and testing them negatively produces safer software, clearer architecture and more dependable change.

Primary sources

Facing a similar decision in your project?

Describe the context. I will assess the technical options, risks and a useful next step.

Discuss the project question ↗