Session scoping
When using a multi-database setup, it’s critical to properly separate user sessions.
Session forgery
Section titled “Session forgery”If your sessions are stored centrally, a user may be able to access another user’s data in another tenant:
- User with ID 1 in
tenant1.yourapp.comchanges the session cookie domain totenant2.yourapp.com - The user then visits
tenant2.yourapp.com - 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.
Using a tenant-scoped session driver
Section titled “Using a tenant-scoped session driver”Database session driver
Section titled “Database session driver”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:
'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:
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):
'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.
Redis session driver
Section titled “Redis session driver”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:
'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).
Cache-based session drivers
Section titled “Cache-based session drivers”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:
'cache' => [ 'scope_sessions' => true,],File session driver
Section titled “File session driver”The FilesystemTenancyBootstrapper takes care of scoping file-based sessions out of the box. Simply make sure tenancy.filesystem.scope_sessions is set to true:
'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
sessionstable, so the shared cookie’s session ID isn’t found in the other tenant’s database. - Single-database (RLS): scope the
sessionstable 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.
Using globally unique user IDs
Section titled “Using globally unique user IDs”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.