Skip to main content

Authentication — token authentication with Sanctum

The API built so far can be called by anyone.

Of the four problems raised in chapter 1, this chapter handles "anyone can call it." Creating an order needs no authentication, so writing someone else's email address lets you create an order in their name. The remaining two (double-allocated stock and synchronous mail) are handled from chapter 14 onward.

This chapter adds user accounts, and the next decides who may do what.

Authentication and authorization are different things​

Let us separate the terms first. Conflate them and the next chapter's design goes blurry.

QuestionCode on failure
AuthenticationWho are you?401 Unauthorized
AuthorizationAre you allowed to do this?403 Forbidden

Someone not logged in calling the order listing gets a 401. Someone logged in calling another user's order gets a 403. The first is "we do not know who you are," the second "we know who you are, but you lack permission."

This chapter covers authentication only. Authorization is next.

Why Sanctum​

Laravel provides two packages for API authentication. The official criterion is clear.

Use Passport if you need full OAuth2 support. Use Sanctum if the goal is an SPA, a mobile app, or issuing simple API tokens. Sanctum does not support OAuth2, but it is far simpler1.

What this series builds is an API for our own e-commerce site. There is no need to delegate authorization to third-party applications, so we choose Sanctum.

Sanctum was already installed by php artisan install:api in chapter 4. No extra work is needed.

If you want a comparison with JWT

Authentication and authorization design in A Practical Guide to Laravel × DDD × Clean Architecture devotes a chapter to the structure of JWT, refresh tokens, and how it compares with Sanctum. Go there when you want to dig into the choice of scheme.

Setting up user accounts​

The users table was already created when you ran php artisan migrate in chapter 3, because it is part of Laravel's standard migrations.

Add the HasApiTokens trait to the User model2.

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

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;

protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

Notice that #[Hidden(['password', ...])] is there from the start. We return things through a Resource because chapter 7 decided not to return models directly, so this is a second layer of defense.

'password' => 'hashed' in casts() matters too. It is hashed automatically on assignment, preventing the accident of storing it in plain text.

Sign-up, login, and logout​

We create three endpoints.

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

Route::post('/auth/register', [AuthController::class, 'register']);
Route::post('/auth/login', [AuthController::class, 'login']);
Route::post('/auth/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');

Only logout carries auth:sanctum, because it has to know whose token to delete.

Sign-up​

app/Http/Controllers/AuthController.php
public function register(RegisterRequest $request)
{
$user = User::create($request->validated());

return response()->json([
'token' => $user->createToken('api', ['order:create', 'order:read'])->plainTextToken,
], 201);
}
app/Http/Requests/RegisterRequest.php
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
];
}

The confirmed rule checks that it matches the password_confirmation field.

The plainTextToken on the object createToken() returns is the token you hand to the client2. The second argument is the list of operations this token is allowed, called an ability. The second half of this chapter covers it.

Login​

app/Http/Controllers/AuthController.php
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

public function login(LoginRequest $request)
{
$user = User::where('email', $request->validated('email'))->first();

if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
throw ValidationException::withMessages([
'email' => 'The email address or password is incorrect.',
]);
}

return response()->json([
'token' => $user->createToken('api', ['order:create', 'order:read'])->plainTextToken,
]);
}

It does not distinguish whether the email address or the password was wrong. Return "this email address is not registered" and anyone can probe from outside which addresses have accounts. That gets used as the first step of a brute-force attack, so return the same message without distinguishing.

Logout​

public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();

return response()->noContent();
}

currentAccessToken() refers to the token used for the current request2. Deleting it logs out that device only. Tokens on other devices survive.

To log out of every device, write this.

$request->user()->tokens()->delete();

Calling this is the standard move when a password changes.

Protecting routes​

Add auth:sanctum to routes that require a token2.

routes/api.php
Route::apiResource('products', ProductController::class)->only(['index', 'show']);

Route::post('/auth/register', [AuthController::class, 'register']);
Route::post('/auth/login', [AuthController::class, 'login']);

Route::middleware('auth:sanctum')->group(function () {
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::post('/orders', [OrderController::class, 'store']);
});

Viewing products stays public, while creating an order now requires authentication.

The client puts the token in a request header.

Authorization: Bearer 1|abcdefghijklmnopqrstuvwxyz0123456789

Missing or invalid tokens get a 401. Thanks to shouldRenderJsonWhen from chapter 9, JSON comes back rather than an HTML login page.

Moving guest orders onto user accounts​

This is the part of the chapter with the most hands-on work.

From chapters 5 to 10, the customer on an order was represented by customer_email. Now that user accounts exist, we move to tying orders to the authenticated user.

It is a migration you see in practice too. It has the same structure as adding accounts to a service that started guest-purchase only, so the procedure is worth following.

(a) Add the user_id column​

php artisan make:migration add_user_id_to_orders_table
database/migrations/xxxx_add_user_id_to_orders_table.php
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->after('id')->constrained();
});
}

public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropConstrainedForeignId('user_id');
});
}

It is nullable() because existing orders have no user_id. Orders created before accounts existed carry only customer_email. Make it NOT NULL and the migration fails on the existing rows.

Chapter 3 said to design migrations to move forward only. Starting a column addition as nullable is the concrete case of that.

We keep customer_email. Even when a user's email address changes, the address used at order time stays on the record.

(b) Drop customer_email from the request​

app/Http/Requests/StoreOrderRequest.php
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1', 'max:20'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:99'],
];
}

The customer_email rule is gone, because we no longer accept it from the client.

This is the same judgment as total_amount in chapter 5's exercise. A value the server knows is not accepted from the client. The server knows who the authenticated user is, so there is no need to make them state it in the request.

Leave it in and you keep a hole where someone can specify another person's email address.

The prepareForValidation from chapter 5 is no longer needed either, so delete it.

(c) Change the service class's arguments​

app/Services/OrderPlacementService.php
public function place(User $user, array $items): Order
{
$this->stockChecker->assertEnough($items);

// ... amount calculation is unchanged ...

$order = Order::create([
'user_id' => $user->id,
'customer_email' => $user->email,
'status' => OrderStatus::Pending,
'total_amount' => $total,
]);

// ... the rest is unchanged ...
}

The first argument changed from string $customerEmail to User $user. customer_email comes from the user.

Add user_id to Order's #[Fillable] as well.

app/Models/Order.php
#[Fillable(['user_id', 'customer_email', 'status', 'total_amount'])]
We left order_id out of OrderItem, so why put this one in?

Chapter 3 left order_id out of OrderItem's #[Fillable]. These are both ownership keys, so the decision looks inverted.

The difference is where the value comes from.

OrderItem's order_id is supplied by the relationship, in the form $order->items()->create([...]). It does not need to be in #[Fillable], and putting it there leaves room for an array from a request to hang lines off another order.

Order's user_id reaches Order::create() inside an array the service class assembled from $user->id. If it is not in #[Fillable] it is silently discarded (the default behavior mentioned in chapter 3). You end up with an order whose user_id is null, and nobody knows whose it is.

The criterion is not "is it an ownership key?" but who supplies the value. Via a relationship, it is unnecessary; passed in an array, it is required.

If the array form bothers you, you can go through the relationship with $user->orders()->create([...]). In that case user_id can come back out of #[Fillable].

Update the controller to match.

app/Http/Controllers/OrderController.php
public function store(StoreOrderRequest $request, OrderPlacementService $service)
{
$order = $service->place($request->user(), $request->validated('items'));

return (new OrderResource($order))
->response()
->setStatusCode(201);
}

$request->user() returns the authenticated user. Since the request came through auth:sanctum, it is never null here.

(d) Fix the tests​

The tests written in chapter 10 sent customer_email. Now that authentication is required, they all fail with 401.

That is a good sign. The tests are detecting a change in behavior. Chapter 10 said not to delete a test to go green. What we do here is rewrite them to the new expectation.

There are two kinds of rewrite: the ones that just need authentication added, and the ones whose subject no longer exists.

Chapter 10's "a missing email address returns 422" is the second kind. With the customer_email rule gone, the behavior that test guarded no longer exists. Rather than deleting it, move it to a subject that serves the same purpose (a missing required field gives 422).

tests/Feature/OrderCreationTest.php
test('omitting the products returns 422', function () {
Sanctum::actingAs(User::factory()->create());

$this->postJson('/api/v1/orders', [])
->assertStatus(422)
->assertJsonValidationErrors(['items']);
});

Stop at "the rule is gone so the test goes too" and nobody checks that validation works at all any more. What we wanted to guard was not a particular field but the fact that input validation is functioning.

(e) Add user_id to the factory​

The OrderFactory from chapter 3 filled in customer_email, status, and total_amount. Now that user_id exists, fix it too.

database/factories/OrderFactory.php
use App\Models\User;

public function definition(): array
{
return [
'user_id' => User::factory(),
'customer_email' => fake()->unique()->safeEmail(),
'status' => OrderStatus::Pending,
'total_amount' => fake()->numberBetween(1000, 100000),
];
}

Writing 'user_id' => User::factory() creates a user alongside the order. It is the same form as Product::factory() in chapter 3's StockFactory.

In tests that want an order for a specific user, override it.

Order::factory()->create(['user_id' => $user->id]);

The next chapter's tests use this form heavily. Forget to update the factory and every order created in a test belongs to a different user, so listing tests do not behave as expected.

tests/Feature/OrderCreationTest.php
use App\Models\User;
use Laravel\Sanctum\Sanctum;

test('an order can be created', function () {
Sanctum::actingAs(User::factory()->create());

$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);

$response = $this->postJson('/api/v1/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 3],
],
]);

$response
->assertStatus(201)
->assertJsonPath('data.total_amount', 3600);
});

Sanctum::actingAs() creates the state of being authenticated as that user2. There is no need to actually call the login API.

And add a test confirming that authentication is required.

test('an unauthenticated request cannot create an order', function () {
$product = Product::factory()->create();
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);

$response = $this->postJson('/api/v1/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
]);

$response->assertStatus(401);
$this->assertDatabaseCount('orders', 0);
});

Pin the protection down with a test. Remove the middleware by mistake and this test fails.

Narrowing a token's abilities​

One user can hold several tokens. Issue them per purpose and narrow what each can do2.

$user->createToken('mobile-app', ['order:create', 'order:read']);
$user->createToken('readonly-integration', ['order:read']);

The second argument is the list of abilities. You check them on the route. Here is an example of the syntax.

Route::post('/orders', [OrderController::class, 'store'])
->middleware(['auth:sanctum', 'abilities:order:create']);

Call this endpoint with the readonly-integration token and you get a 403.

This is a different layer from the authorization in chapter 12. An ability decides what this token can do; a Policy decides what this user can do to this data. Access requires passing both.

A token with no abilities specified has all of them

Omit the second argument, as in createToken('api'), and the abilities become ['*'] — meaning it has every ability. Write middleware like the above and that token always gets through.

That is why sign-up and login state ['order:create', 'order:read'] explicitly. Unless the issuing side is narrowed, checking on the route side means nothing.

The route example above is there to show the syntax; we do not actually add it to the order route. The only place this guide applies an ability check is the single product image upload in chapter 19. That chapter explains why.

Expiration and revocation​

Sanctum tokens have no expiry by default. Once issued, a token is valid until it is explicitly deleted.

Set an expiry.

config/sanctum.php
'expiration' => 525600, // in minutes; 525600 minutes = 1 year

You can also specify it per token2.

$user->createToken('temporary', ['order:read'], now()->plus(weeks: 1))->plainTextToken;

An expired token cannot be used to authenticate, but the record stays in the table. Left alone, personal_access_tokens keeps growing, so clear it periodically.

php artisan sanctum:prune-expired --hours=24

Configuring this to run daily is covered by the task scheduler in chapter 20.

What matters in production​

The plain-text token is visible only at issue time​

The plainTextToken returned by createToken() can only be obtained at that moment. A hashed value is what goes into the database.

When a user says "I lost my token," you cannot tell them what it was. Reissuing is the only option. The design exists so that a database leak does not hand over usable tokens.

The client side has to store the token it receives somewhere safe.

Do not log tokens​

❌ Bad
Log::info('Login succeeded', ['token' => $token->plainTextToken]);

A token left in the logs means everyone who can read the logs can impersonate that user. Logs are sometimes shared beyond developers, and are sometimes sent to an external monitoring service.

Chapter 21 covers masking sensitive data when it gets to log design.

Rate-limit login attempts​

The login API as it stands can be tried any number of times. It is defenseless against password brute forcing.

Chapter 13 adds rate limiting. For now, be aware that the hole is open.

Do not use API tokens for your own SPA​

Sanctum also has a cookie-based authentication scheme for SPAs. For your own frontend running on the same domain, that is the better fit.

Handling an API token from JavaScript makes the choice of storage awkward. Put it in localStorage and XSS steals it; put it in a cookie and you end up reinventing a cookie-based scheme anyway.

This series assumes the frontend is built separately, and takes the token approach with mobile apps and external integrations in mind. If your only consumer is your own SPA, check the SPA authentication section of the official documentation.

Summary​

  • Authentication is "who are you" (401); authorization is "are you allowed to do this" (403). This chapter is authentication only
  • If you do not need OAuth2, use Sanctum. That is the official criterion
  • Do not distinguish email address from password in a login failure message; it lets people probe for accounts
  • Do not accept from the client a value the server already knows. Dropping customer_email from the request is that judgment
  • Start a column addition as nullable. With existing rows, the migration fails otherwise
  • After adding authentication, write a test that an unauthenticated request gets 401. You notice if the protection comes off
  • Tokens have no expiry by default. Set expiration and clean up with sanctum:prune-expired

The next chapter, Authorization — deciding who can do what with Gates and Policies, covers authorization. Users can log in now, but anyone logged in can see anyone's orders. While building a listing API that returns only your own orders, we cover choosing between Gates and Policies, and the trap that a Policy does not take effect on a listing.

Exercises​

The following login API lets outsiders probe for account information. Point out the problem
public function login(LoginRequest $request)
{
$user = User::where('email', $request->validated('email'))->first();

if (! $user) {
throw ValidationException::withMessages([
'email' => 'This email address is not registered.',
]);
}

if (! Hash::check($request->validated('password'), $user->password)) {
throw ValidationException::withMessages([
'password' => 'The password is incorrect.',
]);
}

return response()->json(['token' => $user->createToken('api')->plainTextToken]);
}

Sample answer

The error messages split into two kinds, which lets anyone determine whether an account exists.

Send any email address with an arbitrary password and the message branches.

  • "This email address is not registered" → no account
  • "The password is incorrect" → an account exists

With a list of email addresses in hand, someone can work through it to find which are accounts on this service. Knowing an address is an account, they can focus password brute forcing on it. It also narrows the target for credential stuffing, where passwords leaked from other services are tried.

The remedy is to return the same message for both failures.

if (! $user || ! Hash::check($request->validated('password'), $user->password)) {
throw ValidationException::withMessages([
'email' => 'The email address or password is incorrect.',
]);
}

It looks unhelpful to the user, but the judgment is that protecting the account list is worth more than telling people exactly why login failed.

The same care applies to sign-up. Returning 422 from unique:users,email reveals that an address is already registered. Done strictly, sign-up always returns 200 and sends an "you are already registered" email to an existing address. Adopting it changes what the user sees. The sign-up form always reports success, and whether the account was actually created is only knowable from the email. It comes down to which you weigh more: protecting the member list, or giving immediate feedback that sign-up worked. What this series implements is the login-side measure only.

What about keeping customer_email in the request and validating that it matches the authenticated user's email address?

Sample answer

It works, but it creates a needless failure path.

// The rule for that approach
'customer_email' => ['required', 'email', function ($attribute, $value, $fail) {
if ($value !== auth()->user()->email) {
$fail('The email address does not match.');
}
}],

It certainly prevents orders in someone else's name. But think about the flow: the client sends its own email address, the server checks whether it is right, and if it is, uses it. A value the server already knows is being sent on a round trip.

The approach has concrete drawbacks.

It fails right after a user changes their email address. A client sending a stale cached address fails validation. To the user it is just "I tried to order and got an error," with no clue why.

It adds client-side implementation. The client has to hold the email address and update it on change. If it never sends it, none of that is needed.

Forget the validation and a hole opens. One deleted line and orders in someone else's name go through. Never accept it and there is nothing to forget.

A design that does not accept the value does not depend on code being correct for its defense. That is why "do not accept from the client a value the server knows" is treated as a principle. It is the same stance as total_amount in chapter 5 and "enumerate what you publish" in chapter 7.


Footnotes​

  1. Source: Laravel Passport (Laravel documentation 13.x), the "Passport or Sanctum?" section. On the criterion of Passport when you need full OAuth2 support, and Sanctum for SPAs, mobile apps, or simple API tokens. ↩

  2. Source: Laravel Sanctum (Laravel documentation 13.x). On the HasApiTokens trait, createToken and plainTextToken, specifying abilities, and the auth:sanctum middleware. Revocation (currentAccessToken() and tokens()->delete()), the expiration setting, and Sanctum::actingAs in tests are covered on the same page. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7