Data Privacy in Customer Onboarding

Strategies for protecting customer data during the onboarding process, covering data minimization, encryption, consent management, and regulatory compliance.

business9 min readBy Klivvr Engineering
Share:

Customer onboarding is the moment when a financial application collects the most sensitive personal data it will ever handle. Full legal names, dates of birth, government-issued identification numbers, photographs of identity documents, biometric data, addresses, and financial information all flow into the system during a concentrated period. How that data is collected, processed, stored, and eventually deleted defines the company's privacy posture.

At Klivvr, Oasis is the custodian of customer identity data. Privacy is not a feature we added after the fact; it is an architectural constraint that influenced every design decision. This article describes the privacy strategies embedded in Oasis, from data minimization and purpose limitation to encryption, consent management, and the right to be forgotten.

Data Minimization: Collect Only What You Need

The most effective privacy strategy is also the simplest: do not collect data you do not need. Every field in the onboarding form should map to a specific regulatory requirement or business function. If it does not, it should not be there.

interface DataCollectionPolicy {
  field: string;
  purpose: string;
  legalBasis: LegalBasis;
  retentionDays: number;
  requiredForLevel: VerificationLevel;
  sensitivityClass: "standard" | "sensitive" | "special_category";
}
 
type LegalBasis =
  | "legal_obligation"
  | "contractual_necessity"
  | "legitimate_interest"
  | "consent";
 
const COLLECTION_POLICIES: DataCollectionPolicy[] = [
  {
    field: "email",
    purpose: "Account creation and communication",
    legalBasis: "contractual_necessity",
    retentionDays: -1,
    requiredForLevel: VerificationLevel.NONE,
    sensitivityClass: "standard",
  },
  {
    field: "full_name",
    purpose: "KYC identification requirement",
    legalBasis: "legal_obligation",
    retentionDays: 2555,
    requiredForLevel: VerificationLevel.BASIC,
    sensitivityClass: "standard",
  },
  {
    field: "date_of_birth",
    purpose: "Age verification and identity matching",
    legalBasis: "legal_obligation",
    retentionDays: 2555,
    requiredForLevel: VerificationLevel.BASIC,
    sensitivityClass: "sensitive",
  },
  {
    field: "national_id_number",
    purpose: "Government-mandated identity verification",
    legalBasis: "legal_obligation",
    retentionDays: 2555,
    requiredForLevel: VerificationLevel.STANDARD,
    sensitivityClass: "sensitive",
  },
  {
    field: "selfie_image",
    purpose: "Biometric identity verification",
    legalBasis: "consent",
    retentionDays: 1,
    requiredForLevel: VerificationLevel.ENHANCED,
    sensitivityClass: "special_category",
  },
  {
    field: "id_document_image",
    purpose: "Document verification",
    legalBasis: "legal_obligation",
    retentionDays: 30,
    requiredForLevel: VerificationLevel.STANDARD,
    sensitivityClass: "special_category",
  },
];
 
class DataCollectionValidator {
  private policies: Map<string, DataCollectionPolicy>;
 
  constructor(policies: DataCollectionPolicy[]) {
    this.policies = new Map(policies.map((p) => [p.field, p]));
  }
 
  validateCollection(
    fields: string[],
    verificationLevel: VerificationLevel
  ): ValidationResult {
    const unnecessaryFields: string[] = [];
    const missingPolicies: string[] = [];
 
    for (const field of fields) {
      const policy = this.policies.get(field);
 
      if (!policy) {
        missingPolicies.push(field);
        continue;
      }
 
      if (policy.requiredForLevel > verificationLevel) {
        unnecessaryFields.push(field);
      }
    }
 
    return {
      valid: unnecessaryFields.length === 0 && missingPolicies.length === 0,
      unnecessaryFields,
      missingPolicies,
    };
  }
}

Each data field has a defined purpose, legal basis, retention period, and sensitivity classification. The collection validator ensures that the onboarding flow only requests data appropriate for the customer's verification level. A customer who only needs basic access should never be asked for biometric data, because the legal basis for collecting it does not apply to their situation.

For data processed under the consent legal basis (as opposed to legal obligation or contractual necessity), explicit, informed, and revocable consent is required. The consent management system tracks what the customer has consented to, when, and provides mechanisms for withdrawal.

interface ConsentRecord {
  id: string;
  customerId: string;
  consentType: string;
  version: string;
  grantedAt: Date;
  revokedAt: Date | null;
  ipAddress: string;
  userAgent: string;
  consentText: string;
}
 
class ConsentManager {
  constructor(
    private consentStore: ConsentStore,
    private eventBus: EventBus
  ) {}
 
  async grantConsent(
    customerId: string,
    consentType: string,
    version: string,
    context: RequestContext
  ): Promise<ConsentRecord> {
    const consentText = await this.getConsentText(consentType, version);
 
    const record: ConsentRecord = {
      id: generateUUID(),
      customerId,
      consentType,
      version,
      grantedAt: new Date(),
      revokedAt: null,
      ipAddress: context.ipAddress,
      userAgent: context.userAgent,
      consentText,
    };
 
    await this.consentStore.save(record);
 
    await this.eventBus.publish("consent.granted", {
      customerId,
      consentType,
      version,
    });
 
    return record;
  }
 
  async revokeConsent(
    customerId: string,
    consentType: string
  ): Promise<void> {
    const activeConsent = await this.consentStore.findActive(
      customerId,
      consentType
    );
 
    if (!activeConsent) {
      throw new NoActiveConsentError(customerId, consentType);
    }
 
    activeConsent.revokedAt = new Date();
    await this.consentStore.save(activeConsent);
 
    await this.eventBus.publish("consent.revoked", {
      customerId,
      consentType,
      previousVersion: activeConsent.version,
    });
  }
 
  async hasActiveConsent(
    customerId: string,
    consentType: string
  ): Promise<boolean> {
    const consent = await this.consentStore.findActive(
      customerId,
      consentType
    );
    return consent !== null && consent.revokedAt === null;
  }
 
  async getConsentHistory(customerId: string): Promise<ConsentRecord[]> {
    return this.consentStore.findAllByCustomer(customerId);
  }
 
  private async getConsentText(
    type: string,
    version: string
  ): Promise<string> {
    return this.consentStore.getConsentTextByVersion(type, version);
  }
}

Every consent record stores the full text of what the customer agreed to at the time they agreed to it. This is crucial because consent text evolves over time, and regulators expect you to prove what the customer actually saw and accepted. Storing a reference to the "current" consent text is not sufficient; the exact text must be preserved.

Encryption at Every Layer

Customer data is encrypted at rest and in transit. Beyond standard TLS for transport and disk encryption for storage, Oasis implements application-level encryption for sensitive fields using a per-customer key hierarchy.

interface EncryptionKeyHierarchy {
  masterKey: string;
  customerKeys: Map<string, CustomerKeySet>;
}
 
interface CustomerKeySet {
  customerId: string;
  dataEncryptionKey: Buffer;
  keyVersion: number;
  createdAt: Date;
  rotatedAt: Date | null;
}
 
class FieldLevelEncryption {
  constructor(private keyManager: KeyManagementService) {}
 
  async encryptSensitiveFields(
    customerId: string,
    data: Record<string, unknown>,
    sensitiveFields: string[]
  ): Promise<Record<string, unknown>> {
    const key = await this.keyManager.getOrCreateCustomerKey(customerId);
    const result = { ...data };
 
    for (const field of sensitiveFields) {
      if (result[field] !== undefined && result[field] !== null) {
        const plaintext = String(result[field]);
        result[field] = await this.encrypt(plaintext, key.dataEncryptionKey);
      }
    }
 
    return result;
  }
 
  async decryptSensitiveFields(
    customerId: string,
    data: Record<string, unknown>,
    sensitiveFields: string[]
  ): Promise<Record<string, unknown>> {
    const key = await this.keyManager.getOrCreateCustomerKey(customerId);
    const result = { ...data };
 
    for (const field of sensitiveFields) {
      if (result[field] !== undefined && result[field] !== null) {
        const ciphertext = String(result[field]);
        result[field] = await this.decrypt(ciphertext, key.dataEncryptionKey);
      }
    }
 
    return result;
  }
 
  private async encrypt(plaintext: string, key: Buffer): Promise<string> {
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
    const encrypted = Buffer.concat([
      cipher.update(plaintext, "utf8"),
      cipher.final(),
    ]);
    const authTag = cipher.getAuthTag();
 
    return Buffer.concat([iv, authTag, encrypted]).toString("base64");
  }
 
  private async decrypt(ciphertext: string, key: Buffer): Promise<string> {
    const data = Buffer.from(ciphertext, "base64");
    const iv = data.subarray(0, 12);
    const authTag = data.subarray(12, 28);
    const encrypted = data.subarray(28);
 
    const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
    decipher.setAuthTag(authTag);
 
    return Buffer.concat([
      decipher.update(encrypted),
      decipher.final(),
    ]).toString("utf8");
  }
}

Field-level encryption means that even if the database is compromised, sensitive fields like national ID numbers and dates of birth remain encrypted. The per-customer key hierarchy ensures that a key compromise for one customer does not expose another customer's data.

The Right to Erasure

Under GDPR and similar regulations, customers have the right to request deletion of their personal data. In a financial services context, this right is balanced against legal obligations to retain certain records for AML compliance. The erasure system must navigate these competing requirements.

interface ErasureRequest {
  id: string;
  customerId: string;
  requestedAt: Date;
  status: "pending" | "processing" | "completed" | "partially_completed";
  retainedFields: RetainedField[];
  deletedFields: string[];
  completedAt: Date | null;
}
 
interface RetainedField {
  field: string;
  reason: string;
  legalBasis: string;
  retainUntil: Date;
}
 
class ErasureService {
  constructor(
    private customerStore: CustomerStore,
    private collectionPolicies: DataCollectionPolicy[],
    private consentManager: ConsentManager,
    private auditLogger: AuditLogger
  ) {}
 
  async processErasureRequest(customerId: string): Promise<ErasureRequest> {
    const customer = await this.customerStore.findById(customerId);
    const request: ErasureRequest = {
      id: generateUUID(),
      customerId,
      requestedAt: new Date(),
      status: "processing",
      retainedFields: [],
      deletedFields: [],
      completedAt: null,
    };
 
    for (const policy of this.collectionPolicies) {
      if (policy.legalBasis === "legal_obligation") {
        const retainUntil = new Date(
          customer.createdAt.getTime() +
            policy.retentionDays * 24 * 60 * 60 * 1000
        );
 
        if (retainUntil > new Date()) {
          request.retainedFields.push({
            field: policy.field,
            reason: "Legal retention obligation",
            legalBasis: policy.legalBasis,
            retainUntil,
          });
          continue;
        }
      }
 
      await this.deleteField(customerId, policy.field);
      request.deletedFields.push(policy.field);
    }
 
    await this.consentManager.revokeConsent(customerId, "biometric_processing");
    await this.consentManager.revokeConsent(customerId, "marketing");
 
    request.status =
      request.retainedFields.length > 0 ? "partially_completed" : "completed";
    request.completedAt = new Date();
 
    await this.auditLogger.log("erasure_request_processed", {
      requestId: request.id,
      customerId,
      deletedFieldCount: request.deletedFields.length,
      retainedFieldCount: request.retainedFields.length,
    });
 
    return request;
  }
 
  private async deleteField(
    customerId: string,
    field: string
  ): Promise<void> {
    await this.customerStore.nullifyField(customerId, field);
  }
}

The erasure process evaluates each field against its retention policy. Fields held under legal obligation are retained until the obligation expires, but the customer is informed exactly which fields are retained, why, and until when. Fields held under consent are deleted immediately upon erasure request. This granular approach satisfies both the customer's privacy rights and the company's legal obligations.

Access Controls and Data Governance

Privacy is not just about external threats. Internal access to customer data must be tightly controlled. Not every employee needs access to every field, and every access should be logged.

interface DataAccessPolicy {
  role: string;
  allowedFields: string[];
  requiresJustification: boolean;
  maxRecordsPerQuery: number;
}
 
const ACCESS_POLICIES: DataAccessPolicy[] = [
  {
    role: "customer_support",
    allowedFields: ["full_name", "email", "verification_status"],
    requiresJustification: false,
    maxRecordsPerQuery: 1,
  },
  {
    role: "compliance_officer",
    allowedFields: ["full_name", "email", "date_of_birth", "nationality", "risk_score", "verification_status"],
    requiresJustification: true,
    maxRecordsPerQuery: 10,
  },
  {
    role: "engineering_on_call",
    allowedFields: ["customer_id", "verification_status", "error_logs"],
    requiresJustification: true,
    maxRecordsPerQuery: 5,
  },
];
 
class DataAccessGateway {
  constructor(
    private accessPolicies: DataAccessPolicy[],
    private accessLogger: AccessLogger,
    private customerStore: CustomerStore
  ) {}
 
  async queryCustomerData(
    requestor: InternalUser,
    customerId: string,
    requestedFields: string[],
    justification?: string
  ): Promise<Record<string, unknown>> {
    const policy = this.accessPolicies.find((p) => p.role === requestor.role);
 
    if (!policy) {
      throw new AccessDeniedError(`No access policy for role: ${requestor.role}`);
    }
 
    if (policy.requiresJustification && !justification) {
      throw new JustificationRequiredError(requestor.role);
    }
 
    const allowedRequested = requestedFields.filter((f) =>
      policy.allowedFields.includes(f)
    );
    const deniedFields = requestedFields.filter(
      (f) => !policy.allowedFields.includes(f)
    );
 
    if (deniedFields.length > 0) {
      await this.accessLogger.logDeniedAccess(
        requestor,
        customerId,
        deniedFields
      );
    }
 
    const data = await this.customerStore.getFields(
      customerId,
      allowedRequested
    );
 
    await this.accessLogger.logAccess({
      requestor: requestor.id,
      role: requestor.role,
      customerId,
      fieldsAccessed: allowedRequested,
      fieldsDenied: deniedFields,
      justification: justification || null,
      timestamp: new Date(),
    });
 
    return data;
  }
}

The data access gateway enforces role-based field-level access control. Customer support can see a customer's name and email but not their national ID number. Compliance officers can see more but must provide a justification. Engineers can see operational data but not personal details. Every access, whether granted or denied, is logged for audit purposes.

Conclusion

Data privacy in customer onboarding is not a checkbox exercise. It is a continuous practice that must be embedded in the architecture, the development process, and the organizational culture. The strategies described here, data minimization, explicit consent management, field-level encryption, principled erasure, and role-based access control, represent the technical foundation of a privacy-respecting onboarding system.

The most important insight is that privacy and user experience are not in conflict. Collecting less data means shorter forms and faster onboarding. Transparent consent builds trust. Encryption and access controls protect against breaches that would destroy customer confidence. A privacy-first approach to customer onboarding is not just a regulatory requirement; it is a competitive advantage.

Related Articles

technical

Integrating Third-Party Verification APIs

Practical strategies for integrating third-party identity verification APIs, covering adapter patterns, error handling, rate limiting, and provider management in TypeScript.

10 min read
technical

Risk-Based Authentication Strategies

Implementing risk-based authentication that adapts security requirements to the threat level of each request, balancing security with user experience in TypeScript.

9 min read