Building Secure Multi-Tenant Systems: Row-Level Security and Authentication Hardening
Introduction
In security-critical systems like smart lock management platforms, implementing robust multi-tenant security is paramount. Recent improvements to our authentication and data access patterns demonstrate key strategies for preventing data leakage and ensuring proper tenant isolation. This article explores critical security patterns including Row-Level Security (RLS), stateless authentication clients, and preventing cross-request contamination.
Row-Level Security: The Foundation of Multi-Tenant Data Protection
Row-Level Security provides database-level tenant isolation by automatically filtering data based on user context. Here's how we implement comprehensive RLS policies:
-- Create user context functions for policy enforcement
CREATE OR REPLACE FUNCTION current_user_is_admin()
RETURNS boolean AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid()
AND role = 'admin'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Apply RLS policies before enabling protection
CREATE POLICY "Admin access to checkin_configurations"
ON checkin_configurations
FOR ALL
TO authenticated
USING (current_user_is_admin());
-- Enable RLS only after policies are in place
ALTER TABLE checkin_configurations ENABLE ROW LEVEL SECURITY;The critical security principle here is policy-first activation: always create your RLS policies before enabling row-level security. This prevents the dangerous window where RLS is enabled but no policies exist, potentially blocking all access.
Preventing Authentication State Contamination
In multi-tenant systems, authentication state leakage between requests can lead to severe security vulnerabilities. Here's how to implement stateless authentication clients:
// Secure stateless client configuration
const createStatelessSupabaseClient = () => {
return createClient(supabaseUrl, supabaseKey, {
auth: {
// Critical: disable persistent sessions in stateless contexts
persistSession: false,
autoRefreshToken: false,
// Prevent auth state from persisting across requests
storage: {
getItem: () => null,
setItem: () => {},
removeItem: () => {}
}
}
});
};
// Proper error handling to prevent request contamination
async function handleAuthRequest(request: Request): Promise<Response> {
let requestBody: any;
try {
requestBody = await request.json();
} catch (error) {
// Critical: drain the request stream to prevent test cross-contamination
await request.text().catch(() => {});
return new Response('Invalid JSON', { status: 400 });
}
// Process with clean auth state
return processAuthenticatedRequest(requestBody);
}The key insight is request isolation: each request must start with a clean authentication state, especially in testing environments where request state can leak between test cases.
Database Optimization for Security Queries
Security policies often involve complex queries that can impact performance. Strategic indexing ensures security doesn't compromise system responsiveness:
-- Index organization relationships for efficient RLS queries
CREATE INDEX CONCURRENTLY idx_facilities_organization_id
ON facilities(organization_id);
-- Index user permissions for fast policy evaluation
CREATE INDEX CONCURRENTLY idx_user_permissions_lookup
ON user_permissions(user_id, resource_type, organization_id);
-- Compound index for multi-tenant filtering
CREATE INDEX CONCURRENTLY idx_checkin_configs_tenant_filter
ON checkin_configurations(organization_id, facility_id)
WHERE deleted_at IS NULL;Secure Data Filtering Patterns
Implementing proper soft-delete handling prevents exposure of archived sensitive data:
// Secure data filtering with proper type safety
interface SecureQueryOptions {
organizationId: string;
includeDeleted?: boolean;
userPermissions: UserPermission[];
}
class SecureDataAccess {
async getUnits(options: SecureQueryOptions): Promise<Unit[]> {
// Validate user has access to organization
this.validateOrganizationAccess(options.organizationId, options.userPermissions);
const query = this.db
.from('units')
.select('*')
.eq('organization_id', options.organizationId);
// Security-first: exclude soft-deleted by default
if (!options.includeDeleted) {
query.is('deleted_at', null);
}
const { data, error } = await query;
if (error) {
// Audit security-related query failures
await this.auditLog.logSecurityEvent({
type: 'DATA_ACCESS_FAILURE',
organizationId: options.organizationId,
error: error.message,
timestamp: new Date()
});
throw new SecurityError('Data access denied');
}
return data || [];
}
private validateOrganizationAccess(
orgId: string,
permissions: UserPermission[]
): void {
const hasAccess = permissions.some(
p => p.organizationId === orgId && p.resource === 'units'
);
if (!hasAccess) {
throw new UnauthorizedError(`Access denied to organization ${orgId}`);
}
}
}Environment-Based Security Configuration
Security configurations must adapt to different deployment environments while maintaining consistent protection:
// Environment-aware security configuration
interface SecurityConfig {
emailSender: string;
authDomain: string;
sessionTimeout: number;
auditLevel: 'minimal' | 'standard' | 'verbose';
}
const getSecurityConfig = (): SecurityConfig => {
const env = process.env.NODE_ENV;
const configs: Record<string, SecurityConfig> = {
production: {
emailSender: 'noreply@secure.unlockos.com',
authDomain: 'auth.unlockos.com',
sessionTimeout: 3600000, // 1 hour
auditLevel: 'verbose'
},
staging: {
emailSender: 'noreply@staging.unlockos.com',
authDomain: 'auth.staging.unlockos.com',
sessionTimeout: 7200000, // 2 hours
auditLevel: 'standard'
},
development: {
emailSender: 'noreply@dev.unlockos.com',
authDomain: 'localhost:3000',
sessionTimeout: 86400000, // 24 hours
auditLevel: 'minimal'
}
};
return configs[env] || configs.development;
};Testing Security Boundaries
End-to-end testing must validate security boundaries to ensure tenant isolation works in practice:
// Security-focused E2E test patterns
describe('Multi-tenant Security', () => {
test('should prevent cross-tenant data access', async () => {
// Setup: Create two separate tenant contexts
const tenant1Client = await createTenantClient('org-1');
const tenant2Client = await createTenantClient('org-2');
// Create data in tenant 1
const facility1 = await tenant1Client.createFacility({
name: 'Secure Building A',
organizationId: 'org-1'
});
// Attempt cross-tenant access with tenant 2 credentials
await expect(
tenant2Client.getFacility(facility1.id)
).rejects.toThrow('Access denied');
// Verify audit log captures the attempt
const auditLogs = await getAuditLogs({
type: 'UNAUTHORIZED_ACCESS_ATTEMPT',
resourceId: facility1.id
});
expect(auditLogs).toHaveLength(1);
expect(auditLogs[0].organizationId).toBe('org-2');
});
test('should handle authentication state isolation', async () => {
// Verify no session bleeding between test runs
const client1 = createStatelessSupabaseClient();
const client2 = createStatelessSupabaseClient();
await client1.auth.signInWithPassword({
email: 'user1@example.com',
password: 'secure123'
});
// Client 2 should not inherit client 1's auth state
const { data: user } = await client2.auth.getUser();
expect(user.user).toBeNull();
});
});Summary
Secure multi-tenant systems require defense-in-depth strategies that operate at multiple layers. The key principles demonstrated here include:
- Database-level security through properly configured Row-Level Security policies
- Stateless authentication to prevent session contamination between requests
- Strategic indexing to maintain performance while enforcing security policies
- Environment-aware configuration that maintains security across deployment contexts
- Comprehensive testing that validates security boundaries in realistic scenarios
By implementing these patterns, security-critical systems can maintain robust tenant isolation while providing the performance and reliability users expect from professional access management platforms.