laravel 7 min read

Building Secure REST APIs with Laravel Sanctum: Best Practices

Laravel Sanctum is the right choice for most Laravel REST APIs — simpler than Passport, powerful enough for SPA auth and mobile tokens. Here is how to build one correctly.

G
Gurpreet Singh
February 17, 2026

Sanctum vs Passport: Which One Do You Need?

Laravel ships with two API authentication packages and the choice between them confuses a lot of developers. Here is the short version:

  • Sanctum — lightweight token authentication for SPAs, mobile apps, and simple token-based APIs. No OAuth server, no client credentials, no authorization codes. Just tokens.
  • Passport — full OAuth2 server implementation. Use this when your API needs to issue access tokens to third-party applications on behalf of your users (i.e., you are building an API platform like Stripe or GitHub, where external developers build integrations against your API).

For 95% of Laravel projects — a Vue SPA consuming your own API, a mobile app for your own product, or a token-based API for your own clients — Sanctum is the right choice. It is simpler, faster, and easier to debug than Passport.

This guide builds a production-grade REST API with Sanctum, covering authentication, token abilities, rate limiting, versioning, response formatting, and testing.

Installation and Configuration

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

Add the HasApiTokens trait to your User model:

use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}

Authentication Endpoints

Login and Token Issuance

// routes/api.php
Route::post('/auth/login', [AuthController::class, 'login']);
Route::post('/auth/register', [AuthController::class, 'register']);
// app/Http/Controllers/Api/AuthController.php
class AuthController extends Controller
{
    public function login(LoginRequest $request): JsonResponse
    {
        if (!Auth::attempt($request->only('email', 'password'))) {
            return response()->json([
                'message' => 'Invalid credentials.',
            ], 401);
        }

        $user  = Auth::user();
        $token = $user->createToken(
            name: $request->device_name ?? 'api-token',
            abilities: $this->abilitiesForUser($user),
            expiresAt: now()->addDays(30)
        );

        return response()->json([
            'user'         => new UserResource($user),
            'access_token' => $token->plainTextToken,
            'token_type'   => 'Bearer',
            'expires_at'   => $token->accessToken->expires_at,
        ]);
    }

    public function logout(Request $request): JsonResponse
    {
        $request->user()->currentAccessToken()->delete();
        return response()->json(['message' => 'Logged out.']);
    }

    private function abilitiesForUser(User $user): array
    {
        return match(true) {
            $user->isAdmin()  => ['*'],         // all abilities
            $user->isPro()    => ['read', 'write'],
            default           => ['read'],
        };
    }
}

Token Abilities for Fine-Grained Access Control

Sanctum token abilities let you scope what each token can do — like OAuth scopes, but simpler. Define your ability constants and check them in controllers:

// app/Enums/TokenAbility.php
enum TokenAbility: string
{
    case READ      = 'read';
    case WRITE     = 'write';
    case DELETE    = 'delete';
    case ADMIN     = 'admin';
}

// In a controller action:
public function store(Request $request): JsonResponse
{
    abort_unless(
        $request->user()->tokenCan(TokenAbility::WRITE->value),
        403,
        'This token does not have write access.'
    );

    // ... create resource
}

// Or as middleware on route groups:
Route::middleware(['auth:sanctum', 'abilities:write'])->group(function () {
    Route::post('/posts', [PostController::class, 'store']);
    Route::put('/posts/{post}', [PostController::class, 'update']);
});

API Resource Classes for Consistent Responses

Never return Eloquent models directly from API controllers. Use API Resource classes to control exactly what data is exposed:

php artisan make:resource PostResource
php artisan make:resource PostCollection
// app/Http/Resources/PostResource.php
class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'           => $this->id,
            'title'        => $this->title,
            'slug'         => $this->slug,
            'excerpt'      => $this->excerpt,
            'body'         => $this->when(
                $request->routeIs('api.posts.show'), // only in detail view
                $this->body
            ),
            'category'     => $this->category,
            'tags'         => $this->tags,
            'published_at' => $this->published_at?->toISOString(),
            'author'       => new UserResource($this->whenLoaded('author')),
            'links'        => [
                'self' => route('api.posts.show', $this->slug),
            ],
        ];
    }
}

Using $this->when() conditionally includes the body field only on the detail endpoint — keeping list responses lean. $this->whenLoaded('author') only includes the relationship if it was eager-loaded, preventing N+1 queries from lazy loading in the resource.

Rate Limiting

Laravel's rate limiter is configured in AppServiceProvider and attached to routes via middleware:

// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        return $request->user()
            ? Limit::perMinute(120)->by($request->user()->id)  // authenticated
            : Limit::perMinute(30)->by($request->ip());        // unauthenticated
    });

    RateLimiter::for('auth', function (Request $request) {
        // Stricter limit for login endpoint to prevent brute force
        return Limit::perMinute(5)->by($request->ip());
    });
}
// routes/api.php
Route::middleware(['throttle:api'])->group(function () {
    Route::get('/posts', [PostController::class, 'index']);
});

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

API Versioning

Version your API from day one — changing it later without versioning breaks existing integrations. Route-based versioning is the most widely understood approach:

// routes/api.php
Route::prefix('v1')->name('api.v1.')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->name('api.v2.')->group(base_path('routes/api_v2.php'));

Keep v1 routes unchanged. Add new fields and endpoints in v2. Only remove or change fields in a new major version, giving consumers time to migrate.

Error Handling

Return consistent, machine-readable error responses:

// app/Exceptions/Handler.php (or bootstrap/app.php in L11+)
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (\Throwable $e, Request $request) {
        if ($request->expectsJson()) {
            $status  = method_exists($e, 'getStatusCode') ? $e->getStatusCode() : 500;
            $message = $e->getMessage() ?: 'Server error.';

            return response()->json([
                'error'   => [
                    'code'    => class_basename($e),
                    'message' => $message,
                ],
            ], $status);
        }
    });
})

Testing Your API

// tests/Feature/Api/PostApiTest.php
class PostApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_unauthenticated_request_returns_401(): void
    {
        $this->getJson('/api/v1/posts')
            ->assertStatus(401);
    }

    public function test_can_list_published_posts(): void
    {
        $user  = User::factory()->create();
        $token = $user->createToken('test', ['read'])->plainTextToken;

        Post::factory()->count(3)->published()->create();
        Post::factory()->count(2)->unpublished()->create();

        $this->withToken($token)
            ->getJson('/api/v1/posts')
            ->assertOk()
            ->assertJsonCount(3, 'data')
            ->assertJsonStructure([
                'data' => [
                    '*' => ['id', 'title', 'slug', 'excerpt', 'published_at'],
                ],
                'links', 'meta',
            ]);
    }

    public function test_write_token_cannot_delete(): void
    {
        $user  = User::factory()->create();
        $token = $user->createToken('test', ['read', 'write'])->plainTextToken; // no delete ability
        $post  = Post::factory()->published()->create();

        $this->withToken($token)
            ->deleteJson("/api/v1/posts/{$post->id}")
            ->assertForbidden();
    }
}

Frequently Asked Questions

How do I authenticate a Vue SPA with Sanctum?

For a same-domain SPA (Vue served from the same Laravel app), use Sanctum's cookie-based authentication — not tokens. Call /sanctum/csrf-cookie first, then POST to /login using your standard session-based auth. Sanctum's middleware will attach the session cookie. This is more secure than tokens for SPAs because the cookie is HttpOnly (not accessible to JavaScript) and immune to XSS token theft. Token-based auth is better suited for mobile apps and third-party API consumers.

How long should API tokens live?

For mobile apps: 30–90 days with a refresh token pattern (issue a new token before the old one expires). For server-to-server integrations: tokens can be long-lived or non-expiring if the client is trusted infrastructure. For short-lived user sessions via SPA: use cookie auth instead of tokens. Always let users revoke tokens individually (list tokens in account settings, with a revoke button) and implement automatic revocation when a user changes their password.

Should I use API Resources for every endpoint?

Yes, for any endpoint that returns model data. The cost is a few extra lines of code. The benefit is that you control exactly what data leaks out of your API — you will never accidentally expose a password hash, a Stripe customer ID, or internal status flags because they are on the model's $hidden array (which is not enough). API Resources make it explicit and auditable what each endpoint returns.

#Laravel #REST API #Sanctum #Authentication #API #PHP
G
Gurpreet Singh

Senior Full Stack Developer — Laravel, Vue.js, Nuxt.js & AI. Available for freelance projects.

Hire Me for Your Project

Related Articles