Laravel Sanctum giải quyết hai bài toán khác nhau: xác thực SPA chính chủ bằng session cookie và cấp personal access token cho mobile hoặc API client. Phần lớn lỗi triển khai đến từ việc trộn hai mô hình này, chẳng hạn lưu Bearer token của SPA trong localStorage hoặc cấu hình CORS đúng nhưng quên gửi cookie.
Chọn đúng mô hình trước khi viết code
| Client | Cơ chế nên dùng | Lý do |
|---|---|---|
| SPA chính chủ cùng top-level domain | Session cookie + CSRF | Thông tin xác thực nằm trong cookie HttpOnly và hưởng cơ chế session của Laravel |
| Ứng dụng mobile | Personal access token | Client gửi Bearer token trên mỗi request |
| CLI hoặc tích hợp đơn giản | Personal access token có abilities | Dễ cấp, giới hạn và thu hồi theo thiết bị hoặc mục đích |
| Ủy quyền bên thứ ba chuẩn OAuth | Laravel Passport hoặc OAuth provider | Sanctum không thay thế đầy đủ authorization code flow, client credentials và consent |
Với SPA chính chủ, tài liệu Laravel khuyến nghị dùng xác thực SPA dựa trên cookie của Sanctum, không dùng API token.
Cài Sanctum và bảo vệ route
Trong Laravel 13, lệnh cài API sẽ cài và cấu hình Sanctum:
php artisan install:api
php artisan migrate
Model người dùng cần trait HasApiTokens nếu ứng dụng phát personal access token:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
Dùng cùng middleware auth:sanctum cho route nhận cookie từ SPA hoặc Bearer token từ client bên ngoài:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', fn (Request $request) => $request->user());
Route::post('/projects', [ProjectController::class, 'store']);
});
Luồng đăng nhập đúng cho SPA
SPA và API phải dùng chung top-level domain, dù có thể nằm ở các subdomain khác nhau, ví dụ app.example.com và api.example.com. Bật stateful API middleware trong bootstrap/app.php:
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})
Trước khi đăng nhập, client gọi endpoint CSRF, sau đó mới gửi thông tin đăng nhập:
axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;
await axios.get('/sanctum/csrf-cookie');
await axios.post('/login', { email, password });
const { data } = await axios.get('/api/user');
Request đầu tiên đặt cookie XSRF-TOKEN. Axios đọc giá trị đó và gửi header X-XSRF-TOKEN cho request thay đổi dữ liệu. Cookie session xác định người dùng; CSRF token chứng minh request đến từ giao diện hợp lệ. Hai thành phần có vai trò khác nhau.
Cấu hình domain, cookie và CORS
Các biến môi trường phụ thuộc domain thực tế, nhưng một cấu hình nhiều subdomain thường có dạng:
APP_URL=https://api.example.com
FRONTEND_URL=https://app.example.com
SESSION_DOMAIN=.example.com
SESSION_SECURE_COOKIE=true
SANCTUM_STATEFUL_DOMAINS=app.example.com
Nếu development dùng port, phải đưa cả port vào danh sách stateful. Khi frontend gọi khác origin, cấu hình CORS phải cho phép đúng origin và bật supports_credentials. Không thể dùng wildcard * cho origin khi gửi credential.
- Chỉ dùng HTTPS ở production và bật Secure cho session cookie.
- Giữ HttpOnly cho cookie session để JavaScript không đọc được.
- Chọn SameSite phù hợp với kiến trúc; không hạ xuống
Nonenếu không thực sự cần. - Không cho phép origin động dựa trực tiếp trên header của request.
- Proxy hoặc load balancer phải chuyển tiếp scheme chính xác để Laravel nhận biết HTTPS.
Cấp token cho mobile và API client
Token chỉ hiển thị dạng rõ một lần khi được tạo; Sanctum lưu bản băm SHA-256 trong database. Hãy đặt tên theo thiết bị hoặc mục đích và chỉ cấp abilities cần thiết:
$token = $user->createToken(
'iphone-15',
['projects:read', 'projects:update'],
now()->addDays(30),
);
return ['token' => $token->plainTextToken];
Client gửi token qua header, không đặt trong query string:
Authorization: Bearer 1|plain-text-token
Trên mobile, lưu token trong Keychain hoặc Keystore. Với CLI và server integration, dùng secret manager hoặc credential store của hệ điều hành; không commit token vào source, file cấu hình mẫu hay log.
Abilities không thay thế authorization policy
Abilities giới hạn token được phép làm gì, còn policy xác định người dùng có quyền trên resource cụ thể hay không. Route có thể yêu cầu một hoặc nhiều ability:
Route::put('/projects/{project}', UpdateProjectController::class)
->middleware(['auth:sanctum', 'abilities:projects:update']);
Trong policy, vẫn phải kiểm tra quyền sở hữu hoặc vai trò:
public function update(User $user, Project $project): bool
{
return $user->id === $project->owner_id
&& $user->tokenCan('projects:update');
}
Với request từ SPA chính chủ, tokenCan() có thể trả về true theo thiết kế của Sanctum. Vì vậy policy hoặc gate mới là nơi bắt buộc thực thi quyền nghiệp vụ.
Thu hồi, hết hạn và dọn token
Mặc định token Sanctum không hết hạn nếu ứng dụng không cấu hình expiration. Production nên có thời hạn phù hợp, danh sách thiết bị đang đăng nhập và chức năng thu hồi:
// Thu hồi token hiện tại
$request->user()->currentAccessToken()->delete();
// Thu hồi một token cụ thể
$user->tokens()->whereKey($tokenId)->delete();
// Thu hồi mọi token
$user->tokens()->delete();
Lên lịch dọn bản ghi token đã hết hạn:
use Illuminate\Support\Facades\Schedule;
Schedule::command('sanctum:prune-expired --hours=24')->daily();
Đổi mật khẩu, khóa tài khoản hoặc phát hiện thiết bị mất nên kích hoạt chính sách thu hồi tương ứng. Với hệ thống nhạy cảm, ghi audit log cho việc tạo, sử dụng gần nhất và thu hồi token, nhưng không ghi giá trị token dạng rõ.
Rate limit endpoint đăng nhập và API
Rate limiting giảm brute force và lạm dụng API. Phân đoạn giới hạn theo user khi đã đăng nhập, theo IP và định danh đã chuẩn hóa khi chưa đăng nhập:
RateLimiter::for('login', function (Request $request) {
$email = Str::lower((string) $request->input('email'));
return [
Limit::perMinute(20)->by('ip:'.$request->ip()),
Limit::perMinute(5)->by('login:'.$email.'|'.$request->ip()),
];
});
Không chỉ dựa vào email vì kẻ tấn công có thể làm khóa tài khoản của người khác. Trong hệ thống nhiều máy chủ, dùng cache tập trung như Redis để mọi instance chia sẻ cùng bộ đếm.
Các lỗi production thường gặp
- 401: request không gửi cookie hoặc Bearer token, route dùng sai guard.
- 419: chưa gọi
/sanctum/csrf-cookie, thiếu header XSRF hoặc session đã hết hạn. - CORS error: origin không khớp, thiếu credentials hoặc preflight bị proxy chặn.
- Hoạt động local nhưng lỗi production: sai session domain, Secure cookie hoặc trusted proxy.
- Token có ability nhưng vẫn 403: policy từ chối quyền trên resource cụ thể.
- Đăng xuất nhưng token còn dùng được: chỉ xóa session mà chưa thu hồi personal access token.
Kiểm thử bắt buộc
- SPA lấy CSRF cookie, đăng nhập, gọi route bảo vệ và đăng xuất.
- Request ghi thiếu/sai CSRF token phải bị từ chối khi không được nhánh same-origin chấp nhận; Laravel 13 có origin verification trước token fallback.
- Token đúng ability được phép; token thiếu ability nhận 403.
- User không sở hữu resource vẫn bị policy từ chối dù token có ability.
- Token hết hạn hoặc đã thu hồi nhận 401.
- Rate limiter trả 429 và header retry phù hợp.
- CORS chỉ chấp nhận origin đã định trước.
Checklist trước khi phát hành
- SPA chính chủ dùng cookie session, không lưu personal token trong localStorage.
- Stateful domains, session domain, CORS và HTTPS đã khớp môi trường thật.
- Mobile/API token có abilities tối thiểu và thời hạn hữu hạn.
- Mọi route nhạy cảm có cả authentication và policy/gate.
- Token có giao diện quản lý thiết bị và quy trình thu hồi.
- Endpoint login, reset password và API quan trọng có rate limit.
- Log che token, cookie, mật khẩu và dữ liệu bí mật.
- Kiểm thử 401, 403, 419, 429 và luồng thu hồi token.
Kết luận
Sanctum đơn giản khi mỗi loại client đi đúng luồng: SPA dùng session cookie và CSRF; mobile hoặc API client dùng Bearer token có abilities. Lớp xác thực này chỉ là điểm bắt đầu. Một hệ thống production còn cần policy, thời hạn và thu hồi token, rate limit, HTTPS, cấu hình cookie chặt chẽ và kiểm thử các tình huống thất bại.
Nguồn tham khảo
- Laravel 13 Documentation: Sanctum
- Laravel 13 Documentation: Authorization
- Laravel 13 Documentation: Rate Limiting
Bài 25 trong khóa TaskFlow: thực hành bearer token
Phần trên trình bày cả SPA và personal token. TaskFlow của khóa học chọn hai đường tách biệt: /api/v1 trong web.php dùng session+CSRF từ bài 24; /api/token/v1 trong api.php dùng bearer token. Không bật statefulApi ở milestone này, config sanctum.guard=[] nên session web không tự truy cập được route token. Không copy cấu hình SPA phía trên đè lên lựa chọn này.
Dự án đã cài Sanctum 4.3.3, thêm HasApiTokens vào User và migration personal_access_tokens. Trên database phát triển đúng môi trường, chạy php artisan migrate trước khi dùng. Không cần chạy install:api lại trên source đã tích hợp vì có thể thay scaffolding hiện tại. Các test bên dưới chỉ migrate SQLite bộ nhớ, không sửa database đang sử dụng.
Đăng nhập, mở /tokens/create từ dashboard, nhập tên token, chọn read hoặc read/write và xác nhận mật khẩu hiện tại. Token có hạn một giờ, response no-store/private và chỉ hiển thị lúc cấp. Không lưu vào localStorage cho giao diện web, không ghi vào URL/log. Tải lại POST có thể cấp thêm token; đây không phải thao tác idempotent.
Client bên ngoài gửi Authorization: Bearer với token được cấp. GET /api/token/v1/projects/{project}/tasks cần tasks:read; POST cùng URL và PATCH /tasks/{task}/complete cần tasks:write. Policy owner vẫn áp dụng. DELETE /api/token/v1/token thu hồi chính token đang gọi, không xóa session hoặc các token khác. Location khi tạo trỏ đúng route bearer.
Alias abilities phải được đăng ký trong withMiddleware của bootstrap/app.php:
$middleware->alias([
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
]);
withRouting cũng khai báo api: __DIR__.'/../routes/api.php'. Code và test của milestone:
routes/api.php
<?php
use App\Http\Controllers\Api\TaskController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// Separate from the session API in web.php; no statefulApi middleware enabled.
Route::prefix('token/v1')->middleware('auth:sanctum')->scopeBindings()->group(function () {
Route::get('/projects/{project}/tasks', [TaskController::class, 'index'])
->middleware('abilities:tasks:read');
Route::get('/projects/{project}/tasks/{task}', [TaskController::class, 'show'])
->middleware('abilities:tasks:read')->name('token.tasks.show');
Route::post('/projects/{project}/tasks', [TaskController::class, 'store'])
->middleware('abilities:tasks:write')->name('token.tasks.store');
Route::patch('/projects/{project}/tasks/{task}/complete', [TaskController::class, 'complete'])
->middleware('abilities:tasks:write');
Route::delete('/token', function (Request $request) {
$request->user()->currentAccessToken()->delete();
return response()->noContent();
});
});app/Http/Controllers/TokenController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class TokenController extends Controller
{
public function create()
{
return response()->view('auth.tokens')->header('Cache-Control', 'no-store, private');
}
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:80'],
'access' => ['required', Rule::in(['read', 'write'])],
'password' => ['required', 'string', 'current_password:web'],
]);
$abilities = $data['access'] === 'write' ? ['tasks:read', 'tasks:write'] : ['tasks:read'];
$token = $request->user()->createToken($data['name'], $abilities, now()->addHour());
return response()->view('auth.tokens', ['plainToken' => $token->plainTextToken])
->header('Cache-Control', 'no-store, private');
}
}config/sanctum.php
<?php
return [
// This course exposes a dedicated bearer-only API. Browser routes use web auth.
'guard' => [],
'expiration' => 60,
];tests/Feature/SanctumTokenTest.php
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class SanctumTokenTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
}
private function tokenRequest(string $method, string $url, string $token, array $data = [])
{
// Model a new request rather than retaining the prior request's resolved guard.
app('auth')->forgetGuards();
return $this->withToken($token)->json($method, $url, $data);
}
public function test_read_token_cannot_write_and_other_owner_is_denied(): void
{
$project = Project::factory()->create();
$token = $project->owner->createToken('test-read', ['tasks:read'], now()->addHour())->plainTextToken;
$url = '/api/token/v1/projects/'.$project->id.'/tasks';
$this->tokenRequest('GET', $url, $token)->assertOk();
$this->tokenRequest('POST', $url, $token, ['title' => 'Denied', 'priority' => 'normal'])->assertForbidden();
$otherToken = User::factory()->create()->createToken('test-other', ['tasks:read', 'tasks:write'])->plainTextToken;
$this->tokenRequest('GET', $url, $otherToken)->assertForbidden();
$this->assertDatabaseCount('tasks', 0);
}
public function test_write_token_creates_task_and_can_revoke_itself(): void
{
$project = Project::factory()->create();
$issued = $project->owner->createToken('test-write', ['tasks:read', 'tasks:write'], now()->addHour());
$token = $issued->plainTextToken;
$this->assertNotSame($token, $issued->accessToken->token);
$url = '/api/token/v1/projects/'.$project->id.'/tasks';
$response = $this->tokenRequest('POST', $url, $token, ['title' => 'Token task', 'priority' => 'normal'])
->assertCreated();
$task = $project->tasks()->sole();
$response->assertHeader('Location', route('token.tasks.show', [$project, $task]));
$this->tokenRequest('DELETE', '/api/token/v1/token', $token)->assertNoContent();
$this->tokenRequest('GET', $url, $token)->assertUnauthorized();
}
public function test_expired_and_invalid_tokens_are_rejected(): void
{
$project = Project::factory()->create();
$expired = $project->owner->createToken('expired', ['tasks:read'], now()->subMinute())->plainTextToken;
$url = '/api/token/v1/projects/'.$project->id.'/tasks';
$this->tokenRequest('GET', $url, $expired)->assertUnauthorized();
$this->tokenRequest('GET', $url, 'invalid-test-token')->assertUnauthorized();
}
public function test_browser_identity_alone_does_not_enter_bearer_only_routes(): void
{
$project = Project::factory()->create();
$this->actingAs($project->owner, 'web')
->getJson('/api/token/v1/projects/'.$project->id.'/tasks')->assertUnauthorized();
}
public function test_token_provisioning_requires_password_and_uses_allowlisted_abilities(): void
{
$this->withoutVite();
$user = User::factory()->create(['password' => 'test-password-only']);
$this->actingAs($user, 'web')->postJson('/tokens', [
'name' => 'Client', 'access' => 'read', 'password' => 'wrong',
])->assertUnprocessable()->assertJsonValidationErrors('password');
$this->assertDatabaseCount('personal_access_tokens', 0);
$response = $this->post('/tokens', ['name' => 'Client', 'access' => 'read',
'password' => 'test-password-only', 'abilities' => ['*']])->assertOk();
$this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
$record = $user->tokens()->sole();
$this->assertSame(['tasks:read'], $record->abilities);
$this->assertTrue($record->expires_at->isFuture());
$this->get('/tokens/create')->assertOk()->assertViewMissing('plainToken');
}
}Chạy php artisan test --filter=SanctumTokenTest và php artisan test. Suite đạt 76 test, 348 assertions: kiểm tra token thật, thiếu ability, khác owner, hết hạn, thu hồi, session không thay bearer, password khi cấp và allowlist quyền. Chưa có test trình duyệt SPA/CORS; chưa có màn hình liệt kê/thu hồi token thất lạc, scheduler prune hoặc quota API riêng. Đây là phần còn cần hoàn thiện trước production, không phải tính năng đã có.
Điều hướng: Bài 24 · Lộ trình. Tiếp theo: Queue.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.