Session-Scoped Database Access: Tenant Isolation Beyond WHERE Clauses
Most multi-tenant applications enforce tenant isolation with a WHERE tenant_id = ? clause. It works until a bug skips it. There’s a stronger pattern: session-scoped credentials that are mathematically incapable of accessing another tenant’s data. Let’s look at how this works across cloud providers and databases.
The Core Pattern: Credential-Based Isolation
The idea is simple. Instead of trusting your application code to filter data correctly, you hand each request a set of throw-away credentials that can only access one tenant’s data. The enforcement happens at the infrastructure layer, not the application layer.
Here’s what this looks like on AWS using STS (Security Token Service):
def query_orders(self, tenant_id, scoped_policy):
sts = boto3.client('sts')
# assume a role with an inline policy that restricts to this tenant
tenant_credentials = sts.assume_role(
RoleArn=os.environ.get('IDENTITY_ROLE'),
RoleSessionName=tenant_id,
Policy=scoped_policy,
DurationSeconds=1000
)
# create a session with those short-lived credentials
session = boto3.Session(
aws_access_key_id=tenant_credentials['Credentials']['AccessKeyId'],
aws_secret_access_key=tenant_credentials['Credentials']['SecretAccessKey'],
aws_session_token=tenant_credentials['Credentials']['SessionToken']
)
# this client can ONLY access tenant's data
ddb = session.client('dynamodb')
The key insight is the Policy parameter. This is an inline session policy attached at the moment of assuming the role. It can only restrict permissions, never expand them. So even if the base role has full DynamoDB access, the scoped session can only reach one tenant’s rows.
A scoped policy for DynamoDB might look like:
{
"Statement": [{
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:Query"],
"Resource": "arn:aws:dynamodb:us-east-1:123:table/Orders",
"Condition": {
"ForAllValues:StringEquals": {
"dynamodb:LeadingKeys": ["tenant-abc-*"]
}
}
}]
}
The credentials expire after DurationSeconds, the RoleSessionName shows up in CloudTrail for auditability, and a bug in your application code cannot bypass this. The credentials are the enforcement boundary.
Where This Pattern Shines (and Where It Doesn’t)
Session scoping is only as strong as how deeply IAM is integrated into the resource’s access control. Services built from the ground up on IAM get deep integration. Services that predate the cloud get a shallower layer.
| Service | IAM as Enforcement Boundary | Session Scoping Strength |
|---|---|---|
| DynamoDB | Yes | Strong – row level via partition key conditions |
| S3 | Yes | Strong – prefix level via resource ARNs |
| SQS / SNS | Yes | Strong – queue/topic level |
| RDS (IAM auth) | Partial | Gets you to the DB instance, not inside it |
| ElastiCache | No | Not applicable |
| EC2-hosted DBs | No | Not applicable |
For S3, scoping to a tenant prefix is straightforward:
{
"Resource": "arn:aws:s3:::my-bucket/tenant-abc/*"
}
The credentials literally cannot read or write outside that prefix.
For RDS, however, the story splits into two layers. IAM can control which instance you connect to, but once you’re inside Postgres or MySQL, tenant isolation is the database engine’s job.
Relational Databases: The Two-Layer Problem
AWS supports IAM database authentication for RDS. You can replace static passwords with short-lived tokens:
rds_client = boto3.client('rds')
token = rds_client.generate_db_auth_token(
DBHostname=db_host,
Port=5432,
DBUsername='app_user'
)
conn = psycopg2.connect(
host=db_host,
user='app_user',
password=token, # 15-minute expiry
database='mydb',
sslmode='require' # mandatory for IAM auth
)
This gets you to the door. Inside the database, you need a second isolation mechanism.
Pure Postgres Tenant Isolation
Regardless of cloud provider, Postgres gives you three strategies. Each trades isolation strength for operational scalability.
Strategy 1: Separate Database Per Tenant
The strongest boundary. Each tenant is a fully separate Postgres database:
postgres server
|- tenant_abc_db
|- tenant_xyz_db
+- tenant_foo_db
Complete isolation, easy per-tenant backup and restore, no RLS complexity. But connection pooling becomes painful, schema migrations must run N times, and it gets expensive at scale. Best for low tenant counts with strict isolation requirements.
Strategy 2: Separate Schema Per Tenant
One database, but each tenant gets their own Postgres schema:
CREATE SCHEMA tenant_abc;
CREATE TABLE tenant_abc.orders (...);
CREATE SCHEMA tenant_xyz;
CREATE TABLE tenant_xyz.orders (...);
-- restrict user to their schema
ALTER ROLE tenant_abc_user SET search_path = tenant_abc;
Still strong isolation, shared connection pool, one database to manage. Migrations still need to run per schema, and it gets unwieldy past a few hundred tenants.
Strategy 3: Shared Tables + Row Level Security
The most scalable approach. One table for all tenants, Postgres enforces row-level isolation:
CREATE TABLE orders (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
status TEXT,
amount NUMERIC
);
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant'));
Then in your application, set the tenant context per request:
cursor.execute(
"SET LOCAL app.current_tenant = %s", (tenant_id,)
)
# every query on this connection is now filtered automatically
cursor.execute("SELECT * FROM orders") # only sees this tenant's rows
No WHERE clause needed. Postgres enforces it at the engine level.
The Connection Pool Footgun
This is where most RLS bugs happen in practice. Connection pools (PgBouncer, etc.) reuse connections across requests. If you SET a tenant variable on a pooled connection and don’t reset it, the next request inherits the previous tenant’s context.
The critical distinction:
| Scope | Behavior | Risk |
|---|---|---|
SET app.current_tenant |
Entire connection session | Dangerous if connection is reused without resetting |
SET LOCAL app.current_tenant |
Current transaction only | Safer – resets on COMMIT/ROLLBACK |
The safe pattern is always SET LOCAL inside a transaction:
with conn.transaction():
cursor.execute("SET LOCAL app.current_tenant = %s", (tenant_id,))
cursor.execute("SELECT * FROM orders")
# tenant variable cleared automatically when transaction ends
If using PgBouncer, configure it in transaction pooling mode so each transaction gets a fresh connection context and session variables don’t leak.
Comparison
| Approach | Isolation Strength | Scalability | Operational Complexity |
|---|---|---|---|
| Separate DB | Strongest | Low | High |
| Separate schema | Strong | Medium | Medium |
| RLS shared tables | Good | High | Low (but subtle footguns) |
For most SaaS products at scale, RLS with shared tables is the pragmatic choice.
Cross-Cloud: GCP and Azure
Google Cloud
GCP’s equivalent to STS is service account impersonation:
from google.auth import impersonated_credentials
target_credentials = impersonated_credentials.Credentials(
source_credentials=base_credentials,
target_principal=f'tenant-abc@my-project.iam.gserviceaccount.com',
target_scopes=['https://www.googleapis.com/auth/cloud-platform'],
lifetime=3600
)
GCP has IAM Conditions for scoping, which map roughly to AWS session policies:
condition:
expression: >
resource.name.startsWith(
'projects/_/buckets/my-bucket/objects/tenant-abc/'
)
Works well for GCS. For relational databases (Cloud SQL with Postgres), same RLS patterns apply. One advantage: Cloud Spanner has fine-grained access control at the table and column level natively.
Azure
Azure uses Managed Identities / Service Principals with Azure AD tokens. The key gap: Azure has no equivalent to inline session policies. You cannot dynamically narrow permissions at call time.
This forces you toward:
- A separate Managed Identity per tenant (operationally expensive)
- Shared Access Signatures (SAS) for Blob Storage (token-based, path-scoped, but only for storage)
- Application-level enforcement for everything else
For Azure SQL, tenant isolation uses SQL Server’s Row-Level Security, which works similarly to Postgres RLS:
CREATE SECURITY POLICY tenant_filter
ADD FILTER PREDICATE dbo.fn_tenant_predicate(tenant_id)
ON dbo.orders;
The Cross-Cloud Summary
| Feature | AWS | GCP | Azure |
|---|---|---|---|
| Temporary credentials | STS | Token exchange | AD tokens |
| Inline session narrowing | Strong | Conditions (less dynamic) | No equivalent |
| Object storage scoping | S3 + policy | GCS + conditions | SAS tokens only |
| NoSQL native IAM scoping | DynamoDB | Firestore (limited) | CosmosDB (limited) |
| Relational DB IAM auth | RDS IAM auth | Cloud SQL IAM auth | Azure AD SQL auth |
AWS has the most mature implementation, largely because DynamoDB was designed with IAM as its only auth mechanism. GCP is close but less dynamic. Azure has the biggest gap due to the absence of inline session policies.
The Bottom Line
The strongest tenant isolation doesn’t rely on your application remembering to add a WHERE clause. It uses credentials that physically cannot access another tenant’s data.
For IAM-native services like DynamoDB and S3, session-scoped credentials give you that guarantee. For relational databases, you need a second layer – Postgres RLS is the practical choice, but watch the connection pooling interaction. And across cloud providers, the depth of this pattern depends entirely on how deeply IAM is woven into each service’s access control.
The core principle: push tenant isolation as close to the enforcement boundary as possible. Application-level filtering is a convenience. Infrastructure-level filtering is a guarantee.