Building Biometric Verification Systems: Architecture Patterns for Indonesia's New Requirements
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):
| Parameter | Acceptable Range |
|---|---|
| Yaw angle | -15° to +15° |
| Pitch angle | -10° to +10° |
| Roll angle | -8° to +8° |
| Inter-eye distance | ≥ 90 pixels |
| Illumination | 100-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:
- Preprocessing: Normalize face alignment, crop, resize to model input dimensions (112x112 or 160x160)
- Feature Extraction: Run through a deep neural network (ArcFace, CosFace, or similar) to produce a 128-512 dimensional embedding vector
- Comparison: Calculate cosine similarity or L2 distance between the probe and reference embeddings
- Decision: Apply threshold — typically 0.4-0.6 cosine similarity depending on the model and required FAR
Performance Requirements:
| Metric | Requirement |
|---|---|
| 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
| Obligation | Implementation |
|---|---|
| Lawful basis | Explicit consent + regulatory compliance (KOMDIGI mandate) |
| Purpose limitation | Biometric data used solely for SIM registration verification |
| Data minimization | Store templates, not raw images; delete raw captures after processing |
| Accuracy | Ensure templates are current; re-verification every 5 years |
| Storage limitation | Logs: 5 years; Templates: duration of SIM activation + 1 year |
| Integrity & confidentiality | AES-256 encryption, access controls, breach notification within 72 hours |
| Accountability | Maintain processing records, conduct DPIAs, appoint DPO |
Cross-Border Data Transfer
UU PDP prohibits transferring specific personal data (including biometrics) outside Indonesia unless:
- The destination country has equivalent data protection laws (KOMDIGI maintains an approved country list)
- Binding corporate rules are in place
- 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
| Component | Specification | Justification |
|---|---|---|
| API Nodes | 8-16 instances (4 vCPU, 16GB RAM each) | 1,000 verifications/sec/node |
| PostgreSQL | Primary + 2 read replicas (8 vCPU, 64GB RAM) | Audit log writes + template reads |
| Redis | 3-node cluster (4 vCPU, 32GB RAM) | Template caching, rate limiting, session store |
| Object Storage | S3-compatible (min 10 TB) | Encrypted capture images during processing |
| HSM | 2 HSM instances (active-passive) | Encryption key management |
| Network | 1 Gbps dedicated, <10ms latency to IKD | Real-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
| Factor | Edge (On-Device) | Cloud (Server-Side) | Recommendation |
|---|---|---|---|
| Latency | <500ms | 1-3s (network dependent) | Edge for UX |
| Accuracy | 92-95% (constrained model) | 98-99.5% (full model) | Cloud for decisions |
| Security | Vulnerable to SDK tampering | Full control over processing | Cloud for compliance |
| Bandwidth | Minimal (result only) | 100-500 KB per verification | Edge for rural areas |
| Cost | Zero marginal cost | $0.01-0.05 per verification | Edge at scale |
| Device compatibility | Requires SDK per platform | Any device with camera | Cloud for universality |
| Update cycle | App store review (1-7 days) | Instant server deployment | Cloud for agility |
| Offline capability | Yes (liveness only) | No | Edge for connectivity gaps |
Recommended hybrid approach for Indonesia:
- Edge: Capture quality validation + passive liveness check (immediate user feedback)
- 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:
| Metric | Alert Threshold | Purpose |
|---|---|---|
| Verification success rate | < 90% | Detect system issues or attack waves |
| Average processing time | > 3 seconds | SLA compliance |
| Liveness rejection rate | > 15% | Detect attack campaigns or SDK issues |
| IKD gateway latency | > 2 seconds | Infrastructure degradation |
| IKD circuit breaker state | Open | Immediate incident response |
| Template encryption failures | > 0 | HSM or key management issues |
| Audit log write failures | > 0 | Compliance 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.
What is the recommended disaster recovery strategy?
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.