sanctum-client
v0.1.1
Published
Laravel Sanctum authentication for React, Next.js 16+, Expo, and TanStack Router/Start. Cookie + token modes, CSRF, cross-tab sync, Fortify lifecycle.
Downloads
71
Maintainers
Readme
sanctum-client
Laravel Sanctum authentication for React, Next.js 16+, Expo, and TanStack Router/Start. One package, framework-agnostic core, opt-in adapters via subpath imports.
pnpm add sanctum-clientWhat you get
- Cookie mode (SPA) and token mode (PAT / mobile)
- CSRF lifecycle handled automatically
- Cross-tab sync via
BroadcastChannel(withstorageevent fallback) - Laravel Fortify lifecycle: register, forgot/reset password, email verification, 2FA, profile/password updates
- Next.js 16+ App Router:
proxy.tsroute gating, catch-all gateway to Laravel, RSC helpers, Server Actions - Expo / React Native:
SecureStoretoken storage, token-only mode enforced - TanStack Router / Start: loader-based SSR hydration,
beforeLoadguards, route context - TanStack Query: optional cache mirroring
Table of contents
- Laravel backend setup — required for every client
- React + Vite
- Next.js 16+
- TanStack Router / Start
- Expo
- Fortify hooks
- TanStack Query integration
- Storage & security
- Troubleshooting
Laravel backend setup
This section applies to every client. Skipping these steps will cause CSRF 419s, 401s on /api/user, or silent CORS failures.
1. Install Sanctum + Fortify
composer require laravel/sanctum laravel/fortify
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"
php artisan migrateRegister the Fortify provider in bootstrap/providers.php:
return [
AppServiceProvider::class,
FortifyServiceProvider::class,
];2. Add HasApiTokens + TwoFactorAuthenticatable to your User model
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable {
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
}Also hide the 2FA secret/recovery columns:
#[Hidden(['password', 'remember_token', 'two_factor_recovery_codes', 'two_factor_secret'])]3. Enable Sanctum SPA middleware + register routes/api.php
bootstrap/app.php:
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php', // ← add this
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi(); // ← Sanctum SPA cookie auth
})routes/api.php:
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->get('/user', fn (Request $r) => $r->user());
// Optional: token-mode login endpoint for mobile/Expo clients.
Route::post('/token/login', function (Request $request) {
$request->validate(['email' => 'required|email', 'password' => 'required']);
$user = \App\Models\User::where('email', $request->email)->first();
if (! $user || ! \Hash::check($request->password, $user->password)) {
return response()->json(['message' => 'Invalid credentials.'], 422);
}
$token = $user->createToken('sanctum-client')->plainTextToken;
return response()->json(['token' => $token, 'user' => $user]);
});4. SPA-friendly config/fortify.php
Set views to false so Fortify returns JSON instead of Blade:
'views' => false,Confirm the features array enables what your client uses (defaults are fine for most apps):
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true]),
],5. CORS + Sanctum stateful domains + session cookies
Publish CORS:
php artisan config:publish corsconfig/cors.php:
return [
'paths' => [
'api/*', 'login', 'logout', 'register',
'forgot-password', 'reset-password',
'email/verification-notification',
'two-factor-challenge', 'user/*',
'sanctum/csrf-cookie',
],
'allowed_methods' => ['*'],
'allowed_origins' => array_filter(explode(',', (string) env('FRONTEND_ORIGINS', ''))),
'allowed_headers' => ['*'],
'supports_credentials' => true, // ← REQUIRED for cookie mode
];.env:
# Cookie-mode frontends. Don't include token-mode (Expo) origins here.
FRONTEND_ORIGINS=http://localhost:5173,http://localhost:3000
SANCTUM_STATEFUL_DOMAINS=localhost:5173,localhost:3000
# SameSite=Lax + Secure=false is required for HTTP localhost dev.
SESSION_SAME_SITE=lax
SESSION_SECURE_COOKIE=falseCritical: if any frontend uses token mode (Expo, native), do not include its origin in SANCTUM_STATEFUL_DOMAINS. Sanctum will try to apply the CSRF dance and 419 the token-mode requests.
6. The localhost vs 127.0.0.1 trap
Browsers treat localhost:5173 and 127.0.0.1:8000 as different sites, so SameSite=Lax cookies from Laravel won't be sent back. Pick one hostname and use it everywhere:
| ❌ Broken | ✅ Works |
|---|---|
| Vite on localhost:5173 → Laravel on 127.0.0.1:8000 | both on localhost |
| Browser tab loads from 127.0.0.1, fetches from localhost | both on 127.0.0.1 |
Easiest fix: set *_API_URL=http://localhost:8000 in each frontend's .env.
React + Vite
Install
pnpm add sanctum-clientvite.config.ts
Subpaths (sanctum-client/react, /fortify, /react-query) share a single SanctumContext module. Vite's dep optimizer can pre-bundle each subpath separately and duplicate that module, which causes useSanctum: <SanctumProvider> is missing from the tree. errors at runtime. Pin all subpaths to their dist files and exclude them from pre-bundling:
import { createRequire } from 'node:module'
import { dirname, resolve } from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
const require = createRequire(import.meta.url)
const sanctumRoot = dirname(require.resolve('sanctum-client/package.json'))
export default defineConfig({
plugins: [react()],
// Use `localhost` so the browser and the Laravel host are same-site under
// SameSite=Lax. See the Laravel section above for the rationale.
server: { host: '127.0.0.1' /* or omit and rely on default */ },
resolve: {
alias: [
{ find: /^sanctum-client$/, replacement: resolve(sanctumRoot, 'dist/index.js') },
{ find: /^sanctum-client\/react$/, replacement: resolve(sanctumRoot, 'dist/react/index.js') },
{ find: /^sanctum-client\/fortify$/, replacement: resolve(sanctumRoot, 'dist/fortify/index.js') },
{ find: /^sanctum-client\/react-query$/, replacement: resolve(sanctumRoot, 'dist/react-query/index.js') },
],
},
optimizeDeps: {
exclude: [
'sanctum-client',
'sanctum-client/react',
'sanctum-client/fortify',
'sanctum-client/react-query',
],
},
}).env
VITE_API_URL=http://localhost:8000Client
// src/sanctum.ts
import { createSanctumClient } from 'sanctum-client'
export const sanctum = createSanctumClient({
baseURL: import.meta.env.VITE_API_URL,
mode: 'cookie',
})// src/main.tsx
import { SanctumProvider } from 'sanctum-client/react'
import { sanctum } from './sanctum'
createRoot(document.getElementById('root')!).render(
<SanctumProvider client={sanctum}>
<App />
</SanctumProvider>,
)// src/LoginForm.tsx
import { useLogin } from 'sanctum-client/react'
function LoginForm() {
const { mutate, isPending, error } = useLogin()
return (
<form onSubmit={(e) => {
e.preventDefault()
const data = new FormData(e.currentTarget)
void mutate({ email: data.get('email'), password: data.get('password') })
}}>
{/* ... */}
</form>
)
}Next.js 16+
App Router only. Uses proxy.ts (Next 16's replacement for middleware.ts).
Install
pnpm add sanctum-client.env.local
# Server-side fetches to Laravel.
LARAVEL_URL=http://127.0.0.1:8000
# Public client base — the catch-all gateway forwards /api/* to Laravel.
NEXT_PUBLIC_API_BASE=/apiPer-request server client
// src/lib/sanctum-server.ts
import 'server-only'
import { getSanctumUser } from 'sanctum-client/next/server'
export const currentUser = () =>
getSanctumUser({ baseURL: process.env.LARAVEL_URL! })Root layout (RSC)
// src/app/layout.tsx
import { currentUser } from '@/lib/sanctum-server'
import { Providers } from './providers'
// Reading cookies makes the layout dynamic — opt out of SSG.
export const dynamic = 'force-dynamic'
export default async function RootLayout({ children }) {
const user = await currentUser().catch(() => null)
return (
<html>
<body>
<Providers initialUser={user}>{children}</Providers>
</body>
</html>
)
}// src/app/providers.tsx
'use client'
import { SanctumProvider } from 'sanctum-client/react' // ← /react, NOT /next
import { sanctum } from '@/sanctum'
export function Providers({ initialUser, children }) {
return <SanctumProvider client={sanctum} initialUser={initialUser}>{children}</SanctumProvider>
}Important: import
SanctumProviderand all hooks fromsanctum-client/react, never fromsanctum-client/next. Turbopack pre-bundles each subpath independently and an inconsistent import path will produce twoSanctumContextmodules.
Catch-all gateway
// src/app/api/[...sanctum]/route.ts
import { createSanctumGateway } from 'sanctum-client/next/gateway'
export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = createSanctumGateway({
upstream: process.env.LARAVEL_URL!,
basePath: '/api',
preservePath: false, // /api/sanctum/csrf-cookie → /sanctum/csrf-cookie upstream
})
export const runtime = 'nodejs'Edge route gating (proxy.ts at the app root)
// proxy.ts (Next 16+; this file is called `middleware.ts` in Next ≤15)
import { withSanctumAuth } from 'sanctum-client/next/proxy'
export default withSanctumAuth({
protect: ['/dashboard/:path*'],
loginPath: '/login',
})
export const config = { matcher: ['/dashboard/:path*'] }Client
// src/sanctum.ts
'use client'
import { createSanctumClient } from 'sanctum-client'
export const sanctum = createSanctumClient({
baseURL: process.env.NEXT_PUBLIC_API_BASE ?? '/api',
mode: 'cookie',
})Laravel .env additions for Next
SANCTUM_STATEFUL_DOMAINS=localhost:3000,127.0.0.1:3000
FRONTEND_ORIGINS=http://localhost:3000The gateway lives on Next's origin, so the browser sees same-origin cookies. Laravel sees an
Originoflocalhost:3000(or whatever your Next URL is) — that's what needs to be inSANCTUM_STATEFUL_DOMAINS.
TanStack Router / Start
Install
pnpm add sanctum-clientvite.config.ts
Same Vite dep-optimizer caveat as the plain Vite app — alias subpaths to dist files:
import { createRequire } from 'node:module'
import { dirname, resolve } from 'node:path'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import viteReact from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
const require = createRequire(import.meta.url)
const sanctumRoot = dirname(require.resolve('sanctum-client/package.json'))
export default defineConfig({
resolve: {
alias: [
{ find: /^sanctum-client$/, replacement: resolve(sanctumRoot, 'dist/index.js') },
{ find: /^sanctum-client\/react$/, replacement: resolve(sanctumRoot, 'dist/react/index.js') },
{ find: /^sanctum-client\/fortify$/, replacement: resolve(sanctumRoot, 'dist/fortify/index.js') },
{ find: /^sanctum-client\/tanstack$/, replacement: resolve(sanctumRoot, 'dist/tanstack/index.js') },
],
},
optimizeDeps: {
exclude: [
'sanctum-client',
'sanctum-client/react',
'sanctum-client/fortify',
'sanctum-client/tanstack',
],
},
plugins: [tanstackRouter({ target: 'react', autoCodeSplitting: true }), viteReact()],
})Router setup
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { createSanctumRouterContext } from 'sanctum-client/tanstack'
import { routeTree } from './routeTree.gen'
import { sanctum } from './sanctum'
export function getRouter() {
return createRouter({
routeTree,
context: createSanctumRouterContext(sanctum),
})
}// src/main.tsx
import { RouterProvider } from '@tanstack/react-router'
import { SanctumProvider } from 'sanctum-client/react'
import { getRouter } from './router'
import { sanctum } from './sanctum'
const router = getRouter()
createRoot(document.getElementById('app')!).render(
<SanctumProvider client={sanctum}>
<RouterProvider router={router} />
</SanctumProvider>,
)Typed root route
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import type { RouteContextWithSanctum } from 'sanctum-client/tanstack'
import type { AppUser } from '../sanctum'
export const Route = createRootRouteWithContext<RouteContextWithSanctum<AppUser>>()({
component: () => <Outlet />,
})Guarded route + loader
// src/routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'
import { requireAuthBeforeLoad, sanctumLoader } from 'sanctum-client/tanstack'
import type { AppUser } from '../sanctum'
export const Route = createFileRoute('/dashboard')({
beforeLoad: ({ context, location }) =>
requireAuthBeforeLoad<AppUser>({ context, location, options: { loginPath: '/login' } }),
loader: ({ context }) => sanctumLoader<AppUser>({ context }),
component: Dashboard,
})
function Dashboard() {
const { user } = Route.useLoaderData()
return <pre>{JSON.stringify(user, null, 2)}</pre>
}TanStack Start (SSR + server functions)
Use sanctum-client/tanstack/server inside a createServerFn(...):
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { bridgeSanctumCookies, getSanctumClient } from 'sanctum-client/tanstack/server'
export const getMe = createServerFn({ method: 'GET' }).handler(async () => {
const request = getRequest()
const client = await getSanctumClient({
baseURL: process.env.LARAVEL_URL!,
cookie: bridgeSanctumCookies(request, ['laravel_session', 'XSRF-TOKEN']),
})
return client.fetchUser()
})Expo
Install
pnpm add sanctum-client expo-secure-store.env
EXPO_PUBLIC_API_URL=http://localhost:8000 # iOS Simulator
# EXPO_PUBLIC_API_URL=http://10.0.2.2:8000 # Android Emulator
# EXPO_PUBLIC_API_URL=http://192.168.x.y:8000 # Physical device (LAN IP, run `php artisan serve --host=0.0.0.0`)Client
// sanctum.ts
import { createExpoSanctumClient } from 'sanctum-client/expo'
export const sanctum = createExpoSanctumClient({
baseURL: process.env.EXPO_PUBLIC_API_URL!,
// Fortify's web /login returns a session cookie which RN cannot store.
// Point sanctum-client at a custom token-issuing endpoint instead.
routes: { login: '/api/token/login' },
})Root layout
// app/_layout.tsx
import { Stack } from 'expo-router'
import { SanctumProvider } from 'sanctum-client/react'
import { sanctum } from '../sanctum'
export default function RootLayout() {
return (
<SanctumProvider client={sanctum}>
<Stack />
</SanctumProvider>
)
}Laravel-side caveats for Expo
- Do NOT add Expo's origin to
SANCTUM_STATEFUL_DOMAINS. Sanctum will try CSRF on token-mode requests and 419 them. - Bind Laravel to
0.0.0.0for physical device testing:php artisan serve --host=0.0.0.0 --port=8000. - Fortify routes (
/register,/forgot-password,/two-factor-*) require sessions + CSRF and won't work over PAT mode. To support them on mobile, expose mirror endpoints under anauth:sanctum-guarded API group on the Laravel side. expo-secure-storedoesn't polyfill on Expo web (no Keychain in browsers). Test on a real simulator or device. For web targets, fall back tolocalStorageAdapterfromsanctum-clientand accept the XSS exposure.
Fortify hooks
import {
useRegister,
useForgotPassword,
useResetPassword,
useUpdateProfile,
useUpdatePassword,
useConfirmPassword,
useTwoFactor,
useTwoFactorChallenge,
} from 'sanctum-client/fortify'All endpoints are configurable via routes on createSanctumClient. Defaults match a vanilla Sanctum + Fortify install.
Fortify 2FA requires password confirmation before enabling. Expected flow:
await fortify.confirmPassword({ password }) // sets a session flag
await fortify.twoFactor.enable() // would 423 Locked without the line aboveTanStack Query integration
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
sanctumQueryOptions,
useSanctumQuery,
bindSanctumToQueryClient,
} from 'sanctum-client/react-query'useSanctumQuery()— mirrors the auth user into the query cachebindSanctumToQueryClient(client, queryClient)— auto-invalidates the user query onlogin,logout,userUpdated,sessionExpired, and cross-tab events
Storage & security
- Cookie mode (default for web): the browser owns an
httpOnlysession cookie. No JS-accessible token. CSRF is fetched and propagated automatically. - Token mode on web:
memoryStorageby default — token lost on refresh but immune to XSS exfiltration. Opt in tolocalStorageAdapterwith explicit config; the package logs a one-time XSS-exposure warning in dev. - Token mode on native:
SecureStore(iOS Keychain / Android Keystore) viasanctum-client/expo. Never useAsyncStorage.
The gateway helper for Next strips the Domain attribute from Set-Cookie headers so Laravel cookies don't leak to the wrong host.
Troubleshooting
useSanctum: <SanctumProvider> is missing from the tree.
You have two copies of the SanctumContext module due to bundler dedup. Always check:
- Are you importing
SanctumProviderAND your hooks from the same subpath? Usesanctum-client/reacteverywhere; don't mix withsanctum-client/next/expo. - For Vite: did you add the
resolve.alias+optimizeDeps.excludeblock from the React + Vite section? - For Turbopack/Next: this is fixed by always importing the provider and hooks from
sanctum-client/react.
419 (CSRF token mismatch) on /login
Almost always one of:
- The browser tab origin is not in
SANCTUM_STATEFUL_DOMAINS. (Tab hostname + port must match exactly —localhost:3000and127.0.0.1:3000are different.) - The browser tab and Laravel are on different hosts (e.g.
localhost:5173→127.0.0.1:8000). Pick one hostname for both. SESSION_SAME_SITEisstrict. Uselaxfor dev.
401 Unauthorized on /api/user after a fresh login
supports_credentials: trueis missing fromconfig/cors.php.- Your client isn't sending
credentials: 'include'(it does by default in cookie mode — check that you're not overridingwithCredentials: false). - The browser silently dropped the
Set-Cookiedue toSameSite=None+ non-HTTPS. Uselax+Secure=falsefor HTTP localhost.
423 Locked on /user/two-factor-authentication
Fortify requires password re-confirmation before enabling 2FA. Call useConfirmPassword().mutate({ password }) first.
Next.js: "Cannot read properties of null" during static prerender
Your layout reads cookies and Next is trying to SSG the page. Add export const dynamic = 'force-dynamic' to the layout.
Expo: ExpoSecureStore.default.setValueWithKeyAsync is not a function
expo-secure-store does not work on Expo web. Test on a real simulator/device, or use localStorageAdapter on the web target (XSS-exposed).
License
MIT
