انتقل إلى المحتوى الرئيسي
BiometricsMar 28, 2026

Building Biometric Verification Systems: Architecture Patterns for Indonesia's New Requirements

OS
Open Soft Team

Engineering Team

System Architecture Overview: The Short Answer

A compliant biometric verification system for Indonesia’s SIM mandate requires four core components: biometric capture (camera + SDK), liveness processing (on-device or server-side PAD), identity matching (1:1 verification against the IKD database), and secure storage (encrypted templates with audit logging). The system must handle a population of 270+ million people, process verifications in under 3 seconds, and comply with both KOMDIGI Regulation No. 7/2026 and UU PDP data protection requirements.

This article provides a detailed architectural blueprint for building such a system, including component design, data flow, security measures, and Rust implementation patterns.

Core Components

1. Biometric Capture Subsystem

The capture subsystem is responsible for acquiring a high-quality facial image from the user. Quality directly impacts downstream accuracy — a poorly lit, blurry image will fail verification even for a legitimate user.

Requirements:

  • Minimum image resolution: 640x480 pixels (VGA) for the face region
  • Face detection: Must locate the face within the captured frame and crop to a standardized size
  • Quality assessment: Evaluate illumination, pose angle, occlusion, and blur before proceeding
  • Image format: JPEG at 80%+ quality or PNG for lossless capture
  • Metadata: Capture timestamp (ISO 8601), device identifier, GPS coordinates (with user consent), camera parameters

Quality Thresholds (ISO/IEC 19794-5 compliant):

ParameterAcceptable Range
Yaw angle-15° to +15°
Pitch angle-10° to +10°
Roll angle-8° to +8°
Inter-eye distance≥ 90 pixels
Illumination100-250 lux on face
Blur (Laplacian variance)> 100
Face area ratio> 20% of frame

2. Liveness Processing Subsystem

The liveness subsystem determines whether the captured biometric originates from a live person. For Indonesia’s mandate, this must meet ISO/IEC 30107-3 Level 2 standards.

Architecture decision: Edge vs Server

For the Indonesian market, a hybrid approach is recommended:

  • On-device (edge): Run a lightweight liveness model (MobileFaceNet or similar, <30MB) for immediate user feedback. This catches 90%+ of basic print and screen attacks.
  • Server-side: Run a full ensemble model for definitive liveness scoring. This catches sophisticated attacks including 3D masks and deepfake injections.

The dual-layer approach provides fast user feedback (under 500ms on-device) while maintaining high security (server-side catches what edge misses).

3. Biometric Matching Engine

The matching engine compares the extracted facial template against the reference template stored in the IKD database. For Indonesia’s SIM mandate, this is always 1:1 verification (not 1:N identification).

Matching Pipeline:

  1. Preprocessing: Normalize face alignment, crop, resize to model input dimensions (112x112 or 160x160)
  2. Feature Extraction: Run through a deep neural network (ArcFace, CosFace, or similar) to produce a 128-512 dimensional embedding vector
  3. Comparison: Calculate cosine similarity or L2 distance between the probe and reference embeddings
  4. Decision: Apply threshold — typically 0.4-0.6 cosine similarity depending on the model and required FAR

Performance Requirements:

MetricRequirement
1:1 Verification time< 200ms (server-side)
Throughput> 1,000 verifications/second per node
FAR (False Accept Rate)< 0.001%
FRR (False Reject Rate)< 5%
Template size< 2 KB

4. Secure Storage and Audit Subsystem

All biometric data must be stored and transmitted according to UU PDP and KOMDIGI requirements:

  • Template encryption: AES-256-GCM for templates at rest
  • Key management: Hardware Security Module (HSM) or cloud KMS for encryption keys
  • Transmission: TLS 1.3 with certificate pinning for all biometric data in transit
  • Audit logs: Immutable, append-only log of all verification events
  • Data lifecycle: Templates retained for verification duration only; logs retained for 5 years

Indonesia-Specific Compliance: UU PDP

Indonesia’s Personal Data Protection Law (UU PDP, Law No. 27 of 2022) classifies biometric data as specific personal data (data pribadi yang bersifat spesifik), requiring enhanced protections:

Data Controller Obligations

ObligationImplementation
Lawful basisExplicit consent + regulatory compliance (KOMDIGI mandate)
Purpose limitationBiometric data used solely for SIM registration verification
Data minimizationStore templates, not raw images; delete raw captures after processing
AccuracyEnsure templates are current; re-verification every 5 years
Storage limitationLogs: 5 years; Templates: duration of SIM activation + 1 year
Integrity & confidentialityAES-256 encryption, access controls, breach notification within 72 hours
AccountabilityMaintain processing records, conduct DPIAs, appoint DPO

Cross-Border Data Transfer

UU PDP prohibits transferring specific personal data (including biometrics) outside Indonesia unless:

  1. The destination country has equivalent data protection laws (KOMDIGI maintains an approved country list)
  2. Binding corporate rules are in place
  3. Explicit consent from the data subject is obtained

Practical implication: All biometric processing infrastructure must be hosted in Indonesian data centers. Cloud providers must offer Jakarta region deployments (AWS ap-southeast-3, GCP asia-southeast2, Azure Southeast Asia).

Data Encryption Requirements in Detail

+----------------------------------+
|       Data at Rest               |
|  +----------------------------+  |
|  | Biometric Templates        |  |
|  | Encryption: AES-256-GCM    |  |
|  | Key: HSM-managed, rotated  |  |
|  |        every 90 days       |  |
|  +----------------------------+  |
|  +----------------------------+  |
|  | Audit Logs                 |  |
|  | Encryption: AES-256-GCM    |  |
|  | Integrity: HMAC-SHA256     |  |
|  | Tamper-evident: append-only|  |
|  +----------------------------+  |
+----------------------------------+

+----------------------------------+
|       Data in Transit            |
|  Protocol: TLS 1.3               |
|  Certificate: Pinned             |
|  Cipher: AES-256-GCM + SHA384   |
|  Perfect Forward Secrecy: Yes    |
+----------------------------------+

Scalability: Handling 270M+ Population

Indonesia has 275.5 million people (2025 census estimate) and 345+ million active SIM cards. A biometric verification system must scale to handle:

  • Peak new registrations: 500,000 per day during the initial rollout period
  • Re-verification wave: 200+ million existing SIMs must be re-verified by July 2027
  • Steady state: 50,000-100,000 verifications per day for new SIMs and replacements

Horizontal Scaling Architecture

                    [Load Balancer]
                    /      |      \
                   /       |       \
            [Node 1]  [Node 2]  [Node N]
            (Rust)    (Rust)    (Rust)
               |         |         |
               +----+----+----+----+
                    |         |
              [PostgreSQL]  [Redis Cache]
              (Primary +     (Template
               Replicas)      cache, rate
                              limiting)
                    |
              [IKD Gateway]
              (Rate-limited,
               circuit-breaker)

Capacity Planning

ComponentSpecificationJustification
API Nodes8-16 instances (4 vCPU, 16GB RAM each)1,000 verifications/sec/node
PostgreSQLPrimary + 2 read replicas (8 vCPU, 64GB RAM)Audit log writes + template reads
Redis3-node cluster (4 vCPU, 32GB RAM)Template caching, rate limiting, session store
Object StorageS3-compatible (min 10 TB)Encrypted capture images during processing
HSM2 HSM instances (active-passive)Encryption key management
Network1 Gbps dedicated, <10ms latency to IKDReal-time verification requirement

Rate Limiting and Circuit Breakers

The IKD platform has its own rate limits and SLA. Your system must handle IKD degradation gracefully:

use std::time::Duration;

/// Circuit breaker for IKD gateway
struct IkdCircuitBreaker {
    failure_count: AtomicU32,
    last_failure: AtomicU64,
    state: AtomicU8, // 0=Closed, 1=Open, 2=HalfOpen
}

impl IkdCircuitBreaker {
    const FAILURE_THRESHOLD: u32 = 10;
    const RECOVERY_TIMEOUT: Duration = Duration::from_secs(30);

    async fn call<F, T, E>(&self, operation: F) -> Result<T, AppError>
    where
        F: Future<Output = Result<T, E>>,
        E: Into<AppError>,
    {
        match self.state.load(Ordering::Relaxed) {
            0 => { // Closed - normal operation
                match operation.await {
                    Ok(result) => {
                        self.failure_count.store(0, Ordering::Relaxed);
                        Ok(result)
                    }
                    Err(e) => {
                        let count = self.failure_count
                            .fetch_add(1, Ordering::Relaxed) + 1;
                        if count >= Self::FAILURE_THRESHOLD {
                            self.state.store(1, Ordering::Relaxed);
                            self.last_failure.store(
                                now_millis(), Ordering::Relaxed
                            );
                        }
                        Err(e.into())
                    }
                }
            }
            1 => { // Open - reject immediately
                let elapsed = now_millis()
                    - self.last_failure.load(Ordering::Relaxed);
                if elapsed > Self::RECOVERY_TIMEOUT.as_millis() as u64 {
                    self.state.store(2, Ordering::Relaxed);
                    // Fall through to half-open
                    self.call(operation).await
                } else {
                    Err(AppError::ServiceUnavailable(
                        "IKD gateway circuit breaker open".into()
                    ))
                }
            }
            _ => { // HalfOpen - allow one request
                match operation.await {
                    Ok(result) => {
                        self.state.store(0, Ordering::Relaxed);
                        self.failure_count.store(0, Ordering::Relaxed);
                        Ok(result)
                    }
                    Err(e) => {
                        self.state.store(1, Ordering::Relaxed);
                        self.last_failure.store(
                            now_millis(), Ordering::Relaxed
                        );
                        Err(e.into())
                    }
                }
            }
        }
    }
}

Edge vs Cloud Processing Trade-offs

FactorEdge (On-Device)Cloud (Server-Side)Recommendation
Latency<500ms1-3s (network dependent)Edge for UX
Accuracy92-95% (constrained model)98-99.5% (full model)Cloud for decisions
SecurityVulnerable to SDK tamperingFull control over processingCloud for compliance
BandwidthMinimal (result only)100-500 KB per verificationEdge for rural areas
CostZero marginal cost$0.01-0.05 per verificationEdge at scale
Device compatibilityRequires SDK per platformAny device with cameraCloud for universality
Update cycleApp store review (1-7 days)Instant server deploymentCloud for agility
Offline capabilityYes (liveness only)NoEdge for connectivity gaps

Recommended hybrid approach for Indonesia:

  1. Edge: Capture quality validation + passive liveness check (immediate user feedback)
  2. Cloud: Active liveness validation + template extraction + IKD verification (authoritative decision)

Security: Template Protection and Encryption

Biometric Template Protection

Raw biometric templates are sensitive — if stolen, they cannot be changed like passwords. Implement cancelable biometrics using non-invertible transformations:

use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use aes_gcm::aead::Aead;
use rand::RngCore;

/// Encrypt a biometric template for storage
fn encrypt_template(
    template: &[f32],
    key: &[u8; 32],
) -> Result<EncryptedTemplate, CryptoError> {
    let cipher = Aes256Gcm::new_from_slice(key)
        .map_err(|_| CryptoError::InvalidKey)?;

    // Generate random nonce (96 bits for GCM)
    let mut nonce_bytes = [0u8; 12];
    rand::thread_rng().fill_bytes(&mut nonce_bytes);
    let nonce = Nonce::from_slice(&nonce_bytes);

    // Serialize template to bytes
    let template_bytes: Vec<u8> = template
        .iter()
        .flat_map(|f| f.to_le_bytes())
        .collect();

    // Encrypt with AES-256-GCM (provides authenticity + confidentiality)
    let ciphertext = cipher
        .encrypt(nonce, template_bytes.as_ref())
        .map_err(|_| CryptoError::EncryptionFailed)?;

    Ok(EncryptedTemplate {
        nonce: nonce_bytes.to_vec(),
        ciphertext,
        algorithm: "AES-256-GCM".to_string(),
        created_at: chrono::Utc::now(),
    })
}

/// Decrypt a stored template for matching
fn decrypt_template(
    encrypted: &EncryptedTemplate,
    key: &[u8; 32],
) -> Result<Vec<f32>, CryptoError> {
    let cipher = Aes256Gcm::new_from_slice(key)
        .map_err(|_| CryptoError::InvalidKey)?;

    let nonce = Nonce::from_slice(&encrypted.nonce);

    let plaintext = cipher
        .decrypt(nonce, encrypted.ciphertext.as_ref())
        .map_err(|_| CryptoError::DecryptionFailed)?;

    // Deserialize bytes back to f32 vector
    let template: Vec<f32> = plaintext
        .chunks_exact(4)
        .map(|chunk| {
            let bytes: [u8; 4] = chunk.try_into().unwrap();
            f32::from_le_bytes(bytes)
        })
        .collect();

    Ok(template)
}

End-to-End Verification Pipeline in Rust

Here is a complete biometric verification pipeline using Axum:

use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::Utc;

#[derive(Deserialize)]
struct VerificationRequest {
    nik: String,
    facial_image_base64: String,
    device_id: String,
    liveness_score: f64,
    capture_metadata: CaptureMetadata,
}

#[derive(Deserialize)]
struct CaptureMetadata {
    timestamp: chrono::DateTime<Utc>,
    device_model: String,
    os_version: String,
    sdk_version: String,
    gps_latitude: Option<f64>,
    gps_longitude: Option<f64>,
}

#[derive(Serialize)]
struct VerificationResponse {
    transaction_id: Uuid,
    verified: bool,
    confidence: f64,
    processing_time_ms: u64,
    timestamp: chrono::DateTime<Utc>,
}

async fn verify_biometric(
    State(state): State<AppState>,
    Json(req): Json<VerificationRequest>,
) -> Result<Json<VerificationResponse>, AppError> {
    let start = std::time::Instant::now();
    let transaction_id = Uuid::new_v4();

    // Step 1: Validate NIK format (16 digits)
    if !is_valid_nik(&req.nik) {
        return Err(AppError::InvalidNik);
    }

    // Step 2: Decode and validate image
    let image_bytes = base64::decode(&req.facial_image_base64)
        .map_err(|_| AppError::InvalidImage)?;
    let quality = assess_image_quality(&image_bytes)?;
    if quality.score < 0.7 {
        return Err(AppError::InsufficientImageQuality(quality));
    }

    // Step 3: Server-side liveness validation
    let liveness = state.liveness_engine
        .validate(&image_bytes)
        .await?;
    if liveness.score < 0.95 || liveness.is_attack {
        state.audit_logger.log_failed_liveness(
            transaction_id, &req.nik, &liveness
        ).await?;
        return Err(AppError::LivenessCheckFailed);
    }

    // Step 4: Extract biometric template
    let template = state.biometric_engine
        .extract_template(&image_bytes)
        .await?;

    // Step 5: Verify against IKD (with circuit breaker)
    let ikd_result = state.ikd_circuit_breaker
        .call(state.ikd_client.verify(
            &req.nik, &template
        ))
        .await?;

    // Step 6: Log audit trail
    let elapsed = start.elapsed().as_millis() as u64;
    state.audit_logger.log_verification(
        transaction_id,
        &req.nik,
        ikd_result.verified,
        ikd_result.confidence,
        elapsed,
        &req.capture_metadata,
    ).await?;

    // Step 7: Securely delete raw image from memory
    drop(image_bytes);

    Ok(Json(VerificationResponse {
        transaction_id,
        verified: ikd_result.confidence >= 0.95,
        confidence: ikd_result.confidence,
        processing_time_ms: elapsed,
        timestamp: Utc::now(),
    }))
}

TLS 1.3 Configuration

For the Axum server, configure TLS 1.3 with strong cipher suites:

use axum_server::tls_rustls::RustlsConfig;
use std::path::PathBuf;

async fn configure_tls() -> RustlsConfig {
    RustlsConfig::from_pem_file(
        PathBuf::from("/etc/ssl/certs/biometric-api.pem"),
        PathBuf::from("/etc/ssl/private/biometric-api-key.pem"),
    )
    .await
    .expect("Failed to load TLS certificates")
}

Monitoring and Observability

A production biometric system must have comprehensive monitoring:

MetricAlert ThresholdPurpose
Verification success rate< 90%Detect system issues or attack waves
Average processing time> 3 secondsSLA compliance
Liveness rejection rate> 15%Detect attack campaigns or SDK issues
IKD gateway latency> 2 secondsInfrastructure degradation
IKD circuit breaker stateOpenImmediate incident response
Template encryption failures> 0HSM or key management issues
Audit log write failures> 0Compliance risk

Frequently Asked Questions

What programming languages are best suited for biometric verification systems?

Rust is an excellent choice for the core verification pipeline due to its memory safety guarantees, zero-cost abstractions, and high performance. The biometric matching engine benefits from Rust’s ability to perform SIMD-optimized vector operations without garbage collection pauses. Python remains dominant for ML model training and prototyping. For the mobile SDK, Kotlin (Android) and Swift (iOS) are required, with C/C++ for the cross-platform biometric core.

How do you handle the 270M+ population scale in the matching database?

Since Indonesia’s SIM mandate uses 1:1 verification (not 1:N identification), the database scale is manageable. Each verification query looks up a single NIK and compares against one stored template. The bottleneck is not database size but concurrent throughput. With proper indexing on the NIK field, PostgreSQL handles 10,000+ lookups per second on modest hardware. For the IKD integration, the government provides a rate-limited API — plan for 100-500 requests per second per operator.

What is the difference between AES-256-GCM and AES-256-CBC for template encryption?

AES-256-GCM is strongly recommended over CBC because it provides both confidentiality and authenticity in a single operation (Authenticated Encryption with Associated Data). GCM detects if ciphertext has been tampered with, while CBC does not — making CBC vulnerable to padding oracle attacks. GCM is also faster on modern CPUs with AES-NI instruction support. The KOMDIGI technical specification mandates authenticated encryption, making GCM the natural choice.

Can biometric verification work with Indonesia’s existing e-KTP infrastructure?

e-KTP (electronic national identity card) contains a chip with biometric data (fingerprints and facial photo). However, the SIM mandate specifically uses the IKD digital platform rather than reading from physical e-KTP chips. This is because IKD provides a centralized, up-to-date database accessible via API, while e-KTP chip reading requires NFC hardware and is impractical for high-volume SIM registrations. The biometric data in IKD is derived from the same enrollment process as e-KTP.

Implement a multi-region active-passive setup within Indonesia: primary in Jakarta (AWS ap-southeast-3 or local data center) with failover in Surabaya or Batam. Use PostgreSQL streaming replication with a Recovery Point Objective (RPO) of < 1 minute and Recovery Time Objective (RTO) of < 15 minutes. HSM keys must be replicated to the DR site. Biometric templates in the processing pipeline should be encrypted and persisted to object storage before IKD verification, so they can be retried after recovery.

How do you ensure fairness and prevent bias in facial recognition?

Bias testing is critical for Indonesia’s diverse population. Test the system across Fitzpatrick skin tone types III through VI, both genders, all age groups (18-80+), and with common accessories (hijab, glasses, masks). The KOMDIGI certification process includes demographic parity testing — the system must achieve within 2% accuracy variation across demographic groups. Use training datasets that represent Indonesia’s ethnic diversity (Javanese, Sundanese, Malay, Batak, Bugis, Chinese Indonesian, Papuan, and others).

What happens during a biometric data breach?

Under UU PDP, a breach involving biometric data requires: notification to the data protection authority within 72 hours, notification to affected individuals without undue delay, and a detailed incident report. The compromised templates must be revoked and re-enrolled — this is why cancelable biometrics (non-invertible template transformations) are essential. If templates are properly protected with cancelable biometrics, a breach results in template revocation, not permanent identity compromise.