UnlockOS Developers
← Back to blog
🔐

Building Secure Multi-Tenant Systems: RLS & Auth Hardening

May 11, 2026May 17, 2026
6 min
18 commits
Depth 8/10
securityauthenticationrow-level-securitymulti-tenant

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:

  1. Database-level security through properly configured Row-Level Security policies
  2. Stateless authentication to prevent session contamination between requests
  3. Strategic indexing to maintain performance while enforcing security policies
  4. Environment-aware configuration that maintains security across deployment contexts
  5. 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.

Key Insights

1
Security

Policy-First RLS Implementation

Always create Row-Level Security policies before enabling RLS to prevent dangerous access windows

2
Authentication

Stateless Client Architecture

Prevent auth state contamination by disabling session persistence in stateless contexts

3
Performance

Security-Optimized Indexing

Strategic database indexes ensure security queries don't compromise system responsiveness

4
Testing

Security Boundary Validation

E2E tests must validate tenant isolation and audit unauthorized access attempts