Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 11 min read

Laravel API Security Checklist 2026 for Mobile Apps

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 lo

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/rotate endpoint 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.confirm is session-based, so for a token API store a reauthenticated_at timestamp 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 index that starts from Order::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/1 to /orders/99999 in 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 $fillable on every model and pass $request->validated(), never $request->all(). Otherwise a client sets is_admin or user_id by adding a key to the JSON body.
  • A max: on every string and array rule, and File::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.
  • $hidden for password, 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=false and APP_ENV=production, with config:cache on deploy. The debug error page prints stack traces, request data, and environment values to whoever triggered the exception.
  • .env never committed; secrets injected from the host’s secret store. APP_PREVIOUS_KEYS lets you rotate APP_KEY without 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 encrypted cast 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.php tight 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() and max(). Laravel’s mimes rule 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 http and https, 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 bounded retry() 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+, the Context facade carries the same values into queued jobs, so a job log traces back to the request that queued it.
  • Listen for Illuminate\Auth\Events\Failed and Lockout, 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 Authorization header, 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 ShouldBeEncrypted so 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 $tries and $backoff, implement failed(), and watch failed_jobs. Retrying a payment job blindly after a fix is how refunds go out twice.
  • ShouldBeUnique or 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 (and npm audit for frontend assets), failing on known advisories; see the Composer audit docs. Install with --no-dev in 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:list diff 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 truncating personal_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_KEY with the old one in APP_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 expiration and schedule sanctum:prune-expired.
  • Grep controllers for ::findOrFail( and ::find( on user-owned models; scope or add a policy to each.
  • Grep for ->all() passed into create, update, or fill; replace with validated().
  • Add the login limiter and confirm APP_DEBUG and trusted proxies in production.
  • Add request-ID logging and the failed-login listener, then wire one alert.
  • Put composer audit in CI.

Workers, webhooks, and the incident runbook follow in the next sprint. If you want help with the work, see backend API development.

Sources

Originally published at puneetpal.dev.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.