Stu Mason
Stu Mason
Guide

Multi-Tenant SaaS Authorization: Policies That Actually Scale

Stuart Mason6 min read

The first time you ship a multi-tenant app without proper authorisation scoping, you'll get a support email that says something like "I can see another company's invoices." That's the moment you learn

Multi-Tenant SaaS Authorization: Policies That Actually Scale

The first time you ship a multi-tenant app without proper authorisation scoping, you'll get a support email that says something like "I can see another company's invoices." That's the moment you learn that checking $user->id === $model->user_id isn't enough.

Multi-tenant authorisation needs two checks on every single operation: does this user own (or have access to) this resource, AND does this resource belong to their tenant? Miss either check and you've got a data leak.

The Tenant Model

Most multi-tenant Laravel apps I build have a structure like this:

// A user belongs to one or more tenants (companies, organisations, etc.)
class User extends Authenticatable
{
    public function tenants(): BelongsToMany
    {
        return $this->belongsToMany(Tenant::class)
            ->withPivot('role')
            ->withTimestamps();
    }

    public function currentTenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class, 'current_tenant_id');
    }

    public function belongsToTenant(Tenant $tenant): bool
    {
        return $this->tenants()->where('tenant_id', $tenant->id)->exists();
    }
}

Every model that holds tenant data has a tenant_id:

class Property extends Model
{
    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }
}

Simple enough. The complexity is in consistently enforcing it.

The Naive Policy

Here's what most tutorials show you:

class PropertyPolicy
{
    public function update(User $user, Property $property): bool
    {
        return $user->id === $property->owner_id;
    }
}

This checks ownership but not tenancy. If User A somehow gets the ID of a property belonging to a different tenant (sequential IDs make this trivial — see my article on Sqids), the only thing stopping them is the ownership check. And what about admin users who can manage any property within their tenant? This pattern falls apart immediately.

The Double-Check Pattern

Every Policy method should verify tenant membership first, then check the specific permission:

class PropertyPolicy
{
    public function viewAny(User $user): bool
    {
        // Tenant scoping happens at the query level
        return true;
    }

    public function view(User $user, Property $property): bool
    {
        return $user->belongsToTenant($property->tenant);
    }

    public function create(User $user): bool
    {
        // User must have an active tenant
        return $user->currentTenant !== null;
    }

    public function update(User $user, Property $property): bool
    {
        if (! $user->belongsToTenant($property->tenant)) {
            return false;
        }

        return $user->id === $property->owner_id
            || $user->hasRoleInTenant($property->tenant, TenantRole::Admin);
    }

    public function delete(User $user, Property $property): bool
    {
        if (! $user->belongsToTenant($property->tenant)) {
            return false;
        }

        return $user->id === $property->owner_id;
    }
}

The belongsToTenant check is the gatekeeper. Even if someone manipulates a request to reference a resource from another tenant, the Policy catches it.

Extracting the Tenant Check

You'll write that belongsToTenant check so many times that it makes sense to extract it:

// app/Concerns/AuthorisesWithinTenant.php

trait AuthorisesWithinTenant
{
    protected function withinTenant(User $user, Model $model): bool
    {
        if (! method_exists($model, 'tenant')) {
            throw new \LogicException(
                get_class($model) . ' must have a tenant() relationship'
            );
        }

        return $user->belongsToTenant($model->tenant);
    }
}

Then your policies become:

class BookingPolicy
{
    use AuthorisesWithinTenant;

    public function update(User $user, Booking $booking): bool
    {
        if (! $this->withinTenant($user, $booking)) {
            return false;
        }

        return $user->id === $booking->host_id
            || $user->hasRoleInTenant($booking->tenant, TenantRole::Manager);
    }

    public function cancel(User $user, Booking $booking): bool
    {
        if (! $this->withinTenant($user, $booking)) {
            return false;
        }

        // Only the guest who made the booking or the host can cancel
        return $user->id === $booking->guest_id
            || $user->id === $booking->host_id;
    }
}

Scoping Queries to the Tenant

Policies handle individual resource checks, but you also need to scope all queries. A global scope is the simplest approach:

// app/Models/Scopes/TenantScope.php

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (auth()->check() && auth()->user()->current_tenant_id) {
            $builder->where(
                $model->getTable() . '.tenant_id',
                auth()->user()->current_tenant_id
            );
        }
    }
}

Apply it to every tenant-scoped model:

class Property extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new TenantScope());
    }
}

Now Property::all() automatically returns only the current tenant's properties. No developer can accidentally forget the where clause.

The Gotchas

Gotcha 1: Artisan commands and jobs don't have auth context. Your global scope will fail silently because auth()->check() returns false. For queued jobs, pass the tenant explicitly:

class ProcessBooking implements ShouldQueue
{
    public function __construct(
        public Booking $booking,
        public Tenant $tenant,
    ) {}

    public function handle(): void
    {
        // Temporarily set tenant context
        Tenant::setCurrent($this->tenant);

        // Now scoped queries work
        $this->processBooking();
    }
}

Gotcha 2: Eager loading can leak across tenants. If you eager load a relationship that isn't scoped, you'll get cross-tenant data. Always make sure nested relationships also have the tenant scope, or scope them explicitly:

$properties = Property::with(['bookings' => function ($query) {
    $query->where('tenant_id', auth()->user()->current_tenant_id);
}])->get();

Though if your Booking model has the global scope, this happens automatically. The risk is with polymorphic relationships or manual joins.

Gotcha 3: Route model binding bypasses global scopes by default. If someone hits /properties/42 and property 42 belongs to a different tenant, Laravel will still resolve it. You need to scope route model binding:

// In your RouteServiceProvider or route definition
Route::bind('property', function (string $value) {
    return Property::where('id', $value)
        ->where('tenant_id', auth()->user()?->current_tenant_id)
        ->firstOrFail();
});

Or use the Policy's view method to catch it, which is what I prefer — let the binding resolve, then let the Policy reject unauthorised access. This gives you a proper 403 instead of a 404, which is more honest.

Nested Resources

In a marketplace, resources are nested: a Tenant has Properties, Properties have Bookings, Bookings have Payments. The Policy for a deeply nested resource still checks tenant membership, but through the parent chain:

class PaymentPolicy
{
    use AuthorisesWithinTenant;

    public function view(User $user, Payment $payment): bool
    {
        // Payment belongs to a booking, which belongs to a property,
        // which belongs to a tenant
        return $this->withinTenant($user, $payment->booking->property);
    }
}

This works, but watch out for N+1 queries. Eager load the chain when you know you'll need it:

$payment = Payment::with('booking.property.tenant')->findOrFail($id);

Testing Authorisation

Test the boundaries, not just the happy path:

it('prevents users from updating properties in other tenants', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    $user = User::factory()->create();
    $user->tenants()->attach($tenantA, ['role' => 'admin']);
    $user->update(['current_tenant_id' => $tenantA->id]);

    $property = Property::factory()
        ->for($tenantB)
        ->create();

    $this->actingAs($user)
        ->put(route('properties.update', $property), [
            'name' => 'Hijacked Property',
        ])
        ->assertForbidden();
});

it('allows tenant admins to update any property within their tenant', function () {
    $tenant = Tenant::factory()->create();

    $admin = User::factory()->create(['current_tenant_id' => $tenant->id]);
    $admin->tenants()->attach($tenant, ['role' => 'admin']);

    $owner = User::factory()->create(['current_tenant_id' => $tenant->id]);
    $owner->tenants()->attach($tenant, ['role' => 'member']);

    $property = Property::factory()
        ->for($tenant)
        ->create(['owner_id' => $owner->id]);

    $this->actingAs($admin)
        ->put(route('properties.update', $property), [
            'name' => 'Updated by Admin',
        ])
        ->assertRedirect();
});

Test cross-tenant access. Test role escalation. Test that scoped queries don't leak. These aren't edge cases — they're the entire point of authorisation.

The Principle

Every authorisation check in a multi-tenant app should answer two questions: "Does this user have permission for this operation?" and "Does this resource belong to their tenant?" If you can't answer both with confidence, the request gets rejected.

It's tedious. It's repetitive. It's the only thing standing between you and a data breach disclosure.


I write about Laravel, AI tooling, and the realities of building software. More at stuartmason.co.uk.

Get the Friday email

What I shipped this week, what I learned, one useful thing.

No spam. Unsubscribe anytime. Privacy policy.