Using Sanctum to authenticate a mobile app

Published on by

Using Sanctum to authenticate a mobile app image

Sanctum is Laravel’s lightweight API authentication package. In my last article, I looked at authenticating a React SPA with a Laravel API via Sanctum. This tutorial will go over using Laravel Sanctum to authenticate a mobile app. The app will be built in Flutter, Google’s cross-platform app development toolkit. I may skip some implementation details of the mobile app since that is not the focus of this tutorial.

Links to the final code are at the end of this article.

The backend

I’ve set up Homestead to provision a domain name, api.sanctum-mobile.test, where my backend will be served, as well as a MySQL database.

First, create the Laravel app:

laravel new sanctum_mobile

At the time of writing, it gives me a new Laravel project (v8.6.0). As with the SPA tutorial, the API will provide a list of books, so I’ll create the same resources:

php artisan make:model Book -mr

The mr flags create the migration and controller too. Before we mess with the migrations, let’s first install the Sanctum package, since we’ll need its migrations again.

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

Now, create the books migration:

Schema::create('books', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('author');
$table->timestamps();
});

Next, run your app’s migrations:

php artisan migrate

If you now take a look in the database, you’ll see the Sanctum migration has created a personal_access_tokens table, which we’ll use later when authenticating the mobile app.

Let’s update DatabaseSeeder.php to give us some books (and a user for later):

Book::truncate();
$faker = \Faker\Factory::create();
for ($i = 0; $i < 50; $i++) {
Book::create([
'title' => $faker->sentence,
'author' => $faker->name,
]);
}
User::truncate();
User::create([
'name' => 'Alex',
'email' => 'alex@alex.com',
'password' => Hash::make('pwdpwd'),
]);

Now seed the database: php artisan db:seed. Finally, create the route and the controller action. Add this to the routes/api.php file:

Route::get('book', [BookController::class, 'index']);

and then in the index method of BookController, return all the books:

return response()->json(Book::all());

After checking that the endpoint works — curl https://api.sanctum-mobile.test/api/book — it’s time to start the mobile app.

The mobile app

For the mobile app, we’ll be using Android Studio and Flutter. Flutter allows you to create cross-platform apps that re-use the same code for Android and iPhone devices. First, follow the instructions to install Flutter and to set up Android Studio, then launch Android Studio and click “Create a new Flutter project.”

Follow the recipe in Flutter’s cookbook to fetch data from the internet to create a page that fetches a list of books from the API. A quick and easy way to expose our API to the Android Studio device is to use Homestead’s share command:

share api.sanctum-mobile.test

The console will output an ngrok page, which will give you a URL (something like https://0c9775bd.ngrok.io) exposing your local server to the public. (An alternative to ngrok is Beyond Code’s Expose.) So let’s create a utils/constants.dart file to put that in:

const API_URL = 'http://191b43391926.ngrok.io';

Now, back to the Flutter cookbook. Create a file books.dart which will contain the classes required for our book list. First, a Book class to hold the data from the API request:

class Book {
final int id;
final String title;
final String author;
Book({this.id, this.title, this.author});
factory Book.fromJson(Map<String, dynamic> json) {
return Book(
id: json['id'],
title: json['title'],
author: json['author'],
);
}
}

Second, a BookList class to fetch the books and call the builder to display them:

class BookList extends StatefulWidget {
@override
_BookListState createState() => _BookListState();
}
 
class _BookListState extends State<BookList> {
Future<List<Book>> futureBooks;
 
@override
void initState() {
super.initState();
futureBooks = fetchBooks();
}
 
Future<List<Book>> fetchBooks() async {
List<Book> books = new List<Book>();
final response = await http.get('$API_URL/api/book');
if (response.statusCode == 200) {
List<dynamic> data = json.decode(response.body);
for (int i = 0; i < data.length; i++) {
books.add(Book.fromJson(data[i]));
}
return books;
} else {
throw Exception('Problem loading books');
}
}
 
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
BookListBuilder(futureBooks: futureBooks),
],
);
}
}

And last, a BookListBuilder to display the books:

class BookListBuilder extends StatelessWidget {
const BookListBuilder({
Key key,
@required this.futureBooks,
}) : super(key: key);
 
final Future<List<Book>> futureBooks;
 
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Book>>(
future: futureBooks,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Expanded(child: ListView.builder(
itemCount: snapshot.data.length,
itemBuilder: (context, index) {
Book book = snapshot.data[index];
return ListTile(
title: Text('${book.title}'),
subtitle: Text('${book.author}'),
);
},
));
} else if (snapshot.hasError) {
return Text("${snapshot.error}");
}
return CircularProgressIndicator();
}
);
}
}

Now we just need to modify the MyApp class in main.dart to load the BookList:

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sanctum Books',
home: new Scaffold(
body: BookList(),
)
);
}
}

Now launch this in your test device or the emulator, and you should see a list of books.

Authentication with Sanctum

Great, so we know the API is working and that we can fetch books from it. The next step is to set up authentication.

I’m going to use the provider package, and follow the guidelines in the official documentation for setting up simple state management. I want to create an authentication provider that keeps track of the logged-in status and eventually communicates with the server. Create a new file, auth.dart. Here’s where the authentication functionality will go. For the moment, we’ll return true so we can test the process works:

class AuthProvider extends ChangeNotifier {
bool _isAuthenticated = false;
 
bool get isAuthenticated => _isAuthenticated;
 
Future<bool> login(String email, String password) async {
print('logging in with email $email and password $password');
_isAuthenticated = true;
notifyListeners();
return true;
}
}

With this provider, we can now check whether we’re authenticated and display the correct page accordingly. Modify you main function to include the provider:

void main() {
runApp(
ChangeNotifierProvider(
create: (BuildContext context) => AuthProvider(),
child: MyApp(),
)
);
}

… and modify the MyApp class to show the BookList widget if we’re logged-in, or a LoginForm widget otherwise:

body: Center(
child: Consumer<AuthProvider>(
builder: (context, auth, child) {
switch (auth.isAuthenticated) {
case true:
return BookList();
default:
return LoginForm();
}
},
)
),

The LoginForm classes contain a lot of “widgety” cruft, so I’ll refer you to the GitHub repo if you’re interested in looking at it. Anyway, if you load the app in your test device, you should see a login form. Fill in a random email and password, submit the form, and you’ll see a list of books.

Ok, let’s set up the backend to handle the authentication. The docs tell us to create a route that will accept the username and password, as well as a device name, and return a token. So let’s create a route in the api.php file:

Route::post('token', [AuthController::class, 'requestToken']);

and a controller: php artisan make:controller AuthController. This will contain the code from the docs:

public function requestToken(Request $request): string
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
'device_name' => 'required',
]);
 
$user = User::where('email', $request->email)->first();
 
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
 
return $user->createToken($request->device_name)->plainTextToken;
}

Providing the username and password are valid, this will create a token, save it in the database, and return it to the client. To get this to work, we need to add the HasApiTokens trait to our User model. This gives us a tokens relationship, allowing us to create and fetch tokens for the user, and a createToken method. The token itself is a sha256 hash of a 40-character random string: this string (unhashed) is returned to the client, which should save it to use with any future requests to the API. More precisely, the string returned to the client is composed of the token’s id, followed by a pipe character (|), followed by the plain text (unhashed) token.

So now we have this endpoint in place, let’s update the app to use it. The login method will now have to post the email, password, and device_name to this endpoint, and if it gets a 200 response, save the token in the device’s storage. For device_name, I’m using the device_info package to get the device’s unique ID, but in fact, this string is arbitrary.

final response = await http.post('$API_URL/token', body: {
'email': email,
'password': password,
'device_name': await getDeviceId(),
}, headers: {
'Accept': 'application/json',
});
 
if (response.statusCode == 200) {
String token = response.body;
await saveToken(token);
_isAuthenticated = true;
notifyListeners();
}

I use the shared_preferences package, which allows for the storage of simple key-value pairs, to save the token:

saveToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('token', token);
}

So now we’ve got the app displaying the books page after a successful login. But of course, as things stand, the books are accessible with or without successful login. Try it out: curl https://api.sanctum-mobile.test/api/book. So now let’s protect the route:

Route:::middleware('auth:sanctum')->get('book', [BookController::class, 'index']);

Login again via the app, and this time you’ll get an error: “Problem loading books”. You are successfully authenticating, but because we don’t as yet send the API token with our request to fetch the books, the API is quite rightly not sending them. As in the previous tutorial, let’s look at the Sanctum guard to see what it’s doing here:

if ($token = $request->bearerToken()) {
$model = Sanctum::$personalAccessTokenModel;
 
$accessToken = $model::findToken($token);
 
if (! $accessToken ||
($this->expiration &&
$accessToken->created_at->lte(now()->subMinutes($this->expiration))) ||
! $this->hasValidProvider($accessToken->tokenable)) {
return;
}
 
return $this->supportsTokens($accessToken->tokenable) ? $accessToken->tokenable->withAccessToken(
tap($accessToken->forceFill(['last_used_at' => now()]))->save()
) : null;
}

The first condition is skipped since we aren’t using the web guard. Which leaves us with the above code. First, it only runs if the request has a “Bearer” token, i.e. if it contains an Authorization header which starts with the string “Bearer”. If it does, it will call the findToken method on the PersonalAccessToken model:

if (strpos($token, '|') === false) {
return static::where('token', hash('sha256', $token))->first();
}
 
[$id, $token] = explode('|', $token, 2);
 
if ($instance = static::find($id)) {
return hash_equals($instance->token, hash('sha256', $token)) ? $instance : null;
}

The first conditional checks to see whether the pipe character is in the token and, if not, to return the first model that matches the token. I assume this is to preserve backward compatibility with versions of Sanctum before 2.3, which did not include the pipe character in the plain text token when returning it to the user. (Here is the pull request: the reason was to make the token lookup query more performant.) Anyway, assuming the pipe character is there, Sanctum grabs the model’s ID and the token itself, and checks to see if the hash matches with what is stored in the database. If it does, the model is returned.

Back in Guard: if no token is returned, or if we’re considering expiring tokens (which we’re not in this case), return null (in which case authentication fails). Finally:

return $this->supportsTokens($accessToken->tokenable) ? $accessToken->tokenable->withAccessToken(
tap($accessToken->forceFill(['last_used_at' => now()]))->save()
) : null;

Check that the tokenable model (i.e., the User model) supports tokens (in other words, that it uses the HasApiTokens trait). If not, return null – authentication fails. If so, then return this:

$accessToken->tokenable->withAccessToken(
tap($accessToken->forceFill(['last_used_at' => now()]))->save()
)

The above example uses the single-argument version of the tap helper. This can be used to force an Eloquent method (in this case, save) to return the model itself. Here the access token model’s last_used_at timestamp is updated. The saved model is then passed as an argument to the User model’s withAccessToken method (which it gets from the HasApiTokens trait). This is a compact way of updating the token’s last_used_at timestamp and returning its associated User model. Which means authentication has been successful.

So, back to the app. With this authentication in place, we need to update the app’s call to the book endpoint to pass the token in the request’s Authorization header. To do this, update the fetchBooks method to grab the token from the Auth provider, then add it to the header:

String token = await Provider.of<AuthProvider>(context, listen: false).getToken();
final response = await http.get('$API_URL/book', headers: {
'Authorization': 'Bearer $token',
});

Don’t forget to add a getToken method to the AuthProvider class:

Future<String> getToken() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString('token');
}

Now try logging in again, and this time the books should be displayed.

The final code for the API and app can be found here (including functionality for logging out):

Alex Pestell photo

Full stack developer at fortrabbit in Berlin.

Filed in:
Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate logo

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate

Build your SaaS application in hours. Out-of-the-box multi-tenancy and seamless Stripe integration. Supports subscriptions and one-time purchases, allowing you to focus on building and creating without repetitive setup tasks.

Supercharge Your SaaS Development with FilamentFlow: The Ultimate Laravel Filament Boilerplate
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
Asymmetric Property Visibility in PHP 8.4 image

Asymmetric Property Visibility in PHP 8.4

Read article
Access Laravel Pulse Data as a JSON API image

Access Laravel Pulse Data as a JSON API

Read article
Laravel Forge adds Statamic Integration image

Laravel Forge adds Statamic Integration

Read article
Transform Data into Type-safe DTOs with this PHP Package image

Transform Data into Type-safe DTOs with this PHP Package

Read article
PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell image

PHPxWorld - The resurgence of PHP meet-ups with Chris Morrell

Read article
Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3 image

Herd Executable Support and Pest 3 Mutation Testing in PhpStorm 2024.3

Read article