Cover image for Mobile API Authentication with Laravel 12 Sanctum and React Native: A Production Guide

At a glance

Reading time

~200 words/min

Published

23 hours ago

May 27, 2026

Views

15

All-time total

Mobile API Authentication with Laravel 12 Sanctum and React Native: A Production Guide

Most "Sanctum tutorial" articles stop at here is a token, attach it to your headers, the end. Production mobile apps need biometric login, refresh-on-launch, secure storage, revocation on logout, and graceful handling when a token expires mid-action. This guide is the full story Laravel 12 on the server, React Native on the device, with the security defaults turned on.

What you will build

  • A Laravel 12 API issuing scoped Sanctum tokens
  • A React Native client storing tokens in the Keychain / Keystore
  • Biometric unlock (FaceID, fingerprint) before sensitive calls
  • A refresh-on-launch flow that survives app kills
  • Per-device session listing and remote revocation
i

Info

Why Sanctum (not Passport, not custom JWT)

Laravel 12 ships Sanctum as the default. Tokens are stored hashed in your database, scoping ("abilities") is built in, revocation is one DELETE, and there is no OAuth ceremony. For 95% of mobile apps, Sanctum is the right answer.

1. Server: token issuance with abilities

// app/Http/Controllers/Api/AuthController.php
public function login(Request $request)
{
    $data = $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required'],
        'device_name' => ['required', 'string', 'max:120'],
    ]);

    $user = User::where('email', $data['email'])->first();

    if (! $user || ! Hash::check($data['password'], $user->password)) {
        throw ValidationException::withMessages(['email' => 'Invalid credentials']);
    }

    $abilities = $user->isAdmin() ? ['*'] : ['read', 'write'];

    $token = $user->createToken(
        name: $data['device_name'],
        abilities: $abilities,
        expiresAt: now()->addDays(30),
    );

    return response()->json([
        'token' => $token->plainTextToken,
        'expires_at' => $token->accessToken->expires_at,
        'user' => $user->only('id', 'name', 'email'),
    ]);
}

public function logout(Request $request)
{
    $request->user()->currentAccessToken()->delete();
    return response()->noContent();
}

public function logoutOthers(Request $request)
{
    $current = $request->user()->currentAccessToken()->id;
    $request->user()->tokens()->where('id', '!=', $current)->delete();
    return response()->noContent();
}

Warning

Always include device_name

It is the only handle the user has on a stolen-phone scenario. Without it, your "active sessions" UI shows "Token #4, Token #7" useless. With it, you show "Pixel 8 (Berlin)" which a user can recognise and revoke.

2. Sanctum config: tighten the defaults

// config/sanctum.php
'expiration' => 60 * 24 * 30,        // 30 days, not null
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', 'lpt_'),
'middleware' => [
    'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
],

// Add a hashed prefix so tokens are scannable in GitHub secret-scanning (Sanctum 12+).

3. React Native: secure token storage

Never store tokens in AsyncStorage it is plaintext on disk. Use react-native-keychain on iOS/Android, which backs onto the Keychain and Keystore respectively.

// lib/auth.ts
import * as Keychain from 'react-native-keychain';

const SERVICE = 'app.example.com';

export async function saveToken(token: string, expiresAt: string) {
    await Keychain.setGenericPassword(
        'auth',
        JSON.stringify({ token, expiresAt }),
        {
            service: SERVICE,
            accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
            accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
        }
    );
}

export async function loadToken(): Promise<{ token: string; expiresAt: string } | null> {
    const result = await Keychain.getGenericPassword({ service: SERVICE });
    if (!result) return null;
    return JSON.parse(result.password);
}

export async function clearToken() {
    await Keychain.resetGenericPassword({ service: SERVICE });
}

Danger

AsyncStorage is not a token store

On a rooted Android device, AsyncStorage is plaintext SQLite. On iOS without "Data Protection: Complete", it is readable from a USB backup. Keychain/Keystore is the only acceptable storage for auth tokens period.

4. The fetch wrapper: refresh, retry, revoke

// lib/api.ts
import { loadToken, clearToken } from './auth';

const BASE_URL = 'https://api.example.com';

export async function api(path: string, init: RequestInit = {}): Promise<Response> {
    const stored = await loadToken();
    if (!stored) throw new Error('NOT_AUTHENTICATED');

    const headers = new Headers(init.headers);
    headers.set('Authorization', `Bearer ${stored.token}`);
    headers.set('Accept', 'application/json');

    const response = await fetch(`${BASE_URL}${path}`, { ...init, headers });

    if (response.status === 401) {
        await clearToken();
        // Tell the navigation root to bounce to /login
        global.dispatchEvent(new Event('auth:expired'));
        throw new Error('SESSION_EXPIRED');
    }

    return response;
}

5. Biometric unlock for sensitive actions

import ReactNativeBiometrics from 'react-native-biometrics';

const rnBiometrics = new ReactNativeBiometrics({ allowDeviceCredentials: true });

export async function requireBiometric(reason: string): Promise<boolean> {
    const { available } = await rnBiometrics.isSensorAvailable();
    if (!available) return true; // device cannot prompt — fall back to password gate

    const { success } = await rnBiometrics.simplePrompt({ promptMessage: reason });
    return success;
}

// Usage before a transfer:
const ok = await requireBiometric('Confirm transfer of $250');
if (!ok) return;
await api('/transfers', { method: 'POST', body: JSON.stringify(payload) });

6. Active sessions UI — server side

public function sessions(Request $request)
{
    return $request->user()->tokens()
        ->select('id', 'name', 'last_used_at', 'expires_at', 'created_at')
        ->orderByDesc('last_used_at')
        ->get()
        ->map(fn ($t) => [
            'id' => $t->id,
            'device' => $t->name,
            'last_used' => $t->last_used_at?->diffForHumans(),
            'is_current' => $t->id === $request->user()->currentAccessToken()->id,
        ]);
}

public function revokeSession(Request $request, int $id)
{
    $request->user()->tokens()
        ->where('id', $id)
        ->where('id', '!=', $request->user()->currentAccessToken()->id)
        ->delete();

    return response()->noContent();
}
1

Mark suspicious tokens

Add an "ip_country" column to <code>personal_access_tokens</code> via a migration and populate it on first use. A user logging in from two countries within an hour is a strong revoke signal.

2

Notify on new device

Dispatch a mailable when a token is created from a new device fingerprint. This single email has caught dozens of compromised accounts in apps I have shipped.

3

Background revoke check

On every API call, the <code>last_used_at</code> column updates automatically. A scheduled job that revokes tokens not used in 90 days keeps the surface tight.

7. Production checklist

Pros

  • Tokens hashed at rest (Sanctum default in Laravel 12)
  • Abilities limit blast radius of a leaked token
  • Per-device naming makes "log out other sessions" usable
  • Biometric gate on financial / destructive actions

Cons

  • Long-lived tokens require revocation flows, do not skip them
  • Refresh-on-launch must handle offline gracefully (retry, do not crash)
  • Test with airplane mode toggled mid-request, most apps fail this

Success

You are 90% done after this guide

The remaining 10% is product-specific: device trust scores, push-based step-up auth, account recovery flows. The Sanctum + Keychain + biometric foundation in this guide carries you safely to that 10%.

Newsletter

Want more posts like this?

Get practical software notes and tutorials delivered when something new is published.

No spam. Unsubscribe anytime.

Share

Related posts

CRUD Operations In Laravel 8

This tutorial is created to illustrate the basic CRUD (Create , Read, Update, Delete) operation using SQL with Laravel 8. Laravel is one of the fastest-growing frameworks for PHP.

4 years ago

Installing PHP on Windows

PHP is known to be one of the most commonly used server-side programming language on the web. It provides an easy to master with a simple learning curve. It has close ties with the MySQL database.

5 years ago

Scheduling Tasks with Cron Job in Laravel 5.8

Cron Job is used to schedule tasks that will be executed every so often. Crontab is a file that contains a list of scripts, By editing the Crontab, You can run the scripts periodically.

7 years ago

Connecting Multiple Databases in Laravel 5.8

This tutorial is created to implement multiple database connections using mysql. Let’s see how to configure multiple database connections in Laravel 5.8.

6 years ago

Integrating Google ReCaptcha in Laravel 5.8

reCAPTCHA is a free service from Google. It’s a CAPTCHA-like system designed to recognize that the user is human and, at the same time, assist in the digitization of books. It helps to protects your w

6 years ago