Skip to main content

Data Handling

TxnCheck processes sensitive personal information (PII) including mobile numbers, PAN, Aadhaar, and UPI addresses. This guide covers best practices for handling this data securely and compliantly.

Data Classification

Data TypeClassificationStorage Guidance
Mobile NumberPIIHash or mask for storage
Full NamePIIStore if business need exists
PANSensitive PIINever store full PAN
Masked AadhaarPIIStore only last 4 digits
DOBPIIStore if required for KYC
UPI/VPAPIIMay store for transaction history
API Request IDInternalStore for audit trail

Data Minimization

Only request and store data you actually need:
// ✅ Good: Only request what you need
// If you only need blocklist check, don't do full-check
const result = await client.vpaChargebackCheck([vpa], { sync: true });

// ❌ Bad: Requesting more data than needed
const result = await client.fullCheck(mobile, { sync: true });
// Then ignoring kyc data you didn't need

Selective Data Storage

interface CustomerVerification {
  mobile: string;          // Full mobile for communication
  mobileHash: string;      // Hash for lookups
  name: string;            // From KYC
  kycVerified: boolean;    // Flag only, not full KYC data
  panLast4: string;        // Only last 4 digits
  verifiedAt: Date;
  // DON'T store: full PAN, full Aadhaar
}

function processVerificationResult(result: any): CustomerVerification {
  const kyc = result.result?.kycByMobile || {};
  
  return {
    mobile: result.mobile,
    mobileHash: hashMobile(result.mobile),
    name: kyc.fullName,
    kycVerified: Boolean(kyc.pan && kyc.maskedAadhaar),
    panLast4: kyc.pan ? kyc.pan.slice(-4) : '',
    verifiedAt: new Date(),
  };
}

Data Masking

Mobile Number Masking

function maskMobile(mobile: string): string {
  // +919876543210 -> +91****543210
  if (mobile.length < 10) return mobile;
  const prefix = mobile.slice(0, 3);
  const suffix = mobile.slice(-6);
  return `${prefix}****${suffix}`;
}

// For logging
console.log(`Verifying ${maskMobile(mobile)}`);

PAN Masking

function maskPAN(pan: string): string {
  // ABCDE1234F -> ****E1234F
  if (pan.length !== 10) return '**********';
  return '****' + pan.slice(4);
}

// For display to user
displayElement.textContent = maskPAN(kycData.pan);

VPA Masking

function maskVPA(vpa: string): string {
  // user.name@bank -> u***e@bank
  const [localPart, domain] = vpa.split('@');
  if (localPart.length <= 2) return vpa;
  const masked = localPart[0] + '***' + localPart.slice(-1);
  return `${masked}@${domain}`;
}

Hashing for Storage

Use cryptographic hashing when you need to look up data but don’t need the original value:
import crypto from 'crypto';

// Use a consistent salt for your application
const HASH_SALT = process.env.DATA_HASH_SALT!;

function hashMobile(mobile: string): string {
  return crypto
    .createHmac('sha256', HASH_SALT)
    .update(mobile.toLowerCase())
    .digest('hex');
}

function hashVPA(vpa: string): string {
  return crypto
    .createHmac('sha256', HASH_SALT)
    .update(vpa.toLowerCase())
    .digest('hex');
}

// Store hash, lookup by hash
const customer = await db.customer.findFirst({
  where: { mobileHash: hashMobile(inputMobile) },
});

Encryption at Rest

For data that must be stored in recoverable form:
import crypto from 'crypto';

const ENCRYPTION_KEY = Buffer.from(process.env.DATA_ENCRYPTION_KEY!, 'hex'); // 32 bytes
const IV_LENGTH = 16;

function encrypt(text: string): string {
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
  
  let encrypted = cipher.update(text, 'utf8', 'hex');
  encrypted += cipher.final('hex');
  
  const authTag = cipher.getAuthTag();
  
  // Return iv:authTag:encrypted
  return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}

function decrypt(encrypted: string): string {
  const [ivHex, authTagHex, encryptedText] = encrypted.split(':');
  
  const iv = Buffer.from(ivHex, 'hex');
  const authTag = Buffer.from(authTagHex, 'hex');
  
  const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
  decipher.setAuthTag(authTag);
  
  let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  
  return decrypted;
}

// Usage
const encryptedMobile = encrypt(mobile);
// Store encryptedMobile in database

Secure Logging

What to Log

// ✅ Good: Log request metadata, not data
logger.info('Verification request', {
  requestId: result.requestId,
  method: 'upi-by-mobile',
  status: result.status,
  mobile: maskMobile(mobile), // Masked
  processingTimeMs: endTime - startTime,
  timestamp: new Date().toISOString(),
});

What NOT to Log

// ❌ Bad: Logging sensitive data
logger.info('KYC Result', {
  mobile: mobile, // Full mobile
  pan: kycData.pan, // Full PAN!
  aadhaar: kycData.maskedAadhaar, // Even masked Aadhaar
  result: JSON.stringify(result), // Full response
});

Log Sanitization Middleware

function sanitizeLogData(data: any): any {
  const sensitiveFields = ['mobile', 'pan', 'aadhaar', 'dob', 'vpa'];
  const sanitized = { ...data };
  
  for (const field of sensitiveFields) {
    if (sanitized[field]) {
      sanitized[field] = '[REDACTED]';
    }
  }
  
  // Deep sanitize nested objects
  if (sanitized.result) {
    sanitized.result = '[REDACTED]';
  }
  
  return sanitized;
}

// Use in logger
logger.info('API call', sanitizeLogData(logData));

Data Retention

Retention Policies

Data TypeRetention PeriodJustification
API Request Logs90 daysDebugging and support
Verification Results7 daysDispute resolution
Customer KYC FlagAccount lifetimeCompliance requirement
Full KYC DataNot storedData minimization
Audit Logs7 yearsRegulatory compliance

Implementing Retention

// Scheduled job to purge old data
async function purgeOldData() {
  const retentionDays = {
    verificationResults: 7,
    apiLogs: 90,
  };
  
  // Delete old verification results
  await db.verificationResult.deleteMany({
    where: {
      createdAt: {
        lt: new Date(Date.now() - retentionDays.verificationResults * 24 * 60 * 60 * 1000),
      },
    },
  });
  
  // Delete old API logs
  await db.apiLog.deleteMany({
    where: {
      timestamp: {
        lt: new Date(Date.now() - retentionDays.apiLogs * 24 * 60 * 60 * 1000),
      },
    },
  });
}

// Run daily
cron.schedule('0 3 * * *', purgeOldData);

Data Access Controls

Role-Based Access

enum DataAccessRole {
  VIEWER = 'viewer',         // Can see masked data
  OPERATOR = 'operator',     // Can trigger verifications
  ADMIN = 'admin',           // Can see full data (with audit)
  COMPLIANCE = 'compliance', // Can export for audits
}

function canAccessFullData(role: DataAccessRole): boolean {
  return role === DataAccessRole.ADMIN || role === DataAccessRole.COMPLIANCE;
}

// API endpoint
app.get('/customers/:id', authorize(), async (req, res) => {
  const customer = await getCustomer(req.params.id);
  
  if (canAccessFullData(req.user.role)) {
    // Log access for audit
    await logDataAccess(req.user.id, customer.id, 'full');
    return res.json(customer);
  }
  
  // Return masked data for regular users
  return res.json(maskCustomerData(customer));
});

Audit Trail

interface DataAccessLog {
  userId: string;
  action: 'view' | 'export' | 'verify' | 'delete';
  dataType: 'customer' | 'verification' | 'report';
  dataId: string;
  accessLevel: 'masked' | 'full';
  ipAddress: string;
  userAgent: string;
  timestamp: Date;
}

async function logDataAccess(log: DataAccessLog) {
  await db.dataAccessLog.create({ data: log });
  
  // Alert on sensitive access
  if (log.accessLevel === 'full') {
    await alertSecurityTeam({
      message: `Full data access by ${log.userId}`,
      ...log,
    });
  }
}

Database Security

Encryption in Database

-- PostgreSQL with pgcrypto
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Encrypted column
ALTER TABLE customers ADD COLUMN mobile_encrypted BYTEA;

-- Encrypt on insert
INSERT INTO customers (mobile_encrypted) 
VALUES (pgp_sym_encrypt('9876543210', 'encryption_key'));

-- Decrypt on select (requires key)
SELECT pgp_sym_decrypt(mobile_encrypted, 'encryption_key') 
FROM customers WHERE id = 1;

Field-Level Encryption with ORM

// Using TypeORM with custom transformer
import { ValueTransformer } from 'typeorm';

const encryptedTransformer: ValueTransformer = {
  to: (value: string) => encrypt(value),
  from: (value: string) => decrypt(value),
};

@Entity()
class Customer {
  @Column({
    transformer: encryptedTransformer,
  })
  mobile: string;
}

Data Export & Portability

For GDPR/data portability requests:
async function exportCustomerData(customerId: string): Promise<CustomerExport> {
  const customer = await db.customer.findUnique({
    where: { id: customerId },
    include: {
      verifications: {
        select: {
          id: true,
          method: true,
          status: true,
          createdAt: true,
          // Don't include result data
        },
      },
    },
  });
  
  return {
    personalData: {
      name: customer.name,
      mobile: customer.mobile,
      email: customer.email,
      createdAt: customer.createdAt,
    },
    verificationHistory: customer.verifications.map(v => ({
      date: v.createdAt,
      type: v.method,
      status: v.status,
    })),
    exportDate: new Date(),
    format: 'JSON',
  };
}

Checklist

  • Only collect necessary data
  • Document purpose for each data field
  • Obtain proper consent
  • Encryption at rest enabled
  • Sensitive fields hashed or encrypted
  • Access controls implemented
  • Retention policies defined
  • Logging sanitization in place
  • Masking for display
  • Audit trail for sensitive access
  • Automated retention enforcement
  • Right to deletion process
  • Backup purging included