Laravel makes it fast to stand up a REST API - a resource controller, a handful of routes, and it's live. That speed is exactly why API security tends to lag behind: the checks that matter (authentication scope, output filtering, rate limiting, input validation) are all opt-in, and a working endpoint gives no visual signal that any of them are missing. This guide covers the API-specific gaps that don't show up in a typical Laravel tutorial.
Authentication Scope, Not Just Authentication
Confirming a request is authenticated (auth:sanctum middleware) answers "who is this," not "what can they do." Our Laravel authentication guide covers Sanctum token abilities in depth - the short version for APIs specifically: scope every token to what its consumer actually needs, and check the scope on every sensitive action, not just at issuance.
// Issuing a scoped token
$token = $user->createToken('mobile-app', ['orders:read', 'orders:create']);
// Enforcing it on the route
Route::post('/orders', [OrderController::class, 'store'])
->middleware(['auth:sanctum', 'ability:orders:create']);
API Resources: Stop Over-Exposing Model Data
Returning a raw Eloquent model from a controller (return $user;) serializes every column, including ones that were never meant to leave the server - password hashes are protected by $hidden by default, but internal flags, other users' foreign keys, and soft-delete timestamps often aren't. API Resource classes make the exposed shape explicit instead of implicit.
// app/Http/Resources/UserResource.php
class UserResource extends JsonResource {
public function toArray($request): array {
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
// internal_notes, stripe_customer_id, etc. simply aren't listed -
// an allowlist, not a denylist
];
}
}
This is the output-side counterpart to mass assignment protection on the input side, covered in our main Laravel security guide - one controls what a client can set, the other controls what a client can see, and both need an explicit allowlist rather than trusting the model's default serialization.
Validate With Form Requests, Not Inline Rules
Inline $request->validate([...]) calls work, but they get copy-pasted between controllers and drift out of sync - a rule tightened in one endpoint doesn't propagate to the others that accept the same field. Form Request classes centralize the rule set per input shape and are easy to unit test independently of the controller.
// app/Http/Requests/StoreOrderRequest.php
public function rules(): array {
return [
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
'product_id' => ['required', 'exists:products,id'],
];
}
Pay particular attention to type validation on anything used in a query, a file path, or a shell command downstream - Laravel's route model binding and query builder handle most cases safely, but a raw query (see our guide on common Laravel vulnerabilities) built from an unvalidated field bypasses that protection entirely.
Rate Limiting Per-Consumer, Not Just Globally
Laravel's default API rate limiter throttles by IP, which is the wrong unit for a token-authenticated API - a single misbehaving integration partner on a shared corporate NAT can exhaust the limit for every other user behind the same IP, while a distributed abuse attempt across many IPs sails through untouched.
// bootstrap/app.php or RouteServiceProvider
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by($request->user()->id)
: Limit::perMinute(20)->by($request->ip());
});
CORS: Don't Ship the Default to Production
Laravel's config/cors.php is meant to be tightened per application - see the CORS section of our main Laravel guide for the specific wildcard-plus-credentials combination that causes real damage in API contexts, where a JavaScript client on an unrelated origin can read authenticated responses if the policy is too permissive.
Error Responses: JSON Endpoints Leak Differently Than HTML Ones
With APP_DEBUG=true, Laravel's JSON error responses include the full exception message, stack trace, and file paths - just as damaging as the HTML debug page, but easy to overlook because it's not immediately visible in a browser tab. Confirm APP_DEBUG=false specifically against your API routes, not just the web ones, since a staging environment sometimes has debug mode toggled per-guard and gets missed.
// A debug-mode API error response leaks:
{
"message": "SQLSTATE[42S02]: Base table or view not found...",
"exception": "Illuminate\\Database\\QueryException",
"file": "/var/www/app/Http/Controllers/OrderController.php",
"line": 42,
"trace": [ /* full stack trace */ ]
}
Versioning Without Leaving Old Protections Behind
When an API moves from /api/v1 to /api/v2, it's common for security fixes (a rate limit, a new validation rule, a scope requirement) to land only on the new version while the old one stays live for backward compatibility - meaning the vulnerability the fix addressed is still fully exploitable through the endpoint nobody remembered to deprecate. Track which security controls exist per API version explicitly, and set a real sunset date for old ones rather than leaving them running indefinitely "just in case."
Checking Your API From the Outside
Everything above is a code-level decision that doesn't show up by reading your API documentation - only by testing what the live endpoint actually does. Shieldome's REST API security scanner checks for exactly these gaps - BOLA/IDOR, broken authentication, excessive data exposure, missing rate limiting - and our API Security Checklist covers the framework-agnostic version of this list if you're running more than one backend stack. Create a free account to run your first scan.