Securing Your Laravel API: Common Vulnerabilities and Solutions

Laravel provides secure building blocks for authentication, validation, database access and authorization. However, a Laravel API is not automatically secure simply because it uses the framework.

Most serious API vulnerabilities are caused by application logic: an endpoint checks whether a user is logged in but does not check whether that user owns the requested record, a controller accepts fields it should never accept, or a public endpoint has no request limit.

This guide explains the most common Laravel API security problems, shows vulnerable code, and replaces it with safer Laravel code.

The examples use modern Laravel 12 and Laravel 13 conventions. The same security principles also apply to older supported Laravel applications, although some file locations and installation commands may differ.


Quick Answer

To secure a Laravel API:

  1. Authenticate protected routes with Laravel Sanctum or Passport.
  2. Authorize every action on every protected resource.
  3. Never trust a record ID supplied by the client.
  4. Validate requests with Form Request classes.
  5. Update models using validated, explicitly allowed fields.
  6. Avoid raw SQL containing request data.
  7. Rate-limit login, password-reset, search, upload and expensive endpoints.
  8. Return only the fields the client needs.
  9. Restrict uploaded file type, size and storage location.
  10. Prevent server-side request forgery when your API fetches URLs.
  11. Keep APP_DEBUG=false and protect the .env file in production.
  12. Log security events without logging passwords, tokens or sensitive personal data.
  13. Use HTTPS and keep Laravel, PHP and Composer packages supported and patched.
  14. Add automated tests that attempt unauthorized access—not only successful requests.

The most important rule is this:

Authentication proves who the caller is. Authorization decides what that caller is allowed to do.

An endpoint needs both when it works with private or restricted data.


Common Laravel API Vulnerabilities

VulnerabilityCommon Laravel mistakeSafer solution
Broken object-level authorizationPost::findOrFail($id) after checking only that the user is logged inPolicies, gates or ownership-scoped queries
Broken function-level authorizationAny authenticated user can call an admin routeRole or permission checks enforced on the server
Broken authenticationPermanent tokens, no revocation, no login throttlingSanctum or Passport, short lifetimes where appropriate, revocation and throttling
Mass assignment$model->update($request->all())Form Requests, $request->validated() and $fillable
SQL injectionConcatenating request data into raw SQLQuery Builder bindings and allowlists for column names
Excessive data exposureReturning complete Eloquent modelsAPI Resources and explicit fields
Unrestricted resource consumptionNo limits on requests, pagination, uploads or exportsRate limits and hard server-side limits
Unsafe file uploadTrusting extensions or storing files publiclyContent-based validation, generated names and private storage
SSRFFetching any URL submitted by a userHost allowlists, blocked private IPs, timeouts and redirect controls
CSRF or CORS mistakesDisabling protections or allowing every origin with credentialsCorrect Sanctum SPA configuration and narrow CORS rules
Sensitive-data exposureDebug mode, secrets in logs or committed .env filesProduction configuration, secret management and log redaction
Outdated dependenciesIgnoring security advisoriesSupported versions, composer audit and controlled updates

These risks overlap with the OWASP API Security Top 10, including broken object-level authorization, broken authentication, unrestricted resource consumption, server-side request forgery and security misconfiguration.


1. Use Proper API Authentication

Authentication identifies the user or system making a request.

For a first-party single-page application, mobile application or simple token-based API, Laravel generally recommends Sanctum. Passport is appropriate when the application genuinely requires OAuth 2 features. See Laravel’s authentication guidance before choosing between them.

In a modern Laravel application, API support can be installed with:

php artisan install:api

Protect private routes with auth:sanctum:

<?php

use App\Http\Controllers\PostController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
    Route::post('/posts', [PostController::class, 'store']);
    Route::patch('/posts/{post}', [PostController::class, 'update']);
    Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});

A route outside this group is public unless another middleware protects it.

Do Not Create Your Own Plain-Text Token System

Avoid adding an api_token column to the users table and comparing raw permanent tokens manually. A home-made implementation may omit hashing, expiry, abilities, rotation, revocation and secure lookup behaviour.

Sanctum stores a hash of each personal access token in the database. The plain-text token is returned only when it is created, so it must be shown to the user once and then handled like a password.

$token = $user->createToken(
    'mobile-app',
    ['posts:read', 'posts:write']
)->plainTextToken;

Do not write $token to logs or analytics.

Limit Token Abilities

Do not give every integration unrestricted access.

Route::post('/posts', [PostController::class, 'store'])
    ->middleware([
        'auth:sanctum',
        'abilities:posts:write',
    ]);

Abilities reduce what a token is intended to do. They do not replace model authorization. A token with posts:write must still be prevented from editing another user’s post.

Revoke Tokens

Provide a way to revoke the current token:

public function logout(Request $request): JsonResponse
{
    $request->user()->currentAccessToken()?->delete();

    return response()->json([
        'message' => 'Token revoked.',
    ]);
}

You may also revoke all tokens after a password change, account compromise or administrative security action:

$user->tokens()->delete();

Decide whether tokens need an expiry time based on the sensitivity of the application. Long-lived integration tokens may be necessary, but they should have narrow abilities, visible last-used information, rotation procedures and immediate revocation support.


2. Prevent Broken Object-Level Authorization

Broken object-level authorization, often called BOLA or IDOR, is one of the most common API vulnerabilities.

Consider this endpoint:

GET /api/invoices/8421

If a logged-in user changes 8421 to 8422, can they see another customer’s invoice?

Vulnerable Example

public function show(int $id): JsonResponse
{
    $invoice = Invoice::findOrFail($id);

    return response()->json($invoice);
}

Placing this controller behind auth:sanctum only proves that the caller is authenticated. It does not prove that the invoice belongs to the caller.

UUIDs do not solve this problem. A UUID may be harder to guess than an integer, but leaked, logged or shared UUIDs still require authorization checks.

Solution A: Scope the Query to the Authenticated User

public function show(Request $request, int $id): InvoiceResource
{
    $invoice = $request->user()
        ->invoices()
        ->findOrFail($id);

    return new InvoiceResource($invoice);
}

The query searches only within the authenticated user’s invoices. A record belonging to another user is not returned.

This approach is particularly useful for strictly owned records.

Solution B: Use a Laravel Policy

Generate a policy:

php artisan make:policy InvoicePolicy --model=Invoice

Define the authorization rule:

<?php

namespace App\Policies;

use App\Models\Invoice;
use App\Models\User;

class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): bool
    {
        return $user->id === $invoice->user_id;
    }

    public function update(User $user, Invoice $invoice): bool
    {
        return $user->id === $invoice->user_id
            && $invoice->status === 'draft';
    }
}

Authorize the action in the controller:

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

    return new InvoiceResource($invoice);
}

Laravel policies group authorization rules around a model, while gates are useful for actions not tied to one model. Laravel explains both approaches in its authorization documentation.

Check Every Operation

Authorization is required for more than show.

Check all relevant operations:

  • Listing records
  • Viewing one record
  • Creating records under a parent resource
  • Updating records
  • Deleting or restoring records
  • Downloading attachments
  • Exporting data
  • Viewing comments or activity logs
  • Changing status
  • Assigning a record to another user

A secure show method does not compensate for an insecure update, download or export method.


3. Prevent Broken Function-Level Authorization

Object-level authorization asks, “Can this user access this record?”

Function-level authorization asks, “Can this user perform this type of action at all?”

Vulnerable Example

Route::delete('/admin/users/{user}', function (User $user) {
    $user->delete();

    return response()->noContent();
})->middleware('auth:sanctum');

Every authenticated user can call this route.

Hiding the delete button in the frontend is not security. Attackers can call the API directly.

Safer Example

Use a policy, gate or trusted permission package and enforce the result on the server:

Gate::define('delete-user', function (User $currentUser, User $targetUser) {
    return $currentUser->is_admin
        && $currentUser->id !== $targetUser->id;
});

Then authorize the action:

public function destroy(User $user): Response
{
    Gate::authorize('delete-user', $user);

    $user->delete();

    return response()->noContent();
}

For larger applications, use policies or a well-maintained role-and-permission system so the rules remain consistent and testable.


4. Prevent Mass-Assignment Vulnerabilities

Mass assignment happens when an array is used to create or update several model attributes at once.

It is convenient, but dangerous when the array contains fields the user should not control.

Vulnerable Example

public function update(Request $request, User $user): JsonResponse
{
    $user->update($request->all());

    return response()->json($user);
}

An attacker may submit:

{
    "name": "Normal Name",
    "is_admin": true,
    "account_balance": 100000
}

If the model accepts those attributes, the user may change protected values.

Use $fillable

class User extends Authenticatable
{
    protected $fillable = [
        'name',
        'timezone',
    ];
}

Do not use this in a sensitive model without very careful control:

protected $guarded = [];

An empty $guarded array makes all attributes mass assignable.

Use a Form Request and Validated Data

Create a request class:

php artisan make:request UpdateProfileRequest
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UpdateProfileRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:100'],
            'timezone' => ['required', 'timezone'],
        ];
    }
}

Then update only validated fields:

public function update(UpdateProfileRequest $request): UserResource
{
    $user = $request->user();
    $user->update($request->validated());

    return new UserResource($user->refresh());
}

For additional clarity, select fields explicitly:

$user->update(
    $request->safe()->only([
        'name',
        'timezone',
    ])
);

Treat $fillable, validation and authorization as separate layers:

  • $fillable controls which model attributes may be mass assigned.
  • Validation controls acceptable request structure and values.
  • Authorization controls whether the current user may perform the action.

One layer does not replace the others.


5. Validate Every External Input

Do not validate only forms. Validate all external data, including:

  • JSON request bodies
  • Query-string filters
  • Route parameters
  • Uploaded files
  • Webhook payloads
  • Third-party API responses before important use
  • Import files
  • Sorting and pagination parameters

Example Form Request

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    public function rules(): array
    {
        return [
            'product_id' => [
                'required',
                'integer',
                Rule::exists('products', 'id')
                    ->where('is_active', true),
            ],
            'quantity' => ['required', 'integer', 'min:1', 'max:20'],
            'delivery_note' => ['nullable', 'string', 'max:500'],
        ];
    }
}

Validation prevents malformed values, but business rules must still be checked. For example, a valid product_id does not prove that the customer may order a restricted product.

Set Maximum Limits

Never accept an unlimited per_page value:

$validated = $request->validate([
    'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
]);

$perPage = $validated['per_page'] ?? 20;

$orders = Order::query()->paginate($perPage);

The same principle applies to date ranges, batch sizes, search lengths, export rows, nested JSON arrays and file sizes.


6. Prevent SQL Injection

Laravel’s Query Builder and Eloquent use parameter binding for normal values. Problems appear when developers concatenate request data into raw SQL or let users choose raw column names and expressions.

Vulnerable Raw Query

$email = $request->input('email');

$users = DB::select(
    "SELECT * FROM users WHERE email = '$email'"
);

Use Bindings

$users = DB::select(
    'SELECT * FROM users WHERE email = ?',
    [$request->string('email')->toString()]
);

Better still, use Eloquent or Query Builder when possible:

$user = User::query()
    ->where('email', $request->string('email'))
    ->first();

Laravel warns that raw expressions are inserted into queries as strings and may introduce SQL injection. When a raw expression is necessary, use bindings rather than concatenation. See the official Query Builder documentation.

Allowlist Sort Columns

Database bindings protect values, not arbitrary identifiers such as column names.

Do not do this:

$users = User::orderBy(
    $request->input('sort'),
    $request->input('direction')
)->get();

Use explicit allowlists:

$validated = $request->validate([
    'sort' => ['sometimes', 'in:name,created_at'],
    'direction' => ['sometimes', 'in:asc,desc'],
]);

$sort = $validated['sort'] ?? 'created_at';
$direction = $validated['direction'] ?? 'desc';

$users = User::query()
    ->orderBy($sort, $direction)
    ->paginate(20);

Use the same technique for selectable report fields, aggregate functions, table names and search operators.


7. Add Rate Limits and Resource Limits

Rate limiting reduces brute-force attacks, scraping, accidental loops and denial-of-service pressure.

Laravel includes a rate-limiting abstraction backed by the application’s cache. For distributed production servers, a shared store such as Redis prevents each application server from maintaining an independent counter.

Define a Named API Limiter

<?php

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        RateLimiter::for('api-standard', function (Request $request) {
            $key = $request->user()?->id ?: $request->ip();

            return Limit::perMinute(60)->by($key);
        });
    }
}

Apply it to routes:

Route::middleware([
    'auth:sanctum',
    'throttle:api-standard',
])->group(function () {
    Route::apiResource('orders', OrderController::class);
});

Laravel documents named limiters and route middleware in its routing documentation.

Sensitive Endpoints Need Separate Limits

Do not use one generous limit for everything. Apply stricter limits to:

  • Login attempts
  • Registration
  • Password-reset requests
  • One-time-password verification
  • Email or SMS sending
  • Search endpoints
  • File uploads
  • PDF or report generation
  • AI or third-party paid API calls
  • Data exports
  • Webhook retries

Rate limiting is only one control. Also set:

  • Maximum request-body size at the web server
  • Maximum upload size
  • Maximum pagination size
  • Database query timeouts where appropriate
  • HTTP client connection and response timeouts
  • Queue job timeouts and retry limits
  • Maximum batch-operation size
  • Maximum export date range

Without hard limits, one accepted request may still consume excessive memory, CPU, database time or third-party credits.


8. Return Only the Data the Client Needs

An API can be properly authenticated and still leak data by returning complete models.

Risky Example

return response()->json(User::findOrFail($id));

The response may include internal fields that the current client does not need, such as:

  • Internal status flags
  • Administrative notes
  • Provider identifiers
  • Verification timestamps
  • Billing references
  • Soft-delete timestamps
  • Security-related metadata

Laravel normally hides the password and remember token on the default User model, but custom models and future columns may not be protected automatically.

Use an API Resource

Create a resource:

php artisan make:resource UserResource
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'avatar_url' => $this->avatar_url,
            'created_at' => $this->created_at?->toIso8601String(),
        ];
    }
}

Return the resource:

return new UserResource($user);

API Resources create an explicit response contract. They are clearer and safer than exposing every current and future database column.

Also review nested relationships. Returning a safe user object with an unrestricted orders, payments or notes relationship may still leak private data.


9. Secure File Uploads

File upload endpoints require more than checking the filename extension.

Laravel can validate a file’s content-derived MIME type using the fluent File rule.

use Illuminate\Validation\Rules\File;

$validated = $request->validate([
    'document' => [
        'required',
        File::types(['pdf', 'jpg', 'jpeg', 'png'])
            ->max('5mb'),
    ],
]);

Laravel’s validation documentation notes that SVG files are not allowed by the normal image rule by default because SVG can introduce cross-site scripting risks. Do not enable SVG uploads unless the application has a safe sanitisation and serving strategy. See Laravel file validation.

Store with a Generated Name

$path = $validated['document']->store(
    'private/documents',
    'local'
);

Laravel generates the stored filename. Do not use an untrusted original filename as the filesystem path.

Keep Private Files Private

Do not store identity documents, invoices, medical files or private attachments on a directly public disk.

Serve them through an authorized controller or use short-lived signed storage URLs after authorization.

public function download(Document $document): BinaryFileResponse
{
    Gate::authorize('view', $document);

    return response()->download(
        Storage::disk('local')->path($document->path),
        $document->original_name
    );
}

Additional controls may include malware scanning, image re-encoding, archive rejection, decompression limits and separate storage domains. Never execute uploaded files.


10. Prevent Server-Side Request Forgery

Server-side request forgery, or SSRF, occurs when an attacker causes your server to request an unintended address.

A vulnerable endpoint may accept a URL for an image importer, webhook tester or page preview:

$response = Http::get($request->input('url'));

An attacker may try to reach:

  • localhost
  • Internal admin panels
  • Private network services
  • Cloud metadata endpoints
  • Services protected from the public internet

The safest approach is to avoid arbitrary destinations and allow only known hosts.

$validated = $request->validate([
    'url' => ['required', 'url:https'],
]);

$url = $validated['url'];
$host = strtolower((string) parse_url($url, PHP_URL_HOST));

$allowedHosts = [
    'images.example.com',
    'cdn.example.com',
];

abort_unless(
    in_array($host, $allowedHosts, true),
    422,
    'The URL host is not allowed.'
);

$response = Http::connectTimeout(3)
    ->timeout(8)
    ->withoutRedirecting()
    ->get($url);

If arbitrary public URLs are a genuine requirement, the protection must be stronger:

  • Permit only https where possible.
  • Resolve the hostname and reject loopback, private, link-local, multicast and reserved IP ranges.
  • Re-check every redirect destination.
  • Protect against DNS rebinding.
  • Restrict outbound traffic at the network layer.
  • Set connection, response and size limits.
  • Do not forward the user’s authorization headers or cookies.

URL validation alone does not prove that a destination is safe.


11. Configure CSRF and CORS Correctly

CSRF and CORS solve different problems.

  • CSRF protection prevents another website from causing a browser to perform an unwanted authenticated action.
  • CORS controls which browser origins may read or send permitted cross-origin requests.

CORS is not authentication. Command-line tools, mobile apps and malicious servers are not stopped by a browser’s CORS policy.

Bearer-Token APIs

A stateless API using a token in the Authorization header is normally not authenticated automatically by browser cookies. Traditional CSRF risk is therefore different from cookie-authenticated routes.

The token must still be protected against theft, logging and insecure client storage.

Sanctum SPA Authentication

For a first-party SPA using Sanctum’s cookie-based authentication, follow Laravel’s stateful SPA configuration and CSRF-cookie flow. Do not disable CSRF verification simply to make requests work.

The frontend typically first requests:

/sanctum/csrf-cookie

It then sends the login or authenticated request with credentials configured correctly. Refer to the current Laravel Sanctum documentation because the required middleware and client configuration depend on the Laravel version and frontend setup.

Keep CORS Narrow

If credentials are permitted, list trusted origins explicitly. Do not combine credentialed requests with a broad or reflected origin policy.

Review:

  • Allowed origins
  • Allowed methods
  • Allowed headers
  • Credential support
  • Preflight caching
  • Development origins accidentally left in production

Only publish the CORS configuration file if the defaults need to be changed:

php artisan config:publish cors

12. Protect Secrets and Production Configuration

Disable Debug Mode in Production

Production should use:

APP_ENV=production
APP_DEBUG=false

Laravel’s configuration documentation warns that production debug mode can expose sensitive configuration values.

Do not return stack traces, SQL queries, internal file paths or exception details to API clients. Send a stable error structure and keep full diagnostic details in protected server logs.

Protect the .env File

Never commit .env to Git. Ensure the web server’s document root points to Laravel’s public directory—not the project root.

Secrets include:

  • Database passwords
  • APP_KEY
  • API tokens
  • OAuth client secrets
  • Payment-provider keys
  • Mail credentials
  • Cloud storage credentials
  • Webhook signing secrets

Use environment variables or a secret-management service. Give each environment separate credentials and the minimum required privileges.

Do not casually rotate APP_KEY on an existing application. Data encrypted with the old key may become unreadable unless a planned key-rotation procedure and previous-key support are in place.

Cache Production Configuration

During deployment, run:

php artisan config:cache
php artisan route:cache
php artisan view:cache

Access environment values through configuration files and config() rather than calling env() throughout application code.


13. Do Not Leak Secrets Through Logs

Logs are essential for investigating attacks, but they can become another sensitive database.

Do not log:

  • Passwords
  • Full bearer tokens
  • Session cookies
  • CSRF tokens
  • Credit-card data
  • Complete identity documents
  • Password-reset links
  • Private webhook signatures
  • Full request bodies containing personal data

Risky code:

Log::info('Login request', $request->all());

Safer code:

Log::warning('Failed login attempt', [
    'email_hash' => hash('sha256', strtolower($request->input('email'))),
    'ip' => $request->ip(),
    'user_agent' => $request->userAgent(),
]);

Even hashed identifiers may be personal data depending on context and applicable law. Collect only what the security and support teams genuinely need, restrict log access and configure retention.

Useful security events include:

  • Repeated failed authentication
  • Token creation and revocation
  • Password and email changes
  • Two-factor authentication changes
  • Administrative actions
  • Permission changes
  • Large exports
  • Rejected webhook signatures
  • Unusual rate-limit activity

Do not show internal security reasons to an attacker. For example, a login response can remain generic while the server records whether the account was missing, disabled or supplied an incorrect password.


14. Verify Webhook Signatures

A webhook endpoint is public because an external provider must reach it. Public does not mean unverified.

Do not trust a webhook merely because it contains a plausible order ID or payment status.

Verify the signature using the provider’s official SDK or documented algorithm, using the raw request body when required.

General structure:

public function handle(Request $request): Response
{
    $payload = $request->getContent();
    $signature = $request->header('X-Provider-Signature');

    abort_unless(
        $this->signatureIsValid($payload, $signature),
        401
    );

    // Process the verified event idempotently.

    return response()->noContent();
}

Also:

  • Reject missing or invalid signatures.
  • Enforce timestamp tolerance when supported.
  • Store provider event IDs to prevent duplicate processing.
  • Make processing idempotent.
  • Queue slow work after verification.
  • Rate-limit or network-restrict endpoints when compatible with the provider.
  • Keep signing secrets separate for development and production.

Never invent a generic signature procedure when a provider supplies an official one.


15. Use HTTPS and Secure Infrastructure

API credentials and private data must be encrypted in transit with HTTPS.

Redirect HTTP to HTTPS at the load balancer or web server and renew certificates automatically. Configure Laravel’s trusted proxy settings correctly so URL generation and secure-cookie behaviour recognize the original HTTPS connection.

Infrastructure controls should include:

  • A web root pointing only to public
  • No directory listing
  • Restricted file permissions
  • A database not exposed publicly
  • Separate database users with limited privileges
  • Firewall rules permitting only required ports
  • Protected Redis, queue and monitoring services
  • Encrypted backups with tested restoration
  • Outbound network restrictions for sensitive environments
  • Security headers appropriate to the API and any browser frontend

Laravel security cannot compensate for a publicly exposed database, Redis server or administration panel.


16. Keep Dependencies Supported and Patched

Check the application for known Composer advisories:

composer audit

If the project includes frontend dependencies, also review:

npm audit

An audit warning does not mean that every suggested automatic update is safe. Review:

  1. Which direct or transitive package is affected.
  2. Whether the vulnerable code path is used.
  3. Which fixed version is available.
  4. Whether the update contains breaking changes.
  5. Whether tests and a staging deployment pass.

Keep these components on supported versions:

  • PHP
  • Laravel framework
  • Composer
  • Authentication packages
  • Queue and cache clients
  • Web server and operating system
  • JavaScript dependencies used by the frontend

Remove packages the application no longer uses. Every unnecessary dependency increases maintenance and attack surface.


17. Avoid User Enumeration

Authentication and account-recovery endpoints can reveal whether an email address exists.

Risky responses:

{ "message": "No account uses this email." }

and:

{ "message": "The password is incorrect." }

These allow attackers to build a list of registered users.

For login, return a generic response:

{ "message": "The provided credentials are invalid." }

For password reset, return the same public response whether or not the account exists:

{
    "message": "If an account matches that email, reset instructions will be sent."
}

Use rate limiting as well. Avoid creating obvious response-time differences between existing and nonexistent accounts.


18. Test Security Failures

Many test suites verify only that authorized users receive 200 OK. Security tests must also prove that unauthorized requests fail.

Test Cross-User Record Access

use App\Models\Invoice;
use App\Models\User;
use Laravel\Sanctum\Sanctum;

it('prevents a user from viewing another users invoice', function () {
    $owner = User::factory()->create();
    $attacker = User::factory()->create();

    $invoice = Invoice::factory()
        ->for($owner)
        ->create();

    Sanctum::actingAs($attacker);

    $this->getJson("/api/invoices/{$invoice->id}")
        ->assertForbidden();
});

If the controller intentionally scopes the query to the user’s relationship, expect 404 Not Found instead:

->assertNotFound();

Both approaches can be valid. Apply one behaviour consistently and do not leak extra record information unintentionally.

Test Mass Assignment

it('does not allow a profile update to grant admin access', function () {
    $user = User::factory()->create([
        'is_admin' => false,
    ]);

    Sanctum::actingAs($user);

    $this->patchJson('/api/profile', [
        'name' => 'Updated Name',
        'timezone' => 'Asia/Kuala_Lumpur',
        'is_admin' => true,
    ])->assertSuccessful();

    expect($user->refresh()->is_admin)->toBeFalse();
});

Depending on the validation contract, it may be better to reject unknown fields with 422 Unprocessable Content rather than ignore them. Whichever behaviour you choose, test it.

Additional Security Tests

Add tests for:

  • Requests without authentication
  • Expired or revoked tokens
  • Tokens missing required abilities
  • Normal users calling admin endpoints
  • Cross-tenant access
  • Invalid webhook signatures
  • Replay of the same webhook event
  • Excessive pagination values
  • Disallowed sort columns
  • Oversized and disallowed file uploads
  • Rate-limit responses
  • Sensitive fields missing from API resources
  • Error responses not containing stack traces

A test for every discovered authorization bug prevents the same vulnerability from returning later.


A Secure Laravel API Controller Example

The following controller combines authentication through route middleware, authorization, validation, safe assignment and an API Resource.

<?php

namespace App\Http\Controllers;

use App\Http\Requests\UpdateInvoiceRequest;
use App\Http\Resources\InvoiceResource;
use App\Models\Invoice;
use Illuminate\Support\Facades\Gate;

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

        return new InvoiceResource($invoice);
    }

    public function update(
        UpdateInvoiceRequest $request,
        Invoice $invoice
    ): InvoiceResource {
        Gate::authorize('update', $invoice);

        $invoice->update(
            $request->safe()->only([
                'billing_name',
                'billing_address',
                'notes',
            ])
        );

        return new InvoiceResource($invoice->refresh());
    }
}

Routes:

Route::middleware([
    'auth:sanctum',
    'throttle:api-standard',
])->group(function () {
    Route::get('/invoices/{invoice}', [
        InvoiceController::class,
        'show',
    ]);

    Route::patch('/invoices/{invoice}', [
        InvoiceController::class,
        'update',
    ]);
});

This structure does not make the entire application automatically secure, but it keeps important controls visible and testable.


Laravel API Security Checklist

Use this checklist before releasing an API.

Authentication

  • Private routes require the correct authentication middleware.
  • Sanctum or Passport is used instead of a home-made token system.
  • Tokens can be revoked.
  • Token abilities are limited where useful.
  • Login and password-reset endpoints are rate-limited.
  • Sensitive accounts support multi-factor authentication where appropriate.

Authorization

  • Every endpoint that accepts a record ID checks access to that record.
  • Index endpoints return only permitted records.
  • Download and export endpoints are authorized.
  • Admin functions check roles or permissions on the server.
  • Cross-tenant access has dedicated automated tests.

Input and Database

  • Form Requests validate bodies, query strings and files.
  • Pagination, array and batch sizes have maximum values.
  • Models use carefully reviewed $fillable attributes.
  • Controllers update from validated allowlisted fields.
  • Request data is never concatenated into raw SQL.
  • Sort columns and query operators use allowlists.

Responses and Files

  • API Resources expose only required fields.
  • Sensitive relationships are not returned accidentally.
  • Private uploads are not directly public.
  • Uploaded type and size are validated.
  • Stored filenames are generated by the application.
  • Downloads perform authorization before returning a file.

Configuration and Operations

  • APP_ENV=production is set in production.
  • APP_DEBUG=false is set in production.
  • The web root points to Laravel’s public directory.
  • The .env file is not committed or publicly readable.
  • HTTPS is enforced.
  • Logs exclude passwords, tokens and unnecessary personal data.
  • Backups are encrypted and restoration is tested.
  • Laravel, PHP and dependencies are supported and patched.
  • composer audit is reviewed during the release process.
  • Monitoring alerts on repeated authentication failures and unusual errors.

Common Laravel API Security Mistakes

“The Route Uses auth:sanctum, So It Is Secure”

auth:sanctum verifies identity. It does not automatically decide which invoices, projects, files or users that identity may access.

“We Use UUIDs, So IDs Cannot Be Guessed”

UUIDs reduce simple enumeration but do not replace authorization. IDs leak through browser history, logs, screenshots, emails, analytics and related API responses.

“CORS Blocks Attackers”

CORS is enforced by browsers. It does not prevent direct requests from scripts, servers or mobile tools.

“Validation Prevents Mass Assignment”

Only if the application updates from the validated data. Calling $request->all() after validation can reintroduce unwanted fields.

“Eloquent Prevents Every SQL Injection”

Normal value binding is safe, but unsafe raw expressions and user-controlled column names can still create injection risks.

“The Frontend Hides the Admin Button”

Frontend visibility is not authorization. The backend must reject unauthorized calls.

“A Successful Request Test Is Enough”

Security depends on rejected requests. Test anonymous users, other users, lower roles, revoked tokens and malformed data.


Frequently Asked Questions

Is Laravel secure by default?

Laravel provides secure tools and sensible defaults, including password hashing, query parameter binding, validation, CSRF protection and authentication middleware. Application-specific authorization, data exposure, rate limits, token policy and server configuration remain the developer’s responsibility.

Should I use Laravel Sanctum or Passport?

Use Sanctum for most first-party SPAs, mobile applications and simple token APIs. Use Passport when the application specifically requires OAuth 2 flows and capabilities. Do not choose Passport merely because it sounds more secure; unnecessary complexity can create configuration mistakes.

Is auth:sanctum enough to protect an API route?

No. It authenticates the caller. You must still authorize access to records and actions using scoped queries, gates, policies, roles or permissions.

Does Laravel prevent SQL injection automatically?

Eloquent and Query Builder bind normal values, which protects common queries. Raw expressions, string concatenation and user-controlled identifiers can still be dangerous. Use bindings and allowlists.

Should an API return 403 or 404 for another user’s record?

Both approaches are used. A policy commonly returns 403 Forbidden; an ownership-scoped query commonly returns 404 Not Found. Returning 404 may reveal less about whether the record exists. Choose intentionally, keep behaviour consistent and test it.

Are UUIDs safer than numeric IDs?

They are harder to enumerate, but they are not an authorization control. Every object still requires an access check.

Should I disable CSRF protection for my API?

Do not disable it blindly. Pure bearer-token endpoints and cookie-authenticated SPA endpoints have different requirements. Sanctum’s stateful SPA authentication relies on correct cookies, domains, credentials and CSRF handling.

How should API tokens be stored in a frontend?

For first-party browser SPAs, Laravel Sanctum’s secure, HTTP-only cookie approach is normally preferable to placing long-lived tokens in browser storage. Native mobile applications should use the platform’s secure credential storage. Never expose tokens in URLs, logs or analytics.

How often should dependencies be updated?

Review security advisories continuously or during every release pipeline, apply critical fixes promptly and schedule regular supported-version updates. Test changes in staging rather than ignoring updates until the framework is no longer supported.

What is the most important Laravel API security test?

For applications containing private records, create two users and prove that one user cannot view, update, delete, download or export the other user’s data. Repeat the test across every tenant and role boundary.


Final Summary

Laravel offers strong security features, but developers must apply them consistently.

The most important improvements are:

  • Authenticate protected API routes.
  • Authorize every protected record and action.
  • Scope queries to the current user or tenant.
  • Validate all external input.
  • Update models only with validated, allowlisted fields.
  • Avoid unsafe raw SQL and user-controlled identifiers.
  • Apply request and resource limits.
  • Return explicit API Resources instead of full models.
  • Secure uploads and outbound URL requests.
  • Keep production debug mode disabled.
  • Protect secrets and remove them from logs.
  • Verify webhook signatures.
  • Use HTTPS and supported dependencies.
  • Test that forbidden requests fail.

Do not begin by adding complicated security packages. First make the application’s trust boundaries clear: who is calling, which record they are requesting, which action they want to perform, which fields they may change, and how much work one request is allowed to trigger.

When those checks are explicit, centralized and covered by tests, a Laravel API becomes much harder to misuse.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *