Building a Role-Based REST API with Laravel Sanctum

by DevsrealmGuy

We will learn how roles and permissions work in Laravel Sanctum. At the end of this guide, you will build a multi-user REST API in which resources are scoped by the user role and their permissions. The code for this guide is available on GitHub.

Introduction

Laravel API authentication gets awkward once different callers need different levels of access. Laravel Sanctum handles it with scoped tokens, and this guide shows you how.

You'll build a role-based authentication system on top of Sanctum: an admin who can do everything, a writer who can manage posts, and a subscriber who can only read them. The full source code is on GitHub.

Prerequisites

  • Basic knowledge of PHP and Laravel
  • An API client such as Postman, Insomnia or hoppscotch. This guide uses Insomnia
  • PHP Composer installed
  • A web server such as Nginx, Apache2 or Caddy

What is Laravel Sanctum

Laravel Sanctum is the Laravel team's first-party package for API token authentication. It issues lightweight API tokens, and each token can carry abilities (scopes you check on every request), which is what makes it a natural fit for role-based access control. Sanctum ships with Laravel and needs no OAuth server to run.

Sanctum authenticates a token API, a SPA (Single Page Application) or a mobile application.

This guide covers the API token half of Sanctum: issuing tokens, then authenticating and authorizing requests that carry them. First, a word on why tokens beat the alternative.

Why Use Token-Based Authentication?

With HTTP basic authentication, the client sends a username and password on every request.

That approach is stateless, which is its main appeal: no cookies or session IDs on the server, and the HTTP header carries everything.

The cost is that credentials travel with every request. Unless you enforce SSL across the whole cycle, an attacker in the middle can capture the login data and reuse it. Even over SSL, you're re-sending the username and password for each protected resource, which is more exposure than the job needs.

With token-based authentication, the client proves itself with a token issued by an administrator or generated by the account holder, the way a GitHub personal access token works.

Users then stop handing over their password for every resource, and you as the issuer gain control over how the token behaves: rate limiting, scoped access, expiry after a set time. Sanctum handles most of that for you.

Laravel Sanctum vs Passport

Both are official Laravel packages, but they solve different problems. Sanctum is a lightweight token issuer with no OAuth and minimal setup, built for first-party APIs, SPAs, and mobile apps, which covers the large majority of applications. Passport is a full OAuth2 server: reach for it when you need third-party clients to authorize against your API with authorization codes, client credentials, and refresh tokens.

For the role-based API in this guide, Sanctum is the right choice. You get token authentication plus per-token abilities without standing up and maintaining an OAuth server.

Building the Role-Based API with Laravel Sanctum

The full source code is on GitHub if you'd rather read ahead.

Step 1: Setting Up a New Instance of Laravel app and Sanctum Package

Create a new Laravel application by running this command in your terminal:

composer create-project --prefer-dist laravel/laravel simpleblog

That installs Laravel into a new simpleblog folder. Change into it once the install finishes.

Next, install Sanctum:

composer require laravel/sanctum

Update your .env file with your database credentials, then add a role column to the user migration. The up function should look like this:

public function up()
{
    Schema::create('users', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('email')->unique();
        $table->timestamp('email_verified_at')->nullable();
        $table->string('password');
        $table->tinyInteger('role')->default(1); // <---- add this
        $table->rememberToken();
        $table->timestamps();
    });
}

Then publish Sanctum's configuration and migration files, and run the migration:

php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

The Sanctum configuration file lands in your config directory, and the personal_access_tokens table, where the tokens live, is created with these columns:

+----------------+---------------------+------+-----+---------+----------------+
| Field          | Type                | Null | Key | Default | Extra          |
+----------------+---------------------+------+-----+---------+----------------+
| id             | bigint(20) unsigned | NO   | PRI | NULL    | auto_increment |
| tokenable_type | varchar(255)        | NO   | MUL | NULL    |                |
| tokenable_id   | bigint(20) unsigned | NO   |     | NULL    |                |
| name           | varchar(255)        | NO   |     | NULL    |                |
| token          | varchar(64)         | NO   | UNI | NULL    |                |
| abilities      | text                | YES  |     | NULL    |                |
| last_used_at   | timestamp           | YES  |     | NULL    |                |
| created_at     | timestamp           | YES  |     | NULL    |                |
| updated_at     | timestamp           | YES  |     | NULL    |                |
+----------------+---------------------+------+-----+---------+----------------+
9 rows in set (0.001 sec)

You don't need to know these columns to use Sanctum. They're here so you know what the migration created.

Lastly, add the Laravel\Sanctum\HasApiTokens trait to your User model, and replace the $fillable property with the following:

app/Models/User.php
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{

    use HasFactory, Notifiable, HasApiTokens;

    // --------------------------------↑

    protected $fillable = [
        'name',
        'email',
        'password',
        'role'
    ];

The trait brings the createToken() method with it, which is what issues tokens.

REST API Endpoints

Each user carries a role, and a resource is reachable only by the roles it allows. That's what gives you control over who can do what with the API.

Here are the endpoints and the roles that reach them:

  • GET /posts (list all posts). Admin only
  • GET /posts/:id (get a post). Admin, Writer and Subscriber
  • POST /posts (add a new post). Admin and Writer
  • PUT /post/:id (update a post). Admin and Writer
  • DELETE /posts/:id (delete a post). Admin and Writer
  • POST /users/writer (add a new user with writer scope). Admin only
  • POST /users/subscriber (add a new user with subscriber scope). Admin only
  • DELETE /user/:id (delete a user). Admin only

The first user to register takes the Admin role, and that admin then creates the users with narrower permissions: writers, subscribers and so on.

Create the Posts Table

You need somewhere to keep the posts. Create the migration and model together:

php artisan make:model Post -m

Then fill in the up function of the posts table migration:

database/migrations/2021_05_19_000239_create_posts_table.php
public function up()
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->string('slug')->unique();
        $table->longText('content');
        $table->timestamps();
    });
}

Four columns are enough for the API. Run the migration:

php artisan migrate

With the tables in place, you can start building the API.

Step 2: Scaffold UI With Laravel UI Package

The Laravel UI package gives you a working login and register page, so you don't have to build authentication screens before you can issue a token.

Note: Laravel Breeze works here too if you prefer it. The process is much the same.

Install Laravel UI via Composer:

composer require laravel/ui
php artisan ui:auth

The scaffolded pages come unstyled, which is fine: nothing here depends on how they look.

Step 3: Restrict Registration to Only One User Using Middleware

The first user to register takes the Admin role, so registration has to close behind them.

Laravel middleware does this. Create one:

php artisan make:middleware RestrictRegistrationToOneAdmin

The file lands in app/Http/Middleware. Inside RestrictRegistrationToOneAdmin, fill in the handle() method:

app/Http/Middleware/RestrictRegistrationToOneAdmin.php
namespace App\Http\Middleware;

use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

public function handle(Request $request, Closure $next)
{
    $user = DB::table('users')->select('role')->where('id',1)->first();

    if ($user && (int)$user->role === 1){
        // fail and redirect silently if we already have a user with that role
        return redirect("/");
    }

    return $next($request);
}

The admin is the first row in the table, so the check reads that row and looks for role 1. If it's there, registration redirects to the homepage. Until someone registers, the check finds nothing and lets the request through.

The middleware belongs on the registration route, but it needs a key in the application's app/Http/Kernel.php first:

app/Http/Kernel.php
protected $routeMiddleware = [
    // Other middleware here
    // ...
    'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
    // <-- add this
    'restrictothers' =>
        \App\Http\Middleware\RestrictRegistrationToOneAdmin::class,
];

With the key defined, the middleware method can attach it to a route. Open routes/web.php and add the following under Auth::routes();:

routes/web.php
use App\Http\Controllers\Auth\RegisterController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

Auth::routes();
// Add this ↓
Route::post('register', [RegisterController::class, 'register'])
    ->middleware('restrictothers');

// This serves as the create token page
Route::get('dashboard', function () {
    if(Auth::check() && Auth::user()->role === 1) {
        return auth()
            ->user()
            ->createToken('auth_token', ['admin'])
            ->plainTextToken;
    }
    return redirect("/");

})->middleware('auth');

Registering or logging in as the admin lands you on the dashboard route. The closure checks that the user is authenticated and is the admin, then creates and returns an API token.

Clear the route cache so the new routes take effect:

php artisan route:cache

Step 4: Issuing and Revoking The Admin User Token

Visit the /register route. Registering presents you with a token. Copy it and leave the page.

Note: While you stay logged in, every visit to the dashboard route mints another token.

In a real application you'd put that behind a button rather than a page visit.

To revoke tokens, add a route like this one to routes/web.php:

routes/web.php
Route::get('clear/token', function () {
    if(Auth::check() && Auth::user()->role === 1) {
        Auth::user()->tokens()->delete();
    }

    return 'Token Cleared';
})->middleware('auth');

Visiting clear/token as the admin deletes every token that admin holds. As with the dashboard route, a button suits a real application better.

Step 5: Creating and Restricting The API Endpoint

Open routes/api.php and add the endpoints listed earlier:

routes/api.php
use App\Http\Controllers\ControllerExample;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::group(['middleware' => 'auth:sanctum'], function() {
    // list all posts
    Route::get('posts', [ControllerExample::class, 'post']);
    // get a post
    Route::get('posts/{id}', [ControllerExample::class, 'singlePost']);
    // add a new post
    Route::post('posts', [ControllerExample::class, 'createPost']);
    // updating a post
    Route::put('posts/{id}', [ControllerExample::class, 'updatePost']);
    // delete a post
    Route::delete('posts/{id}', [ControllerExample::class, 'deletePost']);
    // add a new user with writer scope
    Route::post('users/writer', [ControllerExample::class, 'createWriter']);
    // add a new user with subscriber scope
    Route::post(
        'users/subscriber',
        [ControllerExample::class, 'createSubscriber']
    );
    // delete a user
    Route::delete('users/{id}', [ControllerExample::class, 'deleteUser']);
});

A route group shares attributes across many routes without repeating them on each one. Here the shared attribute is the auth:sanctum middleware, which limits every endpoint in the group to authenticated requests.

Authenticated is not the same as authorized. The guard confirms who is calling; authorization answers the next question, should this caller reach this resource?

That second question is what Sanctum abilities answer, and scoping the endpoints by ability is the job of the controller methods in the next step.

Step 6: Creating The API Controller Methods

Every controller method needs the same three things: a way to ask what a token is allowed to do, a consistent JSON envelope, and validation rules. Put them in a trait, ApiHelpers.php, in a new Library folder under app/Http.

The role checks are the heart of it. tokenCan() is Sanctum's own method for asking whether the token carries an ability:

app/Http/Library/ApiHelpers.php
namespace App\Http\Library;

use Illuminate\Http\JsonResponse;

trait ApiHelpers
{
    protected function isAdmin($user): bool
    {
        if (!empty($user)) {
            return $user->tokenCan('admin');
        }

        return false;
    }

    protected function isWriter($user): bool
    {

        if (!empty($user)) {
            return $user->tokenCan('writer');
        }

        return false;
    }

    protected function isSubscriber($user): bool
    {
        if (!empty($user)) {
            return $user->tokenCan('subscriber');
        }

        return false;
    }

You tagged the admin token with the "admin" ability back in step 3. The "writer" and "subscriber" abilities get attached when those users are registered, later in this step.

Next, two helpers so every response has the same shape whether it succeeded or failed:

app/Http/Library/ApiHelpers.php
    protected function onSuccess(
        $data,
        string $message = '',
        int $code = 200
    ): JsonResponse
    {
        return response()->json([
            'status' => $code,
            'message' => $message,
            'data' => $data,
        ], $code);
    }

    protected function onError(int $code, string $message = ''): JsonResponse
    {
        return response()->json([
            'status' => $code,
            'message' => $message,
        ], $code);
    }

And the validation rules for the two kinds of payload the API accepts, which closes the trait:

app/Http/Library/ApiHelpers.php
    protected function postValidationRules(): array
    {
        return [
            'title' => 'required|string',
            'content' => 'required|string',
        ];
    }

    protected function userValidatedRules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
        ];
    }
}

Now the controller. ControllerExample pulls in the trait and holds one method per endpoint:

app/Http/Controllers/ControllerExample.php
namespace App\Http\Controllers;

use App\Http\Library\ApiHelpers;
use App\Models\Post;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;

class ControllerExample extends Controller
{
    use ApiHelpers;

Each method that follows sits inside that class. They all share one shape: check the token's ability, do the work, and return through onSuccess() or onError().

post() lists every post, for an admin token only. Anything else gets a 401:

app/Http/Controllers/ControllerExample.php
    public function post(Request $request): JsonResponse
    {

        if ($this->isAdmin($request->user())) {
            $post = DB::table('posts')->get();
            return $this->onSuccess($post, 'Post Retrieved');
        }

        return $this->onError(401, 'Unauthorized Access');
    }

singlePost() returns one post. All three roles can read, so the check passes for any of them, and a missing row answers 404 rather than 401:

app/Http/Controllers/ControllerExample.php
    public function singlePost(Request $request, $id): JsonResponse
    {
        $user = $request->user();
        if ($this->isAdmin($user) || $this->isWriter($user)
            || $this->isSubscriber($user)) {
            $post = DB::table('posts')->where('id', $id)->first();
            if (!empty($post)) {
                return $this->onSuccess($post, 'Post Retrieved');
            }
            return $this->onError(404, 'Post Not Found');
        }
        return $this->onError(401, 'Unauthorized Access');
    }

createPost() is limited to admins and writers. Past the ability check it validates the payload, and a failed validation returns the validator's errors with a 400:

app/Http/Controllers/ControllerExample.php
    public function createPost(Request $request): JsonResponse
    {

        $user = $request->user();
        if ($this->isAdmin($user) || $this->isWriter($user)) {
            $validator = Validator::make(
                $request->all(),
                $this->postValidationRules()
            );
            if ($validator->passes()) {
                // Create New Post
                $post = new Post();
                $post->title = $request->input('title');
                $post->slug = Str::slug($request->input('title'));
                $post->content = $request->input('content');
                $post->save();

                return $this->onSuccess($post, 'Post Created');
            }
            return $this->onError(400, $validator->errors());
        }

        return $this->onError(401, 'Unauthorized Access');

    }

updatePost() is createPost() against an existing row. Note that it leaves the slug alone, so a post keeps the URL it was created with:

app/Http/Controllers/ControllerExample.php
    public function updatePost(Request $request, $id): JsonResponse
    {
        $user = $request->user();
        if ($this->isAdmin($user) || $this->isWriter($user)) {
            $validator = Validator::make(
                $request->all(),
                $this->postValidationRules()
            );
            if ($validator->passes()) {
                // Update the existing post
                $post = Post::find($id);
                $post->title = $request->input('title');
                $post->content = $request->input('content');
                $post->save();

                return $this->onSuccess($post, 'Post Updated');
            }
            return $this->onError(400, $validator->errors());
        }

        return $this->onError(401, 'Unauthorized Access');
    }

deletePost() removes a post, again for admins and writers:

app/Http/Controllers/ControllerExample.php
    public function deletePost(Request $request, $id): JsonResponse
    {
        $user = $request->user();
        if ($this->isAdmin($user) || $this->isWriter($user)) {
            $post = Post::find($id); // Find the id of the post passed
            $post->delete(); // Delete the specific post data
            if (!empty($post)) {
                return $this->onSuccess($post, 'Post Deleted');
            }
            return $this->onError(404, 'Post Not Found');
        }
        return $this->onError(401, 'Unauthorized Access');
    }

createWriter() is where a new ability gets minted. Only an admin may call it. The new user is stored with role 2, and the token that comes back is tagged with the "writer" ability, which is what isWriter() reads on later requests:

app/Http/Controllers/ControllerExample.php
    public function createWriter(Request $request): JsonResponse
    {
        $user = $request->user();
        if ($this->isAdmin($user)) {
            $validator = Validator::make(
                $request->all(),
                $this->userValidatedRules()
            );
            if ($validator->passes()) {
                // Create New Writer
                User::create([
                    'name' => $request->input('name'),
                    'email' => $request->input('email'),
                    'role' => 2,
                    'password' => Hash::make($request->input('password')),
                ]);

                $writerToken = $user->createToken('auth_token', ['writer'])
                    ->plainTextToken;
                return $this->onSuccess(
                    $writerToken,
                    'User Created With Writer Privilege'
                );
            }
            return $this->onError(400, $validator->errors());
        }

        return $this->onError(401, 'Unauthorized Access');

    }

createSubscriber() is the same method with role 3 and the "subscriber" ability:

app/Http/Controllers/ControllerExample.php
    public function createSubscriber(Request $request): JsonResponse
    {
        $user = $request->user();
        if ($this->isAdmin($user)) {
            $validator = Validator::make(
                $request->all(),
                $this->userValidatedRules()
            );
            if ($validator->passes()) {
                // Create New Subscriber
                User::create([
                    'name' => $request->input('name'),
                    'email' => $request->input('email'),
                    'role' => 3,
                    'password' => Hash::make($request->input('password')),
                ]);

                $writerToken = $user->createToken('auth_token', ['subscriber'])
                    ->plainTextToken;
                return $this->onSuccess(
                    $writerToken,
                    'User Created With Subscriber Privilege'
                );
            }
            return $this->onError(400, $validator->errors());
        }

        return $this->onError(401, 'Unauthorized Access');

    }

deleteUser() removes a user, admin only, and refuses to delete role 1 so the API can't strand itself without an admin. Change that check if you want a different rule. This closes the class:

app/Http/Controllers/ControllerExample.php
    public function deleteUser(Request $request, $id): JsonResponse
    {
        $user = $request->user();
        if ($this->isAdmin($user)) {
            $user = User::find($id); // Find the id of the user passed
            if ($user->role !== 1) {
                $user->delete(); // Delete the specific user
                if (!empty($user)) {
                    return $this->onSuccess('', 'User Deleted');
                }
                return $this->onError(404, 'User Not Found');
            }
        }
        return $this->onError(401, 'Unauthorized Access');
    }
}

That's the whole API. Time to exercise it.

Step 7: Testing The API In Insomnia

The screenshots use Insomnia. Any API client works the same way.

Creating a New Post

Create a new POST request (click the plus icon, or press command+N, or control+N on Windows and Linux) and name it whatever you like.

In the text field next to the word "POST", enter the endpoint. For creating a post that's /api/posts.

In the "Auth" dropdown menu, select "Bearer Token" and paste in the admin token:

Next, open the "Body" tab and select "JSON" from the dropdown menu:

Send a title and a content key as the body:

{
    "title":"This is a new post title",
    "content":"This is an new body"
}

Hit send. The response appears in the pane on the right, carrying the post you created:

The green "200 OK" is the response status, so the request succeeded. Create a few more posts with different titles and content.

Updating a Post

To update a post, change the method from POST to PUT and put the post's id in the path. To modify the first post, that's /api/posts/1.

Then make your changes in the JSON body:

{
    "title":"This is a updated post",
    "content":"This is an updated body"
}

Sending it updates the post and returns the updated record:

Get a Post

To read a post, change the method to GET and keep the id in the path, so /api/posts/1 returns the first post.

Hit send, and the post comes back if it exists:

Ask for a post that doesn't exist and the API answers 404:

Get All Posts

To list every post, send a GET to the /posts endpoint:

Create a New Writer User

Send a POST to the /users/writer endpoint with the new user in the JSON body:

{
    "name": "User One",
    "email":"user1@me.com",
    "password":"password1",
    "password_confirmation":"password1"
}

Sending it returns the new user's token, tagged with the writer ability:

A writer can create, read, update and delete posts, and nothing else.

Create a New Subscriber User

Send a POST to the /users/subscriber endpoint with the new user in the JSON body:

{
    "name": "User Subscriber",
    "email":"usersubscriber@me.com",
    "password":"password2",
    "password_confirmation":"password2"
}

Sending it returns the new user's token, this time tagged with the subscriber ability:

A subscriber can only read posts.

Conclusion

You now have a Laravel API where three roles reach different endpoints, enforced by abilities carried on the token itself rather than by a table of permissions. The same pattern extends to whatever roles your application needs: mint the token with an ability, check that ability where it matters.

Resources