Laravel Sanctum is the right choice for most Laravel API authentication. It is lightweight, well-documented, and integrates with Laravel's auth system without replacing it. For a single-tenant application, the default setup covers most use cases with minimal configuration. You install the package, run the migrations, issue tokens, and move on.
Multi-tenant SaaS introduces complexity that the default setup does not handle. Tenants must be isolated from each other's data. A token issued to a user in one tenant must not provide access to another tenant's resources, even if the underlying user record somehow appears in both. API tokens issued to tenants for machine-to-machine access need different scopes and lifetimes than user session tokens. Tenant resolution needs to happen before route authorization, and the resolution mechanism needs to be consistent across every request type.
I built this pattern across multiple Laravel SaaS products. What follows is the authentication architecture that held up in production: how we configured Sanctum, how we resolved tenants, how we scoped tokens, and where we drew the boundaries between user authentication and tenant API access.
The Multi-Tenant Auth Problem
A naive Sanctum setup checks whether a token is valid and loads the associated user. It does not check which tenant the request is operating in, and it does not verify that the user has access to the tenant the request is targeting.
The failure mode is subtle. If a user belongs to two tenants, a token issued in the context of tenant A is technically valid when used against tenant B's endpoints. The token passes Sanctum's validity check. The user record loads. The tenant scope is never applied. The user reads data that belongs to a different tenant.
The correct model associates every token with both a user and a tenant, resolves the tenant from the request before authorization runs, and applies a global tenant scope to every query so that data isolation is enforced at the database layer regardless of which controller method executes.
Tenant Resolution
Tenant resolution happens in middleware, before Sanctum authenticates the request. The tenant is extracted from the subdomain or a request header, validated against the database, and bound to the container so that every part of the request lifecycle can access it without another database query.
// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class ResolveTenant
{
public function handle(Request $request, Closure $next): Response
{
$identifier = $this->extractIdentifier($request);
if (!$identifier) {
return response()->json(['error' => 'tenant identifier required'], 400);
}
$tenant = Tenant::where('subdomain', $identifier)
->where('status', 'active')
->first();
if (!$tenant) {
return response()->json(['error' => 'tenant not found'], 404);
}
App::instance(Tenant::class, $tenant);
$request->attributes->set('tenant', $tenant);
return $next($request);
}
private function extractIdentifier(Request $request): ?string
{
// Subdomain-based: api.acme.yoursaas.com -> "acme"
$host = $request->getHost();
$parts = explode('.', $host);
if (count($parts) >= 3) {
return $parts[0] === 'api' ? ($parts[1] ?? null) : $parts[0];
}
// Header-based fallback for local development and testing
return $request->header('X-Tenant-ID');
}
}
Binding the tenant to the container with App::instance means any part of the application can resolve it with app(Tenant::class) without receiving it through method parameters. The service container becomes the single source of truth for the current tenant within a request lifecycle.
Checking status = active at the resolution layer means suspended or deleted tenants are rejected before any authentication logic runs. An inactive tenant's tokens are effectively invalidated without touching the tokens table.
The Token Model
Sanctum's default personal_access_tokens table does not carry tenant context. We extend it with a tenant_id column and a token_type column that distinguishes user tokens from service tokens.
// database/migrations/xxxx_add_tenant_to_personal_access_tokens.php
public function up(): void
{
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->foreignUlid('tenant_id')
->nullable()
->constrained('tenants')
->cascadeOnDelete();
$table->string('token_type')->default('user');
$table->index(['tenant_id', 'token_type']);
});
}
token_type carries two values in this system: user for tokens issued to human users through the login flow, and service for long-lived tokens issued to tenant integrations for machine-to-machine API access. They are validated differently, carry different scopes, and have different expiry policies.
Issuing Tokens with Tenant Context
Token issuance always associates the token with the resolved tenant. A token without a tenant association is rejected at the validation layer.
// app/Services/Auth/TokenService.php
namespace App\Services\Auth;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Support\Carbon;
use Laravel\Sanctum\NewAccessToken;
class TokenService
{
public function issueUserToken(User $user, Tenant $tenant, string $deviceName): NewAccessToken
{
// Revoke existing tokens for this user in this tenant on this device
$user->tokens()
->where('tenant_id', $tenant->id)
->where('name', $deviceName)
->delete();
return $user->createToken(
name: $deviceName,
abilities: ['user:read', 'user:write'],
expiresAt: Carbon::now()->addDays(30),
)->tap(function (NewAccessToken $token) use ($tenant) {
$token->accessToken->forceFill([
'tenant_id' => $tenant->id,
'token_type' => 'user',
])->save();
});
}
public function issueServiceToken(Tenant $tenant, string $name): NewAccessToken
{
return $tenant->createToken(
name: $name,
abilities: ['api:read', 'api:write'],
)->tap(function (NewAccessToken $token) use ($tenant) {
$token->accessToken->forceFill([
'tenant_id' => $tenant->id,
'token_type' => 'service',
])->save();
});
}
}
Revoking existing tokens for the same device on login prevents token accumulation. A user who logs in from the same device repeatedly does not end up with thirty valid tokens in the database. One device, one active token.
Service tokens are issued to the tenant model directly rather than to a user. Machine-to-machine integrations authenticate as the tenant, not as a specific user. This distinction matters for audit logging: an action taken by a service token is attributed to the tenant integration, not to a human user who may have left the organisation.
Sanctum Guard Configuration
Sanctum needs to know how to validate tokens against the tenant context. We extend Sanctum's token validation to reject tokens that belong to a different tenant than the one resolved from the request.
// app/Http/Middleware/ValidateTenantToken.php
namespace App\Http\Middleware;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ValidateTenantToken
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user) {
return response()->json(['code' => 'UNAUTHORIZED', 'message' => 'unauthenticated'], 401);
}
$tenant = app(Tenant::class);
$token = $request->user()->currentAccessToken();
if ($token->tenant_id !== $tenant->id) {
return response()->json([
'code' => 'FORBIDDEN',
'message' => 'token does not belong to this tenant',
], 403);
}
return $next($request);
}
}
This middleware runs after Sanctum has authenticated the request. Sanctum confirms the token is valid and loads the user. ValidateTenantToken confirms the token belongs to the resolved tenant. Both checks must pass. A valid token issued in the context of a different tenant returns 403 rather than 401 because the token itself is legitimate. The problem is the tenant mismatch.
Route Configuration
The middleware stack is assembled in a specific order: tenant resolution, then Sanctum authentication, then tenant-token validation, then authorization.
// routes/api.php
use App\Http\Middleware\ResolveTenant;
use App\Http\Middleware\ValidateTenantToken;
Route::middleware([ResolveTenant::class])->group(function () {
// Public routes: tenant resolved, no auth required
Route::post('/auth/login', [AuthController::class, 'login']);
Route::post('/auth/register', [AuthController::class, 'register']);
// Authenticated user routes
Route::middleware(['auth:sanctum', ValidateTenantToken::class])->group(function () {
Route::get('/me', [UserController::class, 'me']);
Route::apiResource('/projects', ProjectController::class);
Route::apiResource('/invoices', InvoiceController::class);
});
// Service token routes: machine-to-machine only
Route::middleware([
'auth:sanctum',
ValidateTenantToken::class,
'ability:api:read,api:write',
])->prefix('/v1')->group(function () {
Route::get('/webhooks', [WebhookController::class, 'index']);
Route::post('/webhooks', [WebhookController::class, 'store']);
});
});
Login and registration sit inside the tenant resolution middleware but outside authentication. The tenant must be resolved to validate that the user is registering or logging into an active tenant, but the routes do not require an existing token.
Service token routes use ability:api:read,api:write to ensure only tokens issued with API abilities can reach them. A user token attempting to reach a service token route fails the ability check even if it passes tenant validation.
Global Tenant Scope
Token validation ensures the request comes from a user or service belonging to the correct tenant. The global tenant scope ensures that every Eloquent query within the request is automatically filtered by that tenant.
// app/Models/Scopes/TenantScope.php
namespace App\Models\Scopes;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (app()->has(Tenant::class)) {
$builder->where($model->getTable() . '.tenant_id', app(Tenant::class)->id);
}
}
}
// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;
use App\Models\Scopes\TenantScope;
use App\Models\Tenant;
use Illuminate\Support\Facades\App;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function (self $model) {
if (empty($model->tenant_id) && App::has(Tenant::class)) {
$model->tenant_id = App::make(Tenant::class)->id;
}
});
}
}
The creating hook on bootBelongsToTenant automatically assigns the resolved tenant ID on every new record. A model that has this trait applied will never be created without a tenant ID as long as a tenant has been resolved for the request. If no tenant is resolved, the empty check prevents a null assignment and the record fails the database constraint, which is the correct outcome.
Any model that belongs to a tenant uses the trait:
// app/Models/Project.php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class Project extends Model
{
use BelongsToTenant;
protected $fillable = ['name', 'description', 'status', 'tenant_id', 'owner_id'];
}
From this point, Project::all() returns only the current tenant's projects. Project::find($id) returns null if the project belongs to a different tenant. No controller or service method needs to remember to add a where('tenant_id', ...) clause. The scope is applied automatically and consistently.
The Login Flow End to End
// app/Http/Controllers/AuthController.php
namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\TokenService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class AuthController extends Controller
{
public function __construct(private TokenService $tokenService) {}
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'device_name' => ['required', 'string', 'max:255'],
]);
$tenant = app(Tenant::class);
$user = User::withoutGlobalScopes()
->where('email', $request->email)
->where('tenant_id', $tenant->id)
->first();
if (!$user || !Hash::check($request->password, $user->password)) {
return response()->json([
'code' => 'UNAUTHORIZED',
'message' => 'invalid credentials',
], 401);
}
if ($user->status !== 'active') {
return response()->json([
'code' => 'FORBIDDEN',
'message' => 'account is not active',
], 403);
}
$token = $this->tokenService->issueUserToken($user, $tenant, $request->device_name);
return response()->json([
'token' => $token->plainTextToken,
'expires_at' => $token->accessToken->expires_at,
], 200);
}
}
withoutGlobalScopes() is necessary on the login query. At the point of login, no user has been authenticated yet, so Sanctum has not loaded a user into the request. The TenantScope relies on app(Tenant::class) which is available from the ResolveTenant middleware, but the user query needs to explicitly scope by tenant rather than relying on the global scope because the global scope applies to Eloquent model instances, and the login query is the query that retrieves the user for the first time.
The explicit where('tenant_id', $tenant->id) on the user query means a user with the same email address in two different tenants cannot log into the wrong tenant. Each login attempt is scoped to the resolved tenant regardless of email uniqueness across the platform.
Token Pruning
Sanctum does not prune expired or revoked tokens automatically. In a multi-tenant system with thousands of active users, the personal_access_tokens table grows continuously without pruning. Schedule the pruning command and extend it with tenant-aware logic.
// app/Console/Commands/PruneExpiredTokens.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\PersonalAccessToken;
class PruneExpiredTokens extends Command
{
protected $signature = 'tokens:prune {--days=90}';
protected $description = 'Remove expired and stale tokens';
public function handle(): int
{
$days = (int) $this->option('days');
// Prune tokens past their expiry
$expired = PersonalAccessToken::where('expires_at', '<', now())->delete();
// Prune user tokens not used in the configured number of days
$stale = PersonalAccessToken::where('token_type', 'user')
->where(function ($q) use ($days) {
$q->whereNull('last_used_at')
->orWhere('last_used_at', '<', now()->subDays($days));
})
->delete();
$this->info("Pruned {$expired} expired and {$stale} stale tokens.");
return Command::SUCCESS;
}
}
Service tokens are excluded from stale pruning. A service token that has not been used for 90 days may belong to an integration that runs quarterly. Pruning it breaks the integration without warning. Expired tokens are pruned regardless of type because an expired token is invalid by definition.
Schedule the command in routes/console.php:
Schedule::command('tokens:prune')->daily()->at('02:00');
Conclusion
Sanctum handles the mechanics of token issuance and validation correctly out of the box. Multi-tenant SaaS requires additional structure that Sanctum does not provide by default: tenant resolution before authentication, token-to-tenant association, scope validation at the route level, and a global Eloquent scope that enforces data isolation at the query layer.
Each layer has a single responsibility. ResolveTenant identifies the tenant. Sanctum authenticates the token. ValidateTenantToken confirms the token belongs to the resolved tenant. TenantScope ensures every query is filtered by the current tenant. The login controller scopes the user lookup explicitly because no user context exists yet when authentication begins.
The result is a system where tenant isolation is enforced at multiple layers simultaneously. Removing any one layer degrades the isolation guarantee. Running all of them means a correctly issued token can only ever access the data it was issued to access.