DEV Community

Marcc Atayde
Marcc Atayde

Posted on

Laravel API Security in Depth: Sanctum Tokens, Granular Rate Limiting, and Multi-Version Routing

You've built the endpoints, wired up the controllers, and the happy path works. Then someone hammers your API with 10,000 requests in a minute, a client ships a breaking change to mobile users on v1, and a rogue token gives third-party access to everything it shouldn't. This is where most "RESTful API" tutorials stop — and where real API design begins.

This article goes beyond the basics. We'll cover Laravel Sanctum token scopes, building genuinely useful rate limiting that doesn't punish legitimate users, and a versioning strategy that scales without turning your routes file into spaghetti.

Authentication with Laravel Sanctum: Scopes and Token Abilities

Sanctum is the right tool for most Laravel APIs — it's lightweight, integrates with your existing users table, and supports both SPA cookie auth and token-based auth. But most implementations ignore token abilities entirely.

Creating Tokens with Abilities

// Issue a token with specific abilities
$token = $user->createToken('mobile-app', ['products:read', 'orders:create']);

return response()->json([
    'token' => $token->plainTextToken,
]);
Enter fullscreen mode Exit fullscreen mode

Checking Abilities in Controllers

public function store(Request $request)
{
    if (!$request->user()->tokenCan('orders:create')) {
        abort(403, 'Token lacks required ability: orders:create');
    }

    // proceed with order creation
}
Enter fullscreen mode Exit fullscreen mode

Or use a dedicated middleware approach with a reusable ability check:

// app/Http/Middleware/RequireTokenAbility.php
public function handle(Request $request, Closure $next, string $ability): Response
{
    if (!$request->user()?->tokenCan($ability)) {
        return response()->json(['message' => 'Insufficient token permissions.'], 403);
    }

    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

Register it in bootstrap/app.php (Laravel 11+):

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'token.ability' => RequireTokenAbility::class,
    ]);
})
Enter fullscreen mode Exit fullscreen mode

Now your routes self-document their permission requirements:

Route::post('/orders', [OrderController::class, 'store'])
    ->middleware('token.ability:orders:create');
Enter fullscreen mode Exit fullscreen mode

Granular Rate Limiting That Actually Makes Sense

The default throttle:60,1 is a blunt instrument. A read-heavy dashboard client and a bulk import tool have very different legitimacy profiles. Laravel's RateLimiter facade lets you build context-aware limits.

Defining Named Limiters

In AppServiceProvider::boot() (or a dedicated RateLimitServiceProvider):

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('api', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(120)->by($request->user()->id)
        : Limit::perMinute(20)->by($request->ip());
});

RateLimiter::for('exports', function (Request $request) {
    return [
        Limit::perMinute(5)->by($request->user()->id),
        Limit::perDay(50)->by($request->user()->id),
    ];
});

RateLimiter::for('auth', function (Request $request) {
    return Limit::perMinute(10)->by($request->ip())
        ->response(function () {
            return response()->json([
                'message' => 'Too many login attempts. Try again in a moment.'
            ], 429);
        });
});
Enter fullscreen mode Exit fullscreen mode

Applying Limiters to Route Groups

Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    Route::get('/products', [ProductController::class, 'index']);
    Route::get('/orders', [OrderController::class, 'index']);
});

Route::middleware(['auth:sanctum', 'throttle:exports'])->group(function () {
    Route::get('/orders/export', [ExportController::class, 'orders']);
});

Route::middleware('throttle:auth')->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
});
Enter fullscreen mode Exit fullscreen mode

The X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers are automatically injected by Laravel's throttle middleware — make sure your API clients are reading and respecting them.

API Versioning Without the Chaos

Versioning is one of those things that feels over-engineered until you ship a breaking change and break three client apps at once. There are two dominant patterns in Laravel: URL prefix versioning and header-based versioning. URL prefixing wins on debuggability — you can see the version in logs, browser tabs, and Postman without extra config.

Folder and Namespace Structure

app/Http/Controllers/
├── Api/
│   ├── V1/
│   │   ├── ProductController.php
│   │   └── OrderController.php
│   └── V2/
│       ├── ProductController.php
│       └── OrderController.php
Enter fullscreen mode Exit fullscreen mode

Route Files

// routes/api.php
use App\Http\Controllers\Api;

Route::prefix('v1')->name('api.v1.')->group(function () {
    Route::apiResource('products', Api\V1\ProductController::class);
    Route::apiResource('orders', Api\V1\OrderController::class);
});

Route::prefix('v2')->name('api.v2.')->group(function () {
    Route::apiResource('products', Api\V2\ProductController::class);
    Route::apiResource('orders', Api\V2\OrderController::class);
});
Enter fullscreen mode Exit fullscreen mode

Sharing Logic Between Versions with Base Controllers

Don't duplicate code — extend a versioned base from a shared abstract:

// app/Http/Controllers/Api/BaseProductController.php
abstract class BaseProductController extends Controller
{
    protected function formatProduct(Product $product): array
    {
        return [
            'id'    => $product->id,
            'name'  => $product->name,
            'price' => $product->price,
        ];
    }
}

// app/Http/Controllers/Api/V2/ProductController.php
class ProductController extends BaseProductController
{
    public function index()
    {
        return ProductResource::collection(
            Product::with('category')->paginate()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

V2 adds category data and pagination; V1 keeps the original flat response. Both extend the same base — changes to shared logic propagate correctly.

API Resources: Your Contract with Consumers

Never return Eloquent models directly from an API. JsonResource classes act as a transformation and documentation layer:

// app/Http/Resources/V2/ProductResource.php
public function toArray(Request $request): array
{
    return [
        'id'         => $this->id,
        'name'       => $this->name,
        'price'      => number_format($this->price, 2),
        'currency'   => 'AED',
        'category'   => new CategoryResource($this->whenLoaded('category')),
        'created_at' => $this->created_at->toIso8601String(),
    ];
}
Enter fullscreen mode Exit fullscreen mode

The whenLoaded() helper prevents N+1 and avoids over-fetching — the field only appears if the relationship was eager-loaded.

Practical Considerations for Production

Token rotation: Set token expiry and build a /auth/refresh endpoint. Long-lived tokens are a liability.

Structured error responses: Be consistent. Pick a shape and stick to it:

{
  "message": "Validation failed.",
  "errors": {
    "email": ["The email field is required."]
  }
}
Enter fullscreen mode Exit fullscreen mode

Deprecation headers: When you ship v3, start sending Deprecation: true and Sunset: Sat, 01 Jan 2026 00:00:00 GMT headers on v1 responses. Clients that read headers will catch it early.

Logging: Attach the authenticated user ID and token ID to your log context inside a middleware so every log line is traceable:

Logger::withContext([
    'user_id'  => $request->user()?->id,
    'token_id' => $request->user()?->currentAccessToken()?->id,
]);
Enter fullscreen mode Exit fullscreen mode

A colleague who works as a Dubai web developer once pointed out that the most expensive API bugs in client projects were never about logic — they were about missing authentication checks on a single route, a rate limiter that only applied to half the endpoints, and a versioning strategy that was never enforced. The infrastructure around your business logic matters as much as the logic itself.

Conclusion

Building a reliable Laravel API means thinking in layers: authentication that's granular enough to limit blast radius when a token leaks, rate limiting that's smart enough to distinguish users from bots, and versioning that lets you ship breaking changes without breaking anyone. Each of these is independently simple — the craft is in wiring them together consistently from day one rather than retrofitting them after your first incident.

Start with Sanctum abilities even if you don't need them yet. Define named rate limiters from day one. Create the V1 namespace before you have a V2. Future you will be grateful.

Top comments (0)