Skip to content

Session scoping

When using a multi-database setup, it’s critical to properly separate user sessions.

If your sessions are stored centrally, a user may be able to access another user’s data in another tenant:

  1. User with ID 1 in tenant1.yourapp.com changes the session cookie domain to tenant2.yourapp.com
  2. The user then visits tenant2.yourapp.com
  3. The user will be logged in as user with ID 1

The reason why this happens is that sessions store user IDs, and you may have different users in different tenants with the same IDs.

This is only an issue if both user IDs and tenant domains/IDs are enumerable or otherwise non-secret, but it’s still worth taking precautions against regardless of your setup.

If you use the DatabaseTenancyBootstrapper, using the database session driver will lead to sessions being automatically scoped.

Only thing to note here is that due to some details of how the database session driver works, you may need to also enable the DatabaseSessionBootstrapper:

config/tenancy.php
'bootstrappers' => [
Bootstrappers\DatabaseSessionBootstrapper::class,
],

Otherwise, you may get confusing exceptions when switching between tenants (specifically: Call to a member function prepare() on null). That said, the scoping is handled entirely by the DatabaseTenancyBootstrapper.

There is one exception to the above, and that’s when you set a SESSION_CONNECTION. In that case, the session bootstrapper is necessary to properly separate tenant sessions.

Database session driver with RLS (single-database)

Section titled “Database session driver with RLS (single-database)”

With Postgres RLS, all tenants share a single database, so there’s a single sessions table. Unlike multi-database tenancy where each tenant has its own sessions table, this shared table has to be scoped by RLS just like any other tenant table. This matters especially with path identification, where all tenants share one domain (and therefore one session cookie). Without scoping, one tenant’s session row is readable in another tenant’s context.

In that case, to scope the sessions properly:

1. Give the sessions table a nullable tenant_id column with a foreign key to tenants. The foreign key is what makes the tenants:rls command generate a policy for the table. Since there’s no sessions model, we can’t use the automatic tenant filling trait or listener. Instead, we set the tenant_id to the current value of the my.current_tenant session variable. In tenant context that’s the current tenant’s key, and in the central context (where the variable isn’t set) it’s null:

database/migrations/2026_09_01_000000_add_tenant_id_to_sessions_table.php
public function up(): void
{
Schema::table('sessions', function (Blueprint $table) {
$table->string('tenant_id')->nullable();
$table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnUpdate()->cascadeOnDelete();
});
DB::statement("ALTER TABLE sessions ALTER COLUMN tenant_id SET DEFAULT current_setting('my.current_tenant', true)");
}
public function down(): void
{
// A column can't be dropped while an RLS policy depends on it, so drop the policy first
// (see the migrations section on the RLS page). Dropping the column then drops its FK automatically.
tenancy()->dropRLSPolicies('sessions');
Schema::table('sessions', function (Blueprint $table) {
$table->dropColumn('tenant_id');
});
}

Make sure this migration runs after the create_tenants_table migration, otherwise the foreign key can’t be created during migrate:fresh.

2. If your central user doesn’t have BYPASSRLS, disable forceRls. The sessions table is accessed in both the central and tenant contexts, which makes it a common place to run into this problem. See the relevant section in the RLS docs for detailed instructions.

3. Enable the DatabaseSessionBootstrapper (after PostgresRLSBootstrapper):

config/tenancy.php
'bootstrappers' => [
Bootstrappers\PostgresRLSBootstrapper::class,
Bootstrappers\DatabaseSessionBootstrapper::class,
],

This is required with the database session driver. It makes the driver use the tenant connection in the tenant context, so session records get the correct tenant_id and are scoped to the tenant. Without it, sessions can end up with a null tenant_id, which RLS then hides from the tenant (the typical issue would be not being able to stay logged in).

Finally, run php artisan tenants:rls (as after any schema change) so the policy for the sessions table gets created.

Similar to above, if you store your sessions in Redis, simply enabling the RedisTenancyBootstrapper and configuring its prefixed connections will lead to sessions being properly scoped:

config/tenancy.php
'bootstrappers' => [
Bootstrappers\RedisTenancyBootstrapper::class,
],
'redis' => [
'prefix_base' => 'tenant',
'prefixed_connections' => [
'default',
],
],

In this case, default is included in the prefixed_connections since it’s the Redis connection used by the session driver (if your SESSION_CONNECTION/session.connection is different, make sure to include it in the prefixed connections).

Many of Laravel’s session drivers leverage Laravel’s cache logic for storing sessions. Namely:

  • apc
  • dynamodb
  • memcached
  • redis

As such, these sessions can be scoped using the CacheTenancyBootstrapper. To enable this behavior, set tenancy.cache.scope_sessions to true:

config/tenancy.php
'cache' => [
'scope_sessions' => true,
],

The FilesystemTenancyBootstrapper takes care of scoping file-based sessions out of the box. Simply make sure tenancy.filesystem.scope_sessions is set to true:

config/tenancy.php
'filesystem' => [
'scope_sessions' => true,
],

Using a middleware to prevent session forgery

Section titled “Using a middleware to prevent session forgery”

Alternatively (or additionally), you may use the Stancl\Tenancy\Middleware\ScopeSessions middleware on your tenant routes to make sure that any attempts to manipulate the session will result in a 403 unauthorized response.

This will work with all storage drivers, but only assuming you use a domain per tenant — each tenant then has its own session cookie, so legitimate visits never carry another tenant’s session, and a mismatch only happens on actual forgery.

If you use path identification, all tenants share one domain and therefore one session cookie. For a legitimate tenant switch to produce a fresh session (rather than a 403 from ScopeSessions), the session lookup has to miss in the other tenant’s context. That requires the session store to be separated or scoped per tenant:

  • Multi-database: store sessions in the database — each tenant has its own sessions table, so the shared cookie’s session ID isn’t found in the other tenant’s database.
  • Single-database (RLS): scope the sessions table via RLS, as described in the Database session driver with RLS section, so the other tenant’s rows are not visible.
  • Other scoped store: file store with tenancy.filesystem.scope_sessions (and the respective bootstrapper) enabled, scoped Redis, …

With a shared, unscoped store, the other tenant reads the existing session, sees the incorrect tenant key, and gets a 403.

Since sessions use user IDs, you can also solve this by using non-enumerable, globally unique IDs like UUIDs.

Alternatively, you can use a custom user provider to use a different column than the primary key for sessions. That column would still need to be globally unique.