A Laravel API used by a mobile app is secure when Sanctum tokens expire and carry narrow abilities, every record lookup is checked by a policy, input passes through validated() and $fillable, named rate limiters guard login and writes, APP_DEBUG is off with secrets out of git, and composer audit gates every deploy. Logging and alerting close the loop.
Mobile backends fail security reviews for the same few reasons: tokens that never expire, lookups that check identity but not ownership, no limits on login or OTP endpoints, and logs that cannot answer “who read this record?” This is the order I work through when hardening a Laravel API behind a mobile app. For the design side (versioning, response shape, pagination), see Laravel as a backend for mobile apps.
Checklist mapped to the OWASP API Security Top 10 (2023)
The OWASP API Security Top 10 is the list most security reviewers work from. Each risk maps to a Laravel control and a section below.
| OWASP risk | Laravel control | Section |
|---|---|---|
| API1 Broken Object Level Authorization | Policies plus queries scoped to the owner | 3 |
| API2 Broken Authentication | Sanctum token expiry, abilities, revocation, login throttling | 2, 5 |
| API3 Broken Object Property Level Authorization | $fillable, validated() input, API Resources for output | 4 |
| API4 Unrestricted Resource Consumption | Named rate limiters, page-size caps, upload limits | 5, 7 |
| API5 Broken Function Level Authorization | Gates and policies on admin routes, token abilities | 2, 3 |
| API6 Unrestricted Access to Sensitive Business Flows | Per-account limits on signup, OTP, checkout | 5 |
| API7 Server Side Request Forgery | Allowlist outbound URLs fetched on a user’s behalf | 7 |
| API8 Security Misconfiguration | APP_DEBUG off, secrets out of git, HTTPS, trusted proxies | 6 |
| API9 Improper Inventory Management | route:list review, retire old API versions | 10 |
| API10 Unsafe Consumption of APIs | Validate third-party and webhook payloads, timeouts | 7, 9 |
1. Threat Model Before Feature Build
Spend an hour per feature area on four questions before the endpoints exist, and write the answers down:
- Actors: end user, support staff, admin, partner integrations, and the attacker holding a stolen phone with a valid token.
- Data: what is regulated or damaging if leaked (PII, payment metadata, health signals, location history).
- Likely abuse: credential stuffing, OTP guessing, scraping of list endpoints, replay of a captured token.
- Blast radius: what one leaked token can read or change, and how fast you can revoke it.
The useful output is a table of endpoints with three columns: needs step-up auth, needs a per-account rate limit, touches regulated data. Apply the controls below against that table, not uniformly.
2. Authentication and Token Lifecycle
With Laravel Sanctum, the most common gap is that personal access tokens never expire: expiration in config/sanctum.php is null by default. The config value applies to every token; the third argument to createToken() sets a per-token expiry. Expired rows stay in the table until sanctum:prune-expired runs.
<?php // config/sanctum.php return [ // ... 'expiration' => 60 * 24 * 30, // minutes; null means tokens never expire ]; // AuthController: issue one token per device with narrow abilities and its own expiry $token = $user->createToken( $request->device_name, ['orders:read', 'orders:write'], now()->addDays(30), )->plainTextToken; // Require an ability on a route (alias the abilities middleware in bootstrap/app.php) Route::post('/orders', [OrderController::class, 'store']) ->middleware(['auth:sanctum', 'abilities:orders:write']); // Logout revokes only this device's token $request->user()->currentAccessToken()->delete(); // Password reset, email change, "sign out everywhere": revoke all of them $user->tokens()->delete(); // routes/console.php: delete expired token rows Schedule::command('sanctum:prune-expired --hours=24')->daily(); Beyond expiry:
- Sanctum has no refresh tokens. Either accept re-login at expiry, or add a
/auth/token/rotateendpoint that issues a new token and deletes the old one in one transaction. Real OAuth2 refresh semantics are Passport’s job. - One token per device, named after the device, so a lost phone can be revoked alone. Device binding (a Secure Enclave or Keystore key that signs requests) is worth the client work only where a replayed token is a real threat, such as payments.
- Step-up for high-risk actions.
password.confirmis session-based, so for a token API store areauthenticated_attimestamp on the token and require it to be recent before transfers, payout changes, or email changes. - Login and reset responses must not reveal whether an account exists: same message, same timing.
3. Authorization Must Be Explicit and Centralized
The most common API flaw, Broken Object Level Authorization, is simply this: a signed-in user changes an ID in the URL and reads someone else’s record. Authentication passed; nothing checked ownership. Fix it with policies or by scoping the query to the caller.
class OrderController extends Controller { // Vulnerable: any authenticated user can fetch any order by ID public function showUnsafe(string $id): OrderResource { return new OrderResource(Order::findOrFail($id)); } // Fixed with a policy (OrderPolicy::view) public function show(Order $order): OrderResource { Gate::authorize('view', $order); return new OrderResource($order); } // Or scope the lookup to the caller's own records public function showOwned(Request $request, string $id): OrderResource { return new OrderResource($request->user()->orders()->findOrFail($id)); } } - One policy class per model, and scoped relationships for list endpoints too. An
indexthat starts fromOrder::query()is the same bug spread across a page. - Function-level checks on admin routes with
can:middleware or a gate, separate from token abilities. Abilities say what a device may do; policies say what the user may do. - ULIDs or UUIDs (
HasUlids) in public URLs. Not a substitute for the policy, but it stops someone walking/orders/1to/orders/99999in an afternoon. - One feature test per resource that fetches another user’s record and asserts 403 or 404. It is the cheapest test in the suite and catches the refactor that drops a policy call.
4. Input Validation and Output Minimization
Every write endpoint gets a Form Request, and only validated fields reach the model.
- Declare
$fillableon every model and pass$request->validated(), never$request->all(). Otherwise a client setsis_adminoruser_idby adding a key to the JSON body. - A
max:on every string and array rule, andFile::types([...])->max(...)on uploads. Unbounded input is how a free-text field becomes a 50 MB request. - Eloquent binds parameters, so SQL injection enters through
DB::raw,whereRaw, or a user-chosen column name. Bind values; allowlist column names, which cannot be bound. -
$hiddenforpassword,remember_token, and internal flags, and an API Resource for every response. Returning a model directly leaks every column the day someone adds one.
// Mass assignment: only fields that passed validation $order->update($request->validated()); // Raw SQL: bind values, never interpolate them Order::whereRaw('total > ?', [$minimum])->get(); // Column names cannot be bound, so allowlist them $sort = in_array($request->query('sort'), ['created_at', 'total'], true) ? $request->query('sort') : 'created_at'; Order::orderBy($sort)->paginate(25); 5. Rate Limiting and Abuse Controls
A single global limit protects the server and nothing else. Limits need to match the endpoint:
- Login, password reset, OTP request: per account plus IP, and per IP alone. Five a minute per account is a normal start.
- OTP verification: a handful of attempts per code, then invalidate it. A six-digit code with unlimited tries is a million-row lookup table.
- Search and feed endpoints: a per-user burst limit so one client cannot scrape at full speed.
- Page sizes capped server-side (
min($request->integer('per_page', 25), 100)) so one request cannot pull a table. - Business flows (signup, checkout, referral): per-account counts over a day, not just per minute.
Laravel’s named rate limiters let each group key its limit by user, IP, or both. Key by user ID whenever the caller is authenticated; carrier NAT puts thousands of phones behind one IP, so IP-only limits lock out real users while barely slowing a proxy pool.
// AppServiceProvider::boot() RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); RateLimiter::for('login', function (Request $request) { return [ Limit::perMinute(5)->by(strtolower((string) $request->input('email')).'|'.$request->ip()), Limit::perMinute(20)->by($request->ip()), ]; }); // routes/api.php Route::post('/login', LoginController::class)->middleware('throttle:login'); Two operational details. Counters live in the cache, so production needs Redis or another shared store; the file driver keeps separate counts per server. And throttle already sends Retry-After on 429; make sure the body is your JSON error envelope so the app shows a message instead of a decode failure.
6. Data Protection, Secrets, and Production Config
Security Misconfiguration findings come almost entirely from this list, and each is a five-minute fix.
-
APP_DEBUG=falseandAPP_ENV=production, withconfig:cacheon deploy. The debug error page prints stack traces, request data, and environment values to whoever triggered the exception. -
.envnever committed; secrets injected from the host’s secret store.APP_PREVIOUS_KEYSlets you rotateAPP_KEYwithout breaking existing encrypted data. - Trusted proxies configured (
$middleware->trustProxies(...)) behind a load balancer. Otherwise$request->ip()is the balancer’s address, every user shares one IP, and the per-IP limiters in section 5 lock everyone out at once. - TLS at the load balancer with HSTS, and
URL::forceScheme('https')so generated links never downgrade. - The
encryptedcast for third-party tokens and similar secrets. Encrypted columns cannot be indexed, so add a hashed blind-index column if you need to look a value up. -
config/cors.phptight for any web frontend, knowing that native HTTP clients ignore CORS entirely. It is not an access control for the app.
7. Secure File Upload and External Integration Boundaries
Uploads and outbound requests are where an API starts touching things it does not control.
- Validate uploads with
File::types()andmax(). Laravel’smimesrule inspects file contents, not the extension. - Uploads on a private disk, served through temporary URLs; nothing user-supplied under
public/. For large files, let the app upload straight to S3 with a presigned URL (see the backend design guide) and validate size and type afterwards. - If the API fetches a URL the user supplies (image import, link preview, webhook target), that is a server-side request forgery surface. Allow only
httpandhttps, resolve the host and reject loopback, private, and link-local ranges including IPv6, re-check after each redirect, and set a short timeout. -
Http::timeout(),connectTimeout(), and a boundedretry()on every third-party call. A partner API that hangs for 60 seconds takes your PHP-FPM pool with it. - Validate third-party responses as strictly as client input; a buggy upstream is API10 for a reason.
8. Logging, Monitoring, and Alerting
After an incident a log has to answer “which user, from which device, read or changed which record, and when?” Build that context in from day one.
- A middleware that generates a request ID and calls
Log::withContext()with it plus the user ID, client platform, and build. In Laravel 11+, theContextfacade carries the same values into queued jobs, so a job log traces back to the request that queued it. - Listen for
Illuminate\Auth\Events\FailedandLockout, and log 403s from the exception handler with user, route, and object ID. A user collecting 403s across sequential IDs is someone probing for the bug in section 3. - Never log the
Authorizationheader, tokens, passwords, OTP codes, or full request bodies. Check what your error reporter captures; most send request data unless told not to. - Alerts on failed logins per IP and per account, 403s per user, 5xx per route, and queue backlog. Dashboards nobody is paged from are decoration.
- Retention is set by your compliance regime, not disk cost. PCI DSS, for example, requires twelve months of audit logs with three months immediately available.
9. Queue, Job, and Cron Hardening
Workers move money, send notifications, and call partner APIs with the same credentials as the web tier, usually with less scrutiny.
- Pass IDs into jobs and reload the model instead of serializing sensitive attributes. If a job must carry a secret, implement
ShouldBeEncryptedso the payload is encrypted at rest. - Verify webhook signatures with
hash_equals(), reject timestamps older than a few minutes, and make the handler idempotent on the provider’s event ID with a unique index. Providers retry; your handler must not double-credit. - Set
$triesand$backoff, implementfailed(), and watchfailed_jobs. Retrying a payment job blindly after a fix is how refunds go out twice. -
ShouldBeUniqueor an idempotency key stored before the side effect for provisioning and money movement, so a duplicate dispatch is a no-op.
10. CI/CD Security Gates
If a check is optional in CI, it stops running the first week a deadline slips. Make these fail the build:
-
composer audit(andnpm auditfor frontend assets), failing on known advisories; see the Composer audit docs. Install with--no-devin production so debugging packages never ship. - Larastan (PHPStan) at a level that flags unchecked nullables and untyped request data flowing into models.
- A Laravel version that still receives security fixes; security support ends about two years after each major release.
- A
php artisan route:listdiff each release, so forgotten debug routes and retired API versions are noticed before a scanner finds them. - The cross-user access tests from section 3, and a test that the login limiter actually fires.
11. Incident Response Readiness
Each procedure below is a few lines of code or a short runbook. Write them before launch so the first run is not during an incident.
- Revocation: one user with
$user->tokens()->delete(); everyone by truncatingpersonal_access_tokens. Sanctum stores only SHA-256 hashes, so a leaked database does not expose usable tokens, but rotate anyway. - Key rotation: a new
APP_KEYwith the old one inAPP_PREVIOUS_KEYS, then every third-party credential the leaked environment could have exposed. - Forced update: a minimum supported build served from an app-config endpoint, so a client-side vulnerability can be retired without waiting on users.
- Severity levels with a named owner, log retention that survives the incident, and regulatory deadlines written down in advance (GDPR allows 72 hours to notify the supervisory authority).
Where to Start on a Live API
If the API is already in production and you have one week, do these in order. Each is a day or less:
- Set a Sanctum
expirationand schedulesanctum:prune-expired. - Grep controllers for
::findOrFail(and::find(on user-owned models; scope or add a policy to each. - Grep for
->all()passed intocreate,update, orfill; replace withvalidated(). - Add the
loginlimiter and confirmAPP_DEBUGand trusted proxies in production. - Add request-ID logging and the failed-login listener, then wire one alert.
- Put
composer auditin CI.
Workers, webhooks, and the incident runbook follow in the next sprint. If you want help with the work, see backend API development.
Sources
- OWASP API Security Top 10 (2023)
- OWASP Laravel Cheat Sheet
- Laravel docs: Sanctum, Authorization, Validation, Mass assignment, Rate limiting
Originally published at puneetpal.dev.
