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
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();
}
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.
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.
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%.