How to Create API Authentication with Laravel

How to Create API Authentication with Laravel

Building an API is one thing. Making it secure is another. The moment your Laravel application starts exposing endpoints to mobile apps, JavaScript frontends, third-party integrations, or internal services, authentication becomes one of the most important parts of the entire project.

A public API without authentication is like leaving the front door open at night. It may work for a little while, but it will not stay safe for long.

Laravel makes API authentication much easier than many developers expect. With tools like Sanctum and Passport, you can build secure, clean, and flexible authentication systems for APIs of all sizes. In this article, we will go step by step through how to create API authentication in Laravel, how it works, when to use each method, and how to build it in a way that feels natural in real projects.

This is not just a dry technical guide. The goal here is to help you actually understand what you are building, why you are building it, and how to avoid the common mistakes that waste time later.


What API authentication really means

Before writing any code, it helps to slow down and understand the idea.

API authentication is the process of verifying who is making the request. In a web application, users usually log in through a browser session. In an API, the client might be a mobile app, a frontend built with Vue or React, another backend service, or a third-party developer using your endpoints.

The API must know whether the request is coming from someone trusted. If it is, the request can continue. If it is not, the request should be rejected.

Authentication is different from authorization.

Authentication answers the question: “Who are you?”
Authorization answers the question: “What are you allowed to do?”

A user may be authenticated but still not allowed to delete another user’s data. That distinction matters a lot in real projects.


Which Laravel authentication method should you use?

Laravel gives you multiple options for API authentication, and that can confuse beginners. The best choice depends on your project.

If you are building a first-party SPA, mobile app, or simple API for your own application, Laravel Sanctum is usually the best choice. It is lightweight and easy to set up.

If you are building a large OAuth2-based API where users need access tokens, scopes, or third-party application access, Laravel Passport may be the better option. It is more powerful, but also more complex.

For most modern Laravel API projects, Sanctum is the practical default.

In this article, we will focus mainly on API authentication with Laravel Sanctum, because that is the cleanest starting point for most real-world cases. After that, we will also look at Passport and explain when it makes sense.


What we are going to build

We will build a simple API authentication system in Laravel that includes:

A registration endpoint
A login endpoint
A logout endpoint
A protected route that requires authentication
Token-based access using Laravel Sanctum
A clear structure you can expand later

By the end, you will have a working API authentication setup that you can use as a base for more advanced features like roles, permissions, password reset, email verification, and user profiles.


Step 1: Create a new Laravel project

If you do not already have a project, create one first.

composer create-project laravel/laravel laravel-api-auth

Then move into the project directory:

cd laravel-api-auth

Now set up your environment file, database connection, and application key:

cp .env.example .env
php artisan key:generate

Open the .env file and configure your database credentials.

Example:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_api_auth
DB_USERNAME=root
DB_PASSWORD=

Then run the migrations:

php artisan migrate

At this point, your Laravel project is ready.


Step 2: Install Laravel Sanctum

Laravel Sanctum is the easiest way to add API token authentication.

Install it with Composer:

composer require laravel/sanctum

Next, publish Sanctum’s configuration and migrations:

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

Run migrations again so Sanctum tables are created:

php artisan migrate

Sanctum will create a table for API tokens, which is where issued tokens are stored.


Step 3: Add the HasApiTokens trait to the User model

Open the app/Models/User.php file and make sure the HasApiTokens trait is included.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

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

This trait allows the user model to generate API tokens.

Without it, the authentication flow will not work properly.


Step 4: Create an authentication controller

Now we need a controller to handle registration, login, logout, and protected user info.

Create the controller:

php artisan make:controller Api/AuthController

Then open the controller and add the following code.

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:8|confirmed',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'message' => 'Validation error',
                'errors' => $validator->errors()
            ], 422);
        }

        $user = User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => Hash::make($request->password),
        ]);

        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'message' => 'User registered successfully',
            'user' => $user,
            'token' => $token,
            'token_type' => 'Bearer'
        ], 201);
    }

    public function login(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'email' => 'required|string|email',
            'password' => 'required|string',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'message' => 'Validation error',
                'errors' => $validator->errors()
            ], 422);
        }

        $user = User::where('email', $request->email)->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            return response()->json([
                'message' => 'Invalid credentials'
            ], 401);
        }

        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'message' => 'Login successful',
            'user' => $user,
            'token' => $token,
            'token_type' => 'Bearer'
        ]);
    }

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

        return response()->json([
            'message' => 'Logged out successfully'
        ]);
    }

    public function me(Request $request)
    {
        return response()->json([
            'user' => $request->user()
        ]);
    }
}

This controller does four important things:

It validates user input
It creates a new user during registration
It generates a personal access token
It protects sensitive endpoints using authentication


Step 5: Make sure the User model allows mass assignment

The User model should allow the name, email, and password fields to be filled.

Open app/Models/User.php and confirm the $fillable property exists.

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

Without this, Laravel may block mass assignment when you try to create users.


Step 6: Define the API routes

Now open routes/api.php and add the following routes.

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AuthController;

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

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

This is the heart of the authentication system.

The registration and login routes are public.

The logout and me routes are protected by the auth:sanctum middleware.

That middleware checks whether the request includes a valid token.


Step 7: Test the register endpoint

Use a tool like Postman, Insomnia, or even curl to test the API.

Send a POST request to:

/api/register

With JSON body:

{
  "name": "Hassan",
  "email": "hassan@example.com",
  "password": "password123",
  "password_confirmation": "password123"
}

If everything works, you should get a response like:

{
  "message": "User registered successfully",
  "user": {
    "id": 1,
    "name": "Hassan",
    "email": "hassan@example.com",
    "updated_at": "2026-04-28T18:00:00.000000Z",
    "created_at": "2026-04-28T18:00:00.000000Z"
  },
  "token": "1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "token_type": "Bearer"
}

That token is what the client will use to access protected routes.


Step 8: Test the login endpoint

Send a POST request to:

/api/login

Body:

{
  "email": "hassan@example.com",
  "password": "password123"
}

If the credentials are correct, the API returns a new token.

That means the user can now authenticate future requests by sending this token in the Authorization header.

Example:

Authorization: Bearer 1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxx

This is a common pattern in token-based API authentication.


Step 9: Test the protected route

Now try to access:

GET /api/me

Without a token, you should get a 401 Unauthorized response.

With a valid token, you should get the authenticated user data.

That is the whole point of the system. Only verified users can access protected endpoints.


Step 10: Logout by deleting the current token

The logout method in the controller deletes the current token:

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

This is a simple and effective way to log out an API user. Once the token is deleted, it can no longer be used.

After logout, any request made with that token should fail.

This is important because API authentication should not only allow access. It should also give you a way to end access safely.


A slightly improved version of the AuthController

The basic controller works, but in real projects you will often want a cleaner structure.

Here is a more polished version with better response handling.

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:8|confirmed',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'status' => false,
                'message' => 'Validation failed',
                'errors' => $validator->errors()
            ], 422);
        }

        $user = User::create([
            'name' => trim($request->name),
            'email' => strtolower(trim($request->email)),
            'password' => Hash::make($request->password),
        ]);

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json([
            'status' => true,
            'message' => 'Registration successful',
            'data' => [
                'user' => $user,
                'token' => $token,
                'token_type' => 'Bearer'
            ]
        ], 201);
    }

    public function login(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'email' => 'required|string|email',
            'password' => 'required|string',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'status' => false,
                'message' => 'Validation failed',
                'errors' => $validator->errors()
            ], 422);
        }

        $user = User::where('email', strtolower(trim($request->email)))->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            return response()->json([
                'status' => false,
                'message' => 'Email or password is incorrect'
            ], 401);
        }

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json([
            'status' => true,
            'message' => 'Login successful',
            'data' => [
                'user' => $user,
                'token' => $token,
                'token_type' => 'Bearer'
            ]
        ]);
    }

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

        return response()->json([
            'status' => true,
            'message' => 'Logout successful'
        ]);
    }

    public function me(Request $request)
    {
        return response()->json([
            'status' => true,
            'data' => [
                'user' => $request->user()
            ]
        ]);
    }
}

This version is a little more organized for frontend developers because it returns data in a predictable format.

That kind of consistency makes a big difference once your frontend gets more complex.


Why Sanctum is a great choice for many projects

A lot of developers jump straight into Passport because it sounds more advanced. But advanced is not always better.

Sanctum is often the right answer because it is simple, fast to set up, and easy to maintain. It gives you token authentication without forcing you into the full complexity of OAuth2.

Use Sanctum when you need:

A token-based API for your own frontend
Authentication for mobile apps
Simple secure access for first-party clients
A practical solution without unnecessary overhead

That is why it is so popular.

In real projects, the best tool is not the fanciest one. It is the one that gets the job done cleanly and does not make your life miserable six months later.


When to use Laravel Passport instead

Laravel Passport is still excellent, but it is best used in different situations.

Use Passport when you need:

OAuth2 authorization
Third-party applications accessing your API
Personal access tokens with advanced permissions
Client credentials flows
More formal token-based authorization systems

Passport is more powerful than Sanctum in some scenarios, but it is also heavier. If you only need basic API authentication, Passport is usually too much.

That said, if your project is expected to grow into a full public API ecosystem, Passport may be worth it.


Important security practices for API authentication

Authentication is not just about making login work. It is also about protecting the entire system.

Here are some important practices you should follow.

First, always hash passwords. Never store plain text passwords. Laravel’s Hash::make() handles this properly.

Second, validate all input before using it. Never trust the request body.

Third, use HTTPS in production. Tokens sent over plain HTTP can be intercepted.

Fourth, do not expose sensitive user data in responses unless absolutely necessary.

Fifth, revoke tokens when users log out or when a device is no longer trusted.

Sixth, consider rate limiting login endpoints so attackers cannot brute-force passwords too easily.

Seventh, keep your token names meaningful. For example, mobile-app, admin-panel, or ios-device is much more useful than a generic name if you later want to manage multiple devices.


How to support multiple devices

One very practical feature in real applications is allowing a user to log in from more than one device.

Laravel Sanctum supports this naturally. Each time a token is created, it can represent a different device or client.

Example:

$token = $user->createToken('android-phone')->plainTextToken;

Another token:

$token = $user->createToken('desktop-browser')->plainTextToken;

This is useful because later you can allow users to manage sessions per device. They can log out from a single phone without affecting their laptop session.

That is the kind of detail people appreciate when your app starts feeling professional.


How to return cleaner API responses

Frontend developers usually prefer consistent responses. It saves time and reduces confusion.

For example, instead of returning raw user data directly everywhere, you can structure responses like this:

return response()->json([
    'status' => true,
    'message' => 'Login successful',
    'data' => [
        'user' => $user,
        'token' => $token,
    ]
]);

This gives the frontend a predictable shape to work with.

You can take this further by using API resources.

Example:

php artisan make:resource UserResource

Then define what the user response should look like.

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UserResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'created_at' => $this->created_at,
        ];
    }
}

Then in your controller:

use App\Http\Resources\UserResource;

return response()->json([
    'status' => true,
    'message' => 'Login successful',
    'data' => [
        'user' => new UserResource($user),
        'token' => $token,
    ]
]);

This is a small change, but it makes your API feel much more polished.


Adding password confirmation and email normalization

These small things matter more than people think.

During registration, requiring password_confirmation is useful because it reduces mistakes. Users often type passwords incorrectly, especially on mobile.

Normalizing email is also smart. You can trim spaces and convert the email to lowercase before saving or searching.

Example:

$email = strtolower(trim($request->email));

That helps avoid bugs like treating User@Example.com and user@example.com as two different values in your logic.


Creating a middleware-protected route group

As your API grows, you will likely have many protected routes, not just /me and /logout.

You can group them like this:

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout']);
    Route::get('/me', [AuthController::class, 'me']);

    Route::get('/profile', [ProfileController::class, 'show']);
    Route::post('/profile', [ProfileController::class, 'update']);
    Route::get('/orders', [OrderController::class, 'index']);
});

This keeps your routes cleaner and easier to maintain.

It also makes the security model easier to understand. Everything inside the group is protected. Everything outside is public.

That clarity is valuable.


How to protect admin-only routes

API authentication is only the first layer. Often you also need authorization.

For example, not every authenticated user should access admin endpoints.

You can check roles manually:

if ($request->user()->role !== 'admin') {
    return response()->json([
        'message' => 'Unauthorized access'
    ], 403);
}

Or you can use policies, gates, or a role package depending on your app’s structure.

The point is simple: authentication says the user is logged in, but it does not mean they should be able to do everything.

That mistake causes many security problems in early-stage projects.


Using Laravel Sanctum with SPAs

Sanctum is not only for token-based API authentication. It also works very well with single-page applications.

If your frontend is on a separate domain or running with Vue, React, or Inertia, Sanctum can help you authenticate via cookies and CSRF protection.

This is slightly different from pure Bearer token authentication, but it is one of the most useful features of Sanctum.

For SPAs, make sure you configure:

The frontend domain
CORS settings
Stateful domains
CSRF cookie handling
Session authentication

This setup can be a little tricky at first, but once it is configured properly, it gives you a secure and smooth experience.


Common mistakes developers make

There are a few mistakes that appear again and again when people build API authentication in Laravel.

One common mistake is forgetting to add HasApiTokens to the User model.

Another is trying to use auth()->user() in a place where the request is not actually authenticated.

Another is returning the password field in API responses accidentally.

Another is forgetting to include the Authorization: Bearer <token> header when testing.

Another is using weak validation rules on registration.

Another is mixing session auth and token auth without understanding the difference.

Another is not deleting old tokens when they are no longer needed.

These mistakes are usually easy to fix, but they can be frustrating if you do not spot them quickly.


Example of a full working flow

Let’s walk through the entire flow in plain language.

A user opens your app and clicks register.
The frontend sends the name, email, password, and password confirmation to /api/register.
Laravel validates the input.
Laravel creates the user in the database.
Laravel creates a token for the user.
The token is returned to the client.
The client stores the token securely.
Later, the client sends requests with the token in the authorization header.
Laravel checks the token and allows the request if it is valid.
When the user logs out, the token is deleted.
After that, the token no longer works.

That is the entire lifecycle of a simple API authentication system.


A more advanced login flow with token abilities

Sanctum also supports token abilities, which are useful if you want to limit what a token can do.

Example:

$token = $user->createToken('mobile-token', ['read', 'write'])->plainTextToken;

Then, in a protected route or controller, you can check abilities.

Example:

if (! $request->user()->tokenCan('write')) {
    return response()->json([
        'message' => 'Forbidden'
    ], 403);
}

This is helpful when you want fine-grained control over tokens.

For many projects, you may not need token abilities on day one, but it is good to know they exist.


Rate limiting authentication endpoints

Login and registration endpoints should be protected from abuse.

Laravel supports rate limiting, which helps prevent brute-force attacks and spam registrations.

You can add middleware to throttle requests.

Example:

Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:10,1');

This limits the route to 10 requests per minute per IP.

You can also define custom rate limiters if your project needs something more specific.

This is a small defense, but it makes a real difference.


Testing your API authentication with Postman

Postman is a friendly way to test your API while developing.

Here is a practical workflow:

First, send a POST request to /api/register.
Copy the returned token.
Then send a GET request to /api/me.
In the headers, add:

Authorization: Bearer YOUR_TOKEN_HERE
Accept: application/json

If the token is valid, the endpoint should return user information.

Then test /api/logout.
After logout, try /api/me again with the same token.
It should fail.

That simple test confirms that your authentication flow is actually working, not just looking good in the code.


Example frontend usage with fetch

Here is a simple example using browser fetch.

async function loginUser() {
    const response = await fetch('/api/login', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        },
        body: JSON.stringify({
            email: 'hassan@example.com',
            password: 'password123'
        })
    });

    const data = await response.json();

    if (response.ok) {
        localStorage.setItem('token', data.data.token);
        console.log('Logged in successfully');
    } else {
        console.log(data.message);
    }
}

Then for an authenticated request:

async function getUser() {
    const token = localStorage.getItem('token');

    const response = await fetch('/api/me', {
        headers: {
            'Authorization': `Bearer ${token}`,
            'Accept': 'application/json'
        }
    });

    const data = await response.json();
    console.log(data);
}

In real applications, you should think carefully about where you store tokens. localStorage is easy, but it is not always the most secure choice depending on your frontend architecture.


Example using Axios

Many frontends use Axios instead of fetch.

import axios from 'axios';

axios.defaults.headers.common['Accept'] = 'application/json';

async function loginUser() {
    const response = await axios.post('/api/login', {
        email: 'hassan@example.com',
        password: 'password123'
    });

    localStorage.setItem('token', response.data.data.token);
}

Then set the token for future requests:

axios.defaults.headers.common['Authorization'] = `Bearer ${localStorage.getItem('token')}`;

This makes repeated requests simpler.


How to handle token expiration

Sanctum personal access tokens do not expire automatically unless you configure expiration or manage them manually.

In some applications, that is fine. In others, you may want stricter control.

You can configure token expiration if needed and also build cleanup tasks to remove old tokens.

For higher security projects, you may prefer shorter-lived tokens or a more advanced authentication model.

It really depends on the nature of the application.

A banking app and a simple blog dashboard should not have the exact same authentication strategy.


Adding email verification later

A common next step after authentication is email verification.

This helps ensure that users actually own the email address they used.

Laravel supports email verification out of the box, and it can be combined with API authentication.

That means you can require verified users before allowing access to certain features.

Example idea:

Route::middleware(['auth:sanctum', 'verified'])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
});

This adds another layer of trust to the application.


Adding password reset for API users

Users forget passwords. That is normal. Human beings are not token machines.

So a real authentication system often needs password reset functionality too.

Laravel provides the building blocks for this, but API-friendly password reset flows usually require a little more planning, especially if the frontend is separate.

The general idea is:

The user requests a reset link or reset token.
The system generates a secure reset request.
The user receives an email.
The user submits a new password.
The system updates the password securely.

This is not the first thing you need to implement, but it is often the next logical step once your login system is stable.


Organizing your code better as the project grows

At first, one controller may feel enough. Later, it becomes messy.

A cleaner structure might include:

AuthController for login and registration
ProfileController for user profile actions
TokenController for device management
PasswordResetController for forgot-password flow
VerificationController for email verification

That separation makes the code easier to read and easier to maintain.

Good structure saves time later. Bad structure steals it.


Example of a token management endpoint

You may want to let users see their active tokens and revoke them.

Example:

public function tokens(Request $request)
{
    return response()->json([
        'status' => true,
        'data' => $request->user()->tokens
    ]);
}

To delete one:

public function destroyToken(Request $request, $tokenId)
{
    $token = $request->user()->tokens()->where('id', $tokenId)->first();

    if (! $token) {
        return response()->json([
            'message' => 'Token not found'
        ], 404);
    }

    $token->delete();

    return response()->json([
        'message' => 'Token deleted successfully'
    ]);
}

This is great for apps where users manage their own sessions across multiple devices.


Real-world advice from experience

When people first build API auth in Laravel, they often focus too much on making the first request work. That is understandable, but it is only the beginning.

The more important questions are:

Can I safely log users out?
Can I revoke access if something goes wrong?
Can I support different devices?
Can I protect sensitive routes?
Can I expand the system later without rewriting everything?

A good authentication design answers those questions early.

You do not need to build everything at once. But you should choose a foundation that will not collapse when the application gets real users.

That is usually the difference between a demo and a production system.


Full example of routes and controller in one place

Here is a compact version you can reuse.

routes/api.php

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AuthController;

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

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

app/Http/Controllers/Api/AuthController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'name' => 'required|string|max:255',
            'email' => 'required|string|email|max:255|unique:users',
            'password' => 'required|string|min:8|confirmed',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'message' => 'Validation error',
                'errors' => $validator->errors()
            ], 422);
        }

        $user = User::create([
            'name' => $request->name,
            'email' => strtolower(trim($request->email)),
            'password' => Hash::make($request->password),
        ]);

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json([
            'message' => 'User registered successfully',
            'user' => $user,
            'token' => $token,
            'token_type' => 'Bearer'
        ], 201);
    }

    public function login(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'email' => 'required|string|email',
            'password' => 'required|string',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'message' => 'Validation error',
                'errors' => $validator->errors()
            ], 422);
        }

        $user = User::where('email', strtolower(trim($request->email)))->first();

        if (! $user || ! Hash::check($request->password, $user->password)) {
            return response()->json([
                'message' => 'Invalid credentials'
            ], 401);
        }

        $token = $user->createToken('api-token')->plainTextToken;

        return response()->json([
            'message' => 'Login successful',
            'user' => $user,
            'token' => $token,
            'token_type' => 'Bearer'
        ]);
    }

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

        return response()->json([
            'message' => 'Logged out successfully'
        ]);
    }

    public function me(Request $request)
    {
        return response()->json([
            'user' => $request->user()
        ]);
    }
}

Final thoughts

API authentication is one of those topics that looks simple at first and then becomes deeply important the moment your app starts growing.

Laravel makes the process much easier by giving you strong tools out of the box. For most modern applications, Laravel Sanctum is the best place to start. It is lightweight, practical, and flexible enough for many real-world projects.

The main lesson is not just how to make login work. The real lesson is how to create an authentication system that is secure, maintainable, and ready for growth.

If you build it carefully from the start, future you will be grateful. And that version of you will probably be the one fixing production bugs at midnight, so any kindness you can offer them now is worth it.

API authentication is not only a technical feature. It is part of the trust between your application and its users. When you get that trust right, everything else becomes easier.