Privy Digital ID — Integration GuidePanduan Integrasi
Privy's official identity verification service, embedded by merchants via an iframe (web) or WebView (native app). Merchants simply create a session and receive the verified result — the entire phone, liveness, and face-match flow runs inside Privy's widget. Layanan verifikasi identitas resmi Privy, di-embed merchant lewat iframe (web) atau WebView (aplikasi native). Merchant cukup membuat sesi dan menerima hasil terverifikasi — seluruh alur input nomor, liveness, dan pencocokan wajah dijalankan Privy di dalam widget.
EnvironmentsEnvironment
Two environments, same API. Use Sandbox for integration and testing, Production for live traffic — just swap the base host. Dua environment, API sama. Pakai Sandbox untuk integrasi & pengujian, Production untuk trafik live — cukup ganti base host.
| Environment | API base URLBase URL API | Widget base URLBase URL widget |
|---|---|---|
| Sandbox | https://sandbox-api-digitalid.privy.id | https://sandbox-digitalid.privy.id |
| Production | https://api-digitalid.privy.id | https://digitalid.privy.id |
OverviewRingkasan
Privy Digital ID is an identity verification widget. The merchant never handles the camera, biometric data, or the user's personal data — the merchant only: Privy Digital ID adalah widget verifikasi identitas. Merchant tidak menangani kamera, data biometrik, maupun data pribadi pengguna — merchant hanya:
- Creates a session from its backend (with an API key) → receives a
session_url. Membuat sesi dari backend (dengan API key) → menerimasession_url. - Embeds the
session_urlin an iframe / WebView. Meng-embedsession_urldi iframe / WebView. - Receives the result via a server-to-server callback (source of truth) and/or a postMessage event. Menerima hasil lewat callback server-to-server (sumber kebenaran) dan/atau event postMessage.
How it worksBagaimana ia bekerja
There are two parties: the Merchant (who embeds) and Privy (who verifies identity). The whole process runs inside Privy's widget. Two steps below depend on your credential's verification type — the Identity markers show what changes for a PII credential. Ada dua pihak: Merchant (yang meng-embed) dan Privy (yang memverifikasi identitas). Seluruh proses berjalan di dalam widget Privy. Dua langkah di bawah bergantung pada jenis verifikasi credential Anda — penanda Identity menunjukkan yang berubah untuk credential PII.
| StepLangkah | What Privy doesYang dilakukan Privy |
|---|---|
| 1 · InputInput | The user enters an identifier — phone, email, NIK, or Privy ID (which ones are offered comes from the credential's get/config input_type) — which Privy matches against a registered identity.Pengguna memasukkan identifier — phone, email, NIK, atau Privy ID (mana yang ditawarkan mengikuti input_type dari get/config credential) — yang dicocokkan Privy dengan identitas terdaftar. |
| 2 · Liveness | Face-liveness detection via camera (anti-spoof, gestures, light reflection).Deteksi keaslian wajah lewat kamera (anti-spoof, gerakan, refleksi cahaya). |
| 3 · Face matchPencocokan wajah | The live selfie is matched against the face on the verified Privy identity.Selfie hidup dicocokkan dengan wajah pada identitas terverifikasi Privy. |
| 4 · OTP Identity | Identity credentials only. A Privy-hosted OTP (2FA) confirms the user right after the face match — before the consent step.Hanya credential Identity. OTP (2FA) hosted Privy mengonfirmasi pengguna tepat setelah pencocokan wajah — sebelum langkah persetujuan. |
| 5 · ConsentPersetujuan | The user reviews the data to share, then agrees or declines.Pengguna meninjau data yang akan dibagikan lalu menyetujui / menolak. |
| 6 · ResultHasil | Privy delivers the verified data to the merchant (callback + exchange code). Identity: the my_identity section is encrypted for your private key.Privy mengirim data terverifikasi ke merchant (callback + exchange code). Identity: section my_identity dienkripsi untuk private key Anda. |
Integration flowDiagram integrasi
The full sequence between the four parties. Steps 1–3 set up the widget; step 4 all happens inside Privy; steps 5–8 deliver the verified data to the merchant. otp* runs only for Identity credentials. Urutan lengkap antara empat pihak. Langkah 1–3 menyiapkan widget; langkah 4 seluruhnya terjadi di dalam Privy; langkah 5–8 mengantar data terverifikasi ke merchant. otp* hanya untuk credential Identity.
Quickstart
1 · Create a session (merchant backend)Buat sesi (backend merchant)
# API key stays in the backend — never in the browserAPI key hanya di backend — jangan pernah di browser
curl -X POST https://sandbox-api-digitalid.privy.id/v1/sessions \
-H "X-API-Key: <MERCHANT_API_KEY>"
{
"session_id": "9f1c…",
"session_url": "https://sandbox-digitalid.privy.id/?t=eyJ…",
"expires_at": "2026-07-21T18:15:00Z"
}
2 · Embed the widget (merchant frontend)Embed widget (frontend merchant)
<iframe src="{{session_url}}" allow="camera"
style="width:100%;height:100%;border:0"></iframe>
3 · Receive the resultTerima hasil
window.addEventListener('message', (e) => {
if (e.origin !== 'https://sandbox-digitalid.privy.id') return; // requiredwajib
const { type, payload } = e.data || {};
if (type === 'privy:success') {
// hand payload.exchange_code to your backend to swap for a tokenserahkan payload.exchange_code ke backend untuk ditukar token
}
});
Verification statesAlur verifikasi
The state machine is enforced server-side. Data-sharing consent happens AFTER the face match (Privy's regulation): State machine dijaga server-side. Consent berbagi data terjadi SETELAH wajah cocok (regulasi Privy):
| State | ScreenLayar | MeaningArti |
|---|---|---|
created | Phone inputInput nomor HP | Session createdSesi dibuat |
identity_found | Liveness (camera)Liveness (kamera) | Phone matched → liveness + face match run right awayNomor cocok → liveness + face match langsung berjalan |
verified | Consent screenLayar consent | Face matched → the user reviews & agrees/declinesWajah cocok → user meninjau & menyetujui/menolak |
completed | SuccessSukses | Agreed → privy:successDisetujui → privy:success |
identity_not_found | Phone (error)Input nomor (error) | Number not registered → retryNomor tidak terdaftar → coba lagi |
consent_denied | CancelledDibatalkan | User declined → privy:cancelUser menolak → privy:cancel |
blocked | BlockedTerblokir | 3 liveness failures → privy:errorGagal liveness 3× → privy:error |
Identity (PII) credentials add an OTP (2FA) step after the face match, before consent (see Verification types): Credential Identity (PII) menambah langkah OTP (2FA) setelah pencocokan wajah, sebelum consent (lihat Jenis verifikasi):
awaiting_otp | OTP inputInput OTP | Liveness + face match passed → a Privy-hosted OTP (2FA) confirms the user; on success the session advances to the consent screen (verified)Liveness + pencocokan wajah lolos → OTP hosted Privy (2FA) mengonfirmasi pengguna; jika berhasil sesi lanjut ke layar consent (verified) |
Verification typesJenis verifikasi
A credential is provisioned as one of two types. The type is fixed per credential (chosen when Privy creates your credential) — it decides the input, whether an OTP step runs, which data is shared, and how it is delivered. Your integration doesn't select it per request. Tiap credential disetel salah satu dari dua jenis. Jenis ini tetap per credential (dipilih saat Privy membuat credential Anda) — menentukan input, ada/tidaknya langkah OTP, data apa yang dibagikan, dan cara pengirimannya. Integrasi Anda tidak memilihnya per request.
| Contact (no PII) | Identity (PII) | |
|---|---|---|
| InputInput | Phone / Email / NIK / Privy ID | Phone / Email / NIK / Privy ID |
| VerificationVerifikasi | Selfie liveness → face match, or OTPSelfie liveness → face match, atau OTP | Selfie liveness → face match → OTP (2FA)Selfie liveness → face match → OTP (2FA) |
| Shared dataData dibagikan | my_contact_dataemail, phoneemail, nomor HP |
my_contact_data + my_identitycontact, plus PrivyID, full name, NIK, date of birth, ID card & selfie (image URLs)kontak, plus PrivyID, nama lengkap, NIK, tgl lahir, KTP & selfie (URL gambar) |
| DeliveryPengiriman | Signed JWT, plaintext claimsJWT ditandatangani, klaim plaintext | Encrypted — only your private key decryptsTerenkripsi — hanya private key Anda yang membuka |
AuthenticationAutentikasi
Privy issues one API key per merchant. All back-channel requests (create session, exchange
code) carry the X-API-Key header.
Privy menerbitkan satu API key untuk tiap merchant. Semua permintaan back-channel (buat sesi,
tukar exchange code) dikirim dengan header X-API-Key.
| CredentialKredensial | Header | Used forDipakai untuk |
|---|---|---|
| Merchant API Key | X-API-Key | Create session & exchange code — from the merchant backend onlyBuat sesi & tukar exchange code — dari backend merchant saja |
X-API-Key in browser code. Sessions are always created from the merchant backend.
Jangan pernah menaruh X-API-Key di kode browser. Sesi selalu dibuat dari backend merchant.
API Reference
Creates a verification session and returns the URL the merchant embeds. No body. Membuat sesi verifikasi dan mengembalikan URL yang di-embed merchant. Tanpa body.
ResponseRespons 201
{ "session_id":"…", "session_url":"https://sandbox-digitalid.privy.id/?t=…", "expires_at":"…Z" }
Swaps the exchange code (received on the frontend via privy:success) for the
verified-data token. The code is single-use and short-lived (~5 min); it is worthless without the API key.
Menukar exchange code (diterima frontend via privy:success) dengan token data
terverifikasi. Kode bersifat sekali-pakai & kadaluarsa singkat (±5 menit); tak berguna tanpa API key.
Body
| Field | Type | NotesKeterangan |
|---|---|---|
code required | string | The code from privy:success.exchange_codeKode dari privy:success.exchange_code |
ResponseRespons 200
{ "session_id":"…", "event":"identity.verified", "token":"eyJ… (JWT EdDSA)" }
Public keys to verify the token (RFC 7517). Algorithm EdDSA, curve Ed25519.Kunci publik untuk memverifikasi token (RFC 7517). Algoritma EdDSA, kurva Ed25519.
{ "keys":[{ "kty":"OKP", "crv":"Ed25519", "alg":"EdDSA", "use":"sig", "kid":"…", "x":"…" }]}
Service health check. Returns 200 when healthy.Health check layanan. Mengembalikan 200 bila sehat.
postMessage eventsEvent postMessage
The widget posts events to the parent (iframe) prefixed with privy:. Always validate
event.origin against the widget origin.
Widget mengirim event ke parent (iframe) dengan prefix privy:. Selalu validasi
event.origin terhadap origin widget.
| Event | Payload | WhenKapan |
|---|---|---|
privy:ready | { session_id, state } | Widget finished bootstrappingWidget selesai bootstrap |
privy:success | { session_id, exchange_code, token?, sections? } | Verified & agreedVerifikasi sukses & disetujui |
privy:cancel | { reason } | User cancelled / declinedUser membatalkan / menolak |
privy:error | { code } | Blocked / expired / embed not allowedTerblokir / kadaluarsa / embed ditolak |
{ type } without payload.
Perlindungan payload: data & token hanya dikirim ke embedder yang terverifikasi
(origin ada di allowlist merchant). Untuk host tak dikenal, widget hanya mengirim { type } tanpa payload.
The sections fieldField sections — optional previewpratinjau opsional
The data the user agreed to share, grouped by section (only populated fields). For display only — the authoritative data is always fetched by the backend via exchange → JWT: Data yang disetujui pengguna, dikelompokkan per bagian (hanya field berisi nilai). Untuk menampilkan ke pengguna saja — data resmi tetap diambil backend lewat exchange → JWT:
"sections": [
{ "section":"my_contact_data", "label":"My Contact Data", "fields":[
{ "key":"email", "label":"Email", "value":"user@mail.com", "is_verified":true }
]}
]
Native integration (iOS / Android)Integrasi native (iOS / Android)
Load the session_url directly in a WebView. The same events are delivered via a native bridge:Muat session_url langsung di WebView. Event yang sama dikirim lewat bridge native:
| iOS · WKWebView | Android · WebView |
|---|---|
The widget calls a ReactNativeWebView.postMessage(json) shim. Provide a handler that receives the JSON event.Widget memanggil shim ReactNativeWebView.postMessage(json). Sediakan handler yang menerima JSON event. |
Add a JS interface named PrivyAndroid; the widget calls PrivyAndroid.postMessage(json). For the liveness camera, see Camera (WebView SDK).Tambahkan JS interface bernama PrivyAndroid; widget memanggil PrivyAndroid.postMessage(json). Untuk kamera liveness, lihat Kamera (SDK WebView). |
// JSON received by the native bridge — a native WebView is a TRUSTED host (not an iframe), so the payload comes in FULLJSON yang diterima bridge native — WebView native adalah host TERPERCAYA (bukan iframe), jadi payload dikirim PENUH
{
"type": "privy:success",
"payload": {
"session_id": "…",
"exchange_code": "…", // redeem server-to-server (recommended)redeem server-to-server (disarankan)
"token": "…", // verified-data JWT (present on native)JWT verified-data (ada di native)
"sections": [ … ] // shared-data preview (Identity: my_identity stays encrypted)pratinjau data (Identity: my_identity tetap terenkripsi)
}
}
token + sections) — unlike an unverified iframe embedder, which gets only
{ type }. Use payload.token directly, or (recommended) redeem exchange_code
server-to-server. Either way, verify the JWT and decrypt my_identity on your backend.
WebView native bukan iframe, jadi diperlakukan sebagai host terpercaya dan menerima payload penuh
(token + sections) — berbeda dari iframe embedder tak terverifikasi yang cuma dapat
{ type }. Pakai payload.token langsung, atau (disarankan) redeem exchange_code
server-to-server. Apa pun caranya, verifikasi JWT dan dekripsi my_identity di backend Anda.
Camera & permissions (WebView SDK)Kamera & izin (SDK WebView)
On mobile the widget is wrapped by the DigitalID WebView SDK (iOS & Android). The liveness step
opens the camera with the standard Web API — navigator.mediaDevices.getUserMedia(). There is
no custom bridge message to request the camera: the SDK observes the getUserMedia call, decides
grant/deny, and drives the native OS permission prompt. The web contract is identical on both platforms; only the
native integrator setup differs.
Di mobile, widget dibungkus SDK WebView DigitalID (iOS & Android). Langkah liveness membuka kamera
dengan Web API standar — navigator.mediaDevices.getUserMedia(). Tidak ada pesan bridge
khusus untuk meminta kamera: SDK mengamati panggilan getUserMedia, memutuskan izinkan/tolak, dan
menggerakkan prompt izin OS. Kontrak web sama di kedua platform; hanya setup integrator native yang berbeda.
Web contract (frontend — required)Kontrak web (frontend — wajib)
- Request video only —
getUserMedia({ video: true }). Do not addaudio: true: both SDKs grant the camera only when the capture type is exactly camera; adding audio makes it camera+microphone, which is denied outright (the camera never opens). Minta video saja —getUserMedia({ video: true }). Jangan tambahkanaudio: true: kedua SDK hanya mengizinkan kamera bila tipe capture tepat camera; menambahkan audio menjadikannya camera+mikrofon yang langsung ditolak (kamera tak pernah terbuka). - Handle the rejected promise — always
.catch(…)and show a fallback/retry UI; never assume a stream was returned. Tangani promise yang gagal — selalu.catch(…)dan tampilkan UI fallback/coba-lagi; jangan berasumsi stream selalu ada. - HTTPS only —
getUserMedianeeds a secure context and the SDK transport policy rejects non-HTTPS pages. All widget/redirect origins must behttps://. Hanya HTTPS —getUserMediabutuh secure context dan kebijakan transport SDK menolak halaman non-HTTPS. Semua origin widget/redirect harushttps://. - Call lazily — request the camera only at the liveness step (the prompt appears on the first
getUserMediacall); don't call it eagerly on load. Popups/new windows are rejected by both SDKs. Panggil saat dibutuhkan — minta kamera hanya di langkah liveness (prompt muncul pada panggilangetUserMediapertama); jangan panggil saat halaman dimuat. Popup/jendela baru ditolak kedua SDK.
Capture-type behaviorPerilaku tipe capture
getUserMedia constraintsconstraint |
Native capture typeTipe capture native | SDK decisionKeputusan SDK |
|---|---|---|
{ video: true } | camera | ✅ granted (origin + OS permission)✅ diizinkan (origin + izin OS) |
{ video: true, audio: true } | camera + microphone | ❌ denied — whole request failsditolak — seluruh request gagal |
{ audio: true } | microphone | ❌ deniedditolak |
| screen / display capturescreen / display capture | display | ❌ deniedditolak |
Rejection contract (error handling)Kontrak penolakan (penanganan error)
navigator.mediaDevices.getUserMedia({ video: true })
.then(stream => { /* proceed with livenesslanjut liveness */ })
.catch(err => {
switch (err.message) {
case "camera_unsupported": // OS/WebView too old → "update device" fallbackOS/WebView terlalu lama → fallback "perbarui perangkat"
case "camera_missing_permission": // host app not configured → "camera unavailable"host app tak dikonfigurasi → "kamera tak tersedia"
default: // DOMException: NotAllowedError / NotFoundError / NotReadableError…DOMException: NotAllowedError / NotFoundError / NotReadableError…
}
});
camera_unsupported and camera_missing_permission are injected by the SDK guard, which rejects
before touching the camera (prevents a native crash) — treat them as terminal "camera not available on this
host" states. User-denied and hardware errors surface as standard DOMExceptions.
camera_unsupported dan camera_missing_permission disuntikkan oleh guard SDK yang menolak
sebelum menyentuh kamera (mencegah crash native) — perlakukan sebagai keadaan terminal "kamera tak tersedia
di host ini". Error karena user menolak / perangkat keras muncul sebagai DOMException standar.
Native integrator requirementsKebutuhan integrator native
| iOS · WKWebView | Android · WebView |
|---|---|
getUserMedia routes to WKUIDelegate.requestMediaCapturePermissionFor, granting
camera only (.camera) when origin/frame satisfy the HTTPS policy.
getUserMedia diarahkan ke WKUIDelegate.requestMediaCapturePermissionFor, memberi
kamera saja (.camera) bila origin/frame memenuhi kebijakan HTTPS.
|
getUserMedia fires WebChromeClient.onPermissionRequest for RESOURCE_VIDEO_CAPTURE;
the SDK grants video only and denies audio/other resources.
getUserMedia memicu WebChromeClient.onPermissionRequest untuk RESOURCE_VIDEO_CAPTURE;
SDK memberi video saja dan menolak audio/resource lain.
|
// iOS · Info.plist
<key>NSCameraUsageDescription</key>
<string>Digital ID uses the camera to capture identity verification images.</string>
// Android · AndroidManifest.xml
<uses-permission android:name="android.permission.CAMERA" />
__digitalIDCamera is
SDK→native only; Android exposes none) · bundle audio into the liveness capture · rely on the camera over
non-HTTPS or in a popup/new window.
Frontend TIDAK boleh: mem-post ke bridge kamera native mana pun (__digitalIDCamera internal SDK
iOS hanya SDK→native; Android tak menyediakannya) · menyertakan audio pada capture liveness · mengandalkan
kamera di non-HTTPS atau popup/jendela baru.
Data deliveryPengiriman data
Verified data is always delivered as a Privy-signed JWT — regardless of the identity source — through two complementary channels: Data terverifikasi selalu diantar sebagai JWT yang ditandatangani Privy — apapun sumber identitasnya — lewat dua jalur yang saling melengkapi:
- Exchange code → JWT — the frontend receives a single-use
exchange_codeviaprivy:success; the merchant backend swaps it at/v1/sessions/exchangefor a JWT with the data. Avoids the token travelling through the front channel. The code & verified data are held briefly in a fast store (in-memory / Redis) with a short TTL, never in the database. Exchange code → JWT — frontend menerimaexchange_codesekali-pakai viaprivy:success; backend merchant menukarnya di/v1/sessions/exchangeuntuk memperoleh JWT berisi data. Menghindari token melintas front-channel. Kode & data terverifikasi ditahan singkat di penyimpanan cepat (in-memory / Redis) dengan TTL pendek, bukan di basis data. - Server-to-server callback (source of truth) — Privy POSTs the same JWT to the
merchant
callback_url:{ session_id, event:"identity.verified", token }. Callback server-to-server (sumber kebenaran) — Privy mem-POST JWT yang sama kecallback_urlmerchant:{ session_id, event:"identity.verified", token }.
sections field on privy:success is only an optional front-channel preview to
show the user — the authoritative data is always fetched by the backend via exchange → JWT (or the callback).
Field sections pada privy:success hanya pratinjau front-channel opsional untuk
menampilkan data ke pengguna — data resmi selalu diambil backend lewat exchange → JWT (atau callback).
data claim is fully plaintext
(my_contact_data). For Identity credentials the same channels are used and data carries
both sections: my_contact_data plaintext + my_identity as an RSA-4096 encrypted value
only your private key can open — see Encrypted PII & keys.
Payload Contact vs Identity. Untuk credential Contact, klaim data pada JWT sepenuhnya plaintext
(my_contact_data). Untuk credential Identity, jalur pengirimannya sama dan data membawa
dua section: my_contact_data plaintext + my_identity berupa nilai terenkripsi RSA-4096
yang hanya bisa dibuka private key Anda — lihat PII terenkripsi & kunci.
Encrypted PII & keysPII terenkripsi & kunci
Identity credentials only. The user's personal data (my_identity) is never delivered in the
clear — it arrives encrypted for your key inside the verified-data JWT, only on a successful (verified) event.
Your backend decrypts it with your private key; decryption happens on your side, never in the browser.
Hanya credential Identity. Data pribadi pengguna (my_identity) tidak pernah dikirim terbuka —
data tiba terenkripsi untuk kunci Anda di dalam JWT verified-data, hanya pada event sukses (terverifikasi).
Backend Anda yang mendekripsi dengan private key Anda; dekripsi terjadi di sisi Anda, bukan di browser.
Key provisioningPenyediaan kunci
Each merchant credential has its own RSA key pair (unique per merchant). At credential creation you receive the private key once — keep it in your backend secret manager. Privy holds the matching public key and encrypts the PII with it. You are the party that decrypts the delivered payload — keep the private key server-side and never ship it to a browser or mobile app. Setiap credential merchant punya pasangan kunci RSA sendiri (unik per merchant). Saat credential dibuat, Anda menerima private key sekali — simpan di secret manager backend Anda. Privy memegang public key pasangannya dan memakainya untuk mengenkripsi PII. Andalah pihak yang mendekripsi payload yang dikirim — simpan private key di sisi server dan jangan pernah dikirim ke browser atau aplikasi mobile.
Encryption schemeSkema enkripsi
Per-section, direct RSA. Only the sensitive my_identity is encrypted; my_contact_data
is delivered plaintext alongside it. The identity payload is compact JSON that fits in a single RSA-4096 block, so
it is encrypted directly with RSA-4096 (PKCS#1 v1.5) using your public key — no hybrid/AES. Inside the JWT
data claim, my_identity is a base64 ciphertext string:
Per-section, RSA langsung. Hanya my_identity sensitif yang dienkripsi; my_contact_data
dikirim plaintext di sampingnya. Payload identitas berupa JSON ringkas yang muat dalam satu blok RSA-4096, jadi
dienkripsi langsung dengan RSA-4096 (PKCS#1 v1.5) memakai public key Anda — tanpa hybrid/AES. Di dalam klaim
data JWT, my_identity berupa string ciphertext base64:
// JWT data claim (Identity credential)
{
"my_contact_data": { "email": "…", "phone": "…" }, // plaintext
"my_identity": "<base64 RSA-4096 ciphertext>" // decrypt → JSON below
}
Decrypting my_identity yields:
Mendekripsi my_identity menghasilkan:
{
"privy_id": "…",
"name": "…",
"identity_number": "…", // NIK
"dob": "1998-09-04",
"email": "…",
"phone": "+62…",
"identity_card": "https://…/ktp?token=…", // KTP image — fetch this URL
"selfie": "https://…/selfie?token=…" // selfie image — fetch this URL
}
identity_card and selfie are URLs, not inline images — fetch each URL to download the
KTP / selfie image.
identity_card dan selfie berupa URL, bukan gambar inline — ambil tiap URL untuk
mengunduh gambar KTP / selfie.
Decrypt on your backendDekripsi di backend Anda
import forge from 'node-forge';
// my_identity is a base64 RSA-4096 / PKCS#1 v1.5 ciphertext string
function decryptIdentity(myIdentityB64, rsaPrivateKeyPem) {
const priv = forge.pki.privateKeyFromPem(rsaPrivateKeyPem);
const plain = priv.decrypt(forge.util.decode64(myIdentityB64), 'RSAES-PKCS1-V1_5');
return JSON.parse(plain); // { name, identity_number, dob, … }
}
crypto.privateDecrypt rejects
RSA_PKCS1_PADDING for private decryption since the CVE-2023-46809 fix (Node 18.19/20/22+),
so the native path throws. A pure-JS lib (node-forge) does the PKCS#1 v1.5 unpadding; other languages
(Go rsa.DecryptPKCS1v15, PHP openssl_private_decrypt, Python
PKCS1v15) work directly.
Kenapa node-forge? crypto.privateDecrypt bawaan Node menolak
RSA_PKCS1_PADDING untuk dekripsi privat sejak perbaikan CVE-2023-46809 (Node 18.19/20/22+),
jadi jalur native akan error. Library pure-JS (node-forge) menangani unpadding PKCS#1 v1.5; bahasa lain
(Go rsa.DecryptPKCS1v15, PHP openssl_private_decrypt, Python
PKCS1v15) bekerja langsung.
data.my_identity.
Dekripsi hanya di backend Anda. Jangan pernah mengirim private key ke browser atau aplikasi mobile.
Verifikasi tanda tangan JWT dulu (lihat Verifikasi JWT), baru dekripsi data.my_identity.
Verify the JWTVerifikasi JWT
The token is an EdDSA / Ed25519 JWT. Verify its signature on your backend with the JWKS, then read the claims: Token adalah JWT EdDSA / Ed25519. Verifikasi tanda tangannya di backend memakai JWKS, lalu baca klaim:
{
"iss": "https://id.privy.id", // issuer
"sub": "<identity ref>",
"aud": "<merchant_id>",
"session_id": "…",
"merchant": "…",
// shared data, grouped by section (fields determined by Privy)data yang dibagikan, dikelompokkan per section (field ditentukan Privy)
"data": {
"my_contact_data": { "email":"…", "phone":"…" }, // plaintext (both types)
"my_identity": "<base64 RSA-4096 ciphertext>" // Identity only — decrypt (see #encrypted)
},
"verification": { "method":"phone_lookup+liveness_face_match" },
"iat": …, "exp": …
}
aud = your merchant id, exp not passed, and iss matches.Selalu cek aud = merchant id kamu, exp belum lewat, dan iss sesuai.Security notesCatatan keamanan
- API key stays in the backend. Sessions are created server-side; never expose
X-API-Keyto the browser.API key hanya di backend. Sesi dibuat server-side; jangan exposeX-API-Keyke browser. - Validate
event.originon the postMessage listener — accept only the widget origin.Validasievent.originpada listener postMessage — hanya terima dari origin widget. - Verify the JWT (signature via JWKS +
aud/exp/iss) before trusting the data.Verifikasi JWT (signature via JWKS +aud/exp/iss) sebelum mempercayai data. - The exchange code is useless without the API key — safe to travel the front channel; still single-use & short-lived.Exchange code tak berguna tanpa API key — aman melintas front-channel; tetap single-use & short-lived.
- The iframe must set
allow="camera"; register the merchant origin in the allowlist.iframe wajiballow="camera"; daftarkan origin merchant di allowlist. - Data/token only reach a verified embedder; an unknown host receives the event signal without payload.Data/token hanya sampai ke embedder terverifikasi; host tak dikenal hanya menerima sinyal event tanpa payload.
Error codesKode error
| HTTP | CodeKode | MeaningArti |
|---|---|---|
| 400 | invalid_input | Bad body/parameterBody/parameter tidak valid |
| 401 | unauthorized | Wrong API key / tokenAPI key / token salah |
| 404 | not_found | Session/merchant/code not foundSesi/merchant/kode tidak ditemukan |
| 409 | invalid_state | Transition not allowedTransisi state tidak diizinkan |
| 410 | session_expired | Session expiredSesi kadaluarsa |
| 423 | session_blocked | 3 liveness failuresGagal liveness 3× |
| 429 | rate_limited | Too many requestsTerlalu banyak permintaan |
Error shapeFormat error: { "error": { "code": "…", "message": "…" } }
Changelog
- Initial public release of the Privy Digital ID integration API.Rilis publik pertama API integrasi Privy Digital ID.
- Session flow:
POST /v1/sessions→ embed →privy:success→POST /v1/sessions/exchange.Alur sesi:POST /v1/sessions→ embed →privy:success→POST /v1/sessions/exchange. - Data-sharing consent moved to after the face match; consent screen rendered from the shared-data sections.Consent berbagi data pindah ke setelah pencocokan wajah; layar consent dirender dari section data yang dibagikan.
- Unified delivery: verified data as a JWT via exchange code + callback; exchange held in a fast store (in-memory / Redis).Delivery terpadu: data terverifikasi sebagai JWT via exchange code + callback; exchange ditahan di penyimpanan cepat (in-memory / Redis).
- Optional
sectionspreview onprivy:successfor verified embedders.Pratinjausectionsopsional padaprivy:successuntuk embedder terverifikasi. - Added Camera (WebView SDK): the liveness camera uses
getUserMedia({ video: true })— no native bridge — with the iOS/Android integrator setup and thecamera_unsupported/camera_missing_permissionrejection contract.Menambahkan Kamera (SDK WebView): kamera liveness memakaigetUserMedia({ video: true })— tanpa bridge native — beserta setup integrator iOS/Android dan kontrak penolakancamera_unsupported/camera_missing_permission. - Documented two credential verification types: Contact (contact data) and Identity (OTP 2FA → encrypted PII). Both accept phone / email / NIK / Privy ID input; type is fixed per credential.Mendokumentasikan dua jenis verifikasi credential: Contact (data kontak) dan Identity (OTP 2FA → PII terenkripsi). Keduanya menerima input phone / email / NIK / Privy ID; jenis tetap per credential.
- Identity flow implemented: OTP (2FA) runs after the face match, before consent (
awaiting_otpstate), ordered by the credential'sauthentication_method. Themy_identityPII is delivered encrypted (per-merchant RSA-4096, PKCS#1 v1.5) in the JWTdataclaim; the merchant decrypts it with their private key. Decrypt example switched to node-forge (Node's nativeprivateDecryptblocks PKCS#1 v1.5, CVE-2023-46809).Flow Identity diimplementasikan: OTP (2FA) berjalan setelah pencocokan wajah, sebelum consent (stateawaiting_otp), urutannya mengikutiauthentication_methodcredential. PIImy_identitydikirim terenkripsi (RSA-4096 per-merchant, PKCS#1 v1.5) di klaimdataJWT; merchant mendekripsi dengan private key-nya. Contoh dekripsi beralih ke node-forge (nativeprivateDecryptNode memblokir PKCS#1 v1.5, CVE-2023-46809).