How to Integrate Laravel Sanctum with Spatie Permissions
In this article, I’m going to show you a simplified setup of how I’ve used Spatie Roles & Permissions package with Laravel Sanctum. This will allow users Sanctum tokens to respect the user’s abilities through this package.
Why?
Roles and permissions is a widely used package for determining authorization within your application. I treat a user’s abilities through its role as the source of truth for what this user can or cannot do.
By default, sanctum allows for *
ability, which means this token has cart blanche across any middleware checks.
You can also pass explicit permissions to the token:
$user->createAccessToken('some-token', ['view:protected-rotue'])->plainTextToken
The abilities of a token are specific to that given token, so regardless of the user the token belongs to, the token can do anything in its capabilities column.
This introduces scenarios where we may not give a user the permission to do an action, but in creating a token for this user, they may be able to perform those actions through their API token.
In effect, we’d be maintaining two authorization systems. Life is too short to work that hard.
💡
By default, abilities are stored in the abilities
column in the personal access tokens
table.
How to sync with Spatie Permissions
Basically, we’re going to override how Laravel’s default PersonalAccessToken
class looks up a token’s capabilities and defer to its tokenable
model’s capabilities. The tokenable
model is our User
model by default.
Install Roles & Permissions + Sanctum
To begin, you should have both Spatie Roles & Permissions installed, as well as Laravel Sanctum.
During installation, make sure you:
-
Apply the
HasRoles
andHasApiTokens
traits to the User model. -
Publish the configurations and run all applicable migrations.
-
If your user model is using UUIDs, then be sure to configure your migrations.
Extend the Personal Access Token
The Sanctum docs detail how to override the Personal Access Token model here.
Following the same pattern, create a new file in /app/Models/CustomToken.php
This is the new model Sanctum will use to manage tokens.
<?php
namespace App\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
class CustomToken extends SanctumPersonalAccessToken
{
public $table = 'personal_access_tokens';
public function can($ability)
{
return $this->tokenable->can($ability);
}
}
This class is overriding the can()
method. Originally, the method does a look up on the token’s abilities column. Our class will proxy the same ability check, but against our user model’s permissions according to the Spatie permissions package.
Register the Custom Access Token Model
Next, we need to tell Sanctum to override the default model for our Custom token.
<?php
namespace App\Providers;
use App\Models\CustomToken;
use Illuminate\Support\ServiceProvider;
use Laravel\Sanctum\Sanctum;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Sanctum::usePersonalAccessTokenModel(CustomToken::class);
}
}
Example Usage
Here is a basic setup for seeing the same rule set in action across web guard and sanctum.
Routes
Our web.php
routes file could look like this:
Route::get('/protected', function () {
Route::get('/protected', function () {
return 'This is protected';
})->middleware('auth', 'can:view-protected');
And your api.php
route looks like this:
Route::get('/protected', function () {
return 'This is protected by sanctum';
})->middleware(['auth:sanctum', 'can:view-protected'])
->name('api.protected');
If you do not have a /routes/api.php
file yet, first create a /routes/api.php
file. Then, in your bootstrap/app.php
file, you can add it here:
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
api: __DIR__.'/../routes/api.php', // 👈 Add this
health: '/up',
)
Tests
Next, we can try out our new setup using some tests below.
<?php
use App\Models\User;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use function Pest\Laravel\get;
beforeEach(function () {
$this->role = Role::create(['name' => 'admin']);
$this->permission = Permission::create(['name' => 'view-protected']);
$this->role->givePermissionTo($this->permission);
$this->route = route('api.protected');
});
test('user can access protected route', function () {
$user = User::factory()
->create()
->assignRole($this->role);
$this->actingAs($user)
->get('/protected')
->assertStatus(200);
});
test('user cannot access protected route', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/protected')
->assertStatus(403);
});
test('Spatie permissions are proxied to token abilities', function () {
$token = User::factory()
->create()
->givePermissionTo('view-protected')
->createToken('test-token')
->plainTextToken;
get(route('api.protected'), ['Authorization' => 'Bearer '.$token])
->assertOk(200);
});
In the tests above, we’re verifying that a user can access the same respective abilities in both the API as well as the web guard (our web app).
Trade-offs & Advanced Usage
In a tradition as old as time, when figuring out if this pattern makes sense for your application, the answer is always it depends. I find this approach works brilliantly if I have a both a user-facing dashboard, as well as a public API, and I want to create a simple token that allows users to do exactly what they can do in the admin. However, there are trade offs. In our simpler approach, we don’t account for allowing users to create more granular-permission tokens, which violates the Principle of least privilege.
If a user wants to be more granular with token scopes, we can alter our approach to allow a layered permission check. In the class below, this would allow the User
to create either tokens with specific permissions, or defer to the inherited permissions of the user.
<?php
namespace App\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
class CustomToken extends SanctumPersonalAccessToken
{
public $table = 'personal_access_tokens';
public function can($ability)
{
// if the user can't do it, our token can't either.
if(!$this->tokenable->can($ability)) {
return false;
}
// no wildcards. sorry Charlie.
$abilities = collect($this->abilities)->filter(function($ability) {
return $ability !== '*';
})->toArray();
// we have explicit permissions passed to `createAccessToken`
if(count($abilities) > 0) {
return $this->canDb($abilities);
}
return true;
}
protected function canDb($ability): bool
{
return array_key_exists($ability, array_flip($this->abilities));
}
}
Video Tutorial
If you are a visual learner, I recorded this video just for you :)
https://www.youtube.com/watch?v=YX_X-kYLN8c
Thanks for reading, and if you’d like to chat more about this, you can find me over at https://bsky.app/profile/arter.dev