Passkey adalah cara login tanpa password, dibangun di atas standar WebAuthn. Isinya sepasang kunci: private key yang gak pernah keluar dari perangkatmu, dan public key yang disimpan server. Login berarti perangkatmu menandatangani sebuah tantangan baru dengan private key, lalu server memeriksa tanda tangannya dengan public key. Bayangin stempel pribadi dan contoh capnya. Stempelnya kamu simpan sendiri, sekolah cuma menyimpan contoh capnya. Untuk membuktikan dirimu, sekolah memberi kertas baru bernomor acak hari itu, kamu mengecapnya, lalu sekolah membandingkan dengan contoh cap. Dari contoh cap, orang gak bisa membuat stempelnya.
🧪 Tebakan cepat: database Portal Sekolah bocor, termasuk tabel passkey milik Siti. Bisakah isi tabel itu dipakai untuk login sebagai Siti? Tebak dulu, jawabannya ada di bagian "Yang disimpan server".
Dua upacara: daftar dan masuk
WebAuthn menyebut dua alurnya ceremony (upacara): registration (mendaftarkan passkey) dan authentication (masuk dengannya). Pihaknya tiga: server Portal Sekolah (disebut relying party, dengan rpId portal.sekolah.test), browser, dan authenticator, yaitu bagian perangkat yang menyimpan private key dan meminta sidik jari atau PIN.
Keduanya dimulai dengan challenge: 32 byte acak dari server, disimpan di session, berlaku sebentar (misalnya 60 detik), dan cuma boleh dipakai sekali. Challenge yang selalu baru membuat tanda tangan lama gak bisa dipakai ulang.
Daftar, langkah demi langkah
Siti mendaftarkan passkey dari HP-nya. Ini yang terjadi, persis seperti di Lab:
- Server membuat challenge dan mengirim opsi pendaftaran.
- Browser memanggil
navigator.credentials.create. Authenticator di HP meminta sidik jari Siti, membuat pasangan kunci ECDSA P-256 baru, dan memberi credential ID acak. - Browser mengirim balik credential ID, public key,
clientDataJSON, danauthenticatorData. - Server memeriksa, lalu menyimpan credential-nya.
const credential = await navigator.credentials.create({
publicKey: {
challenge, // Uint8Array 32 byte dari server
rp: { id: "portal.sekolah.test", name: "Portal Sekolah" },
user: { id: userIdBytes, name: "[email protected]", displayName: "Siti" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }], // -7 = ES256 (ECDSA P-256)
authenticatorSelection: { residentKey: "required", userVerification: "required" },
timeout: 60000, // milidetik
},
});
clientDataJSON ditulis browser, bukan halaman: {"type":"webauthn.create","challenge":"...","origin":"https://portal.sekolah.test","crossOrigin":false}. Server mengecek empat hal sebelum menyimpan:
| Cek | Harus sama dengan |
|---|---|
type |
"webauthn.create" (saat masuk: "webauthn.get") |
challenge |
challenge yang disimpan di session |
origin |
origin yang diizinkan: https://portal.sekolah.test |
32 byte pertama authenticatorData (rpIdHash) |
SHA-256 dari portal.sekolah.test, 87f02101... |
Dia juga mengecek flag di authenticatorData: UP (user present, ada sentuhan) dan UV (user verified, sidik jari atau PIN).
Masuk, langkah demi langkah
- Server membuat challenge baru.
- Browser memanggil
navigator.credentials.get({ publicKey: { challenge, rpId: "portal.sekolah.test", userVerification: "required" } }). Siti menempelkan jari. - Authenticator menandatangani
authenticatorDatayang disambung dengan SHA-256 dariclientDataJSON, memakai private key. - Server mencari credential berdasarkan credential ID, mengecek
type, challenge, origin, rpIdHash, dan flag, lalu memverifikasi tanda tangannya dengan public key yang disimpan. Cocok berarti Siti, dan server membuat session biasa.
Gak ada rahasia yang dikirim. Yang lewat jaringan cuma tanda tangan untuk satu challenge, yang gak berguna untuk challenge berikutnya.
Yang disimpan server
Satu baris per passkey:
| Kolom | Isinya |
|---|---|
credential_id |
ID acak dari authenticator |
public_key |
kunci publik P-256 |
sign_count |
angka counter terakhir |
backed_up |
apakah passkey ini tersinkron |
user_id |
pemiliknya, Siti (1) |
Gak ada password, gak ada hash password, gak ada private key. Public key cuma bisa memeriksa tanda tangan, gak bisa membuat tanda tangan. Jadi jawaban tebakannya: gak bisa. Isi tabel yang bocor gak cukup untuk login sebagai Siti.
Origin dan rpId
Passkey terikat ke rpId. Browser cuma menawarkan passkey portal.sekolah.test ke halaman di domain itu atau subdomain-nya, dan menuliskan origin halaman yang memanggil ke clientDataJSON. Halaman di domain lain gak bisa meminta tanda tangannya sama sekali, itu sebabnya passkey tahan phishing.
Server tetap mengecek origin dengan daftar yang diizinkan. Di Lab, halaman https://staging.portal.sekolah.test boleh memakai rpId portal.sekolah.test (subdomain), jadi browser setuju. Tapi server production cuma mengizinkan https://portal.sekolah.test, jadi permintaannya ditolak di cek origin.
Dibanding password ditambah kode OTP lewat SMS, passkey menutup tiga lubang sekaligus: gak ada kode yang bisa diketik ke situs palsu, gak ada SMS yang bisa dialihkan lewat SIM swap (nomor HP dipindahkan ke kartu SIM lain), dan server gak menyimpan rahasia yang bisa dicuri.
Passkey tersinkron dan counter
Passkey modern tersinkron: Google Password Manager, iCloud Keychain, atau 1Password menyalin private key secara terenkripsi ke perangkat lain milik akun yang sama. Siti mendaftar dari HP, lalu laptopnya bisa langsung masuk dengan credential ID dan public key yang sama, tanpa mendaftar lagi. Flag BE (backup eligible) dan BS (backed up) di authenticatorData memberi tahu server bahwa passkey-nya tersinkron.
Signature counter (signCount) dikirim di setiap login. Kunci keamanan USB menaikkannya tiap dipakai: 1, 2, 3. Kalau angka yang datang gak lebih besar dari yang tersimpan, server curiga kuncinya digandakan. Passkey tersinkron umumnya selalu mengirim 0, karena beberapa perangkat berbagi kunci yang sama. Aturannya: kalau angka lama dan baru sama-sama 0, terima. Selain itu, angka baru harus lebih besar.
Latihan: tiga function
Latihannya meniru bagian server dengan object sederhana:
createChallenge(userId, operation)mengembalikan{ challenge, userId, expiresIn: 60, type }.typeitu nilai asli diclientDataJSON:"webauthn.create"untuk"register","webauthn.get"untuk"authenticate". Challenge-nya 32 byte acak daricrypto.getRandomValues(new Uint8Array(32)), ditulis sebagai hex (64 karakter), jadi gak bisa ditebak dan beda tiap kali. Server sungguhan juga menyimpannya di session dan menghapusnya setelah dipakai.isValidPublicKey(publicKey): public key ditiru sebagai string. Sah kalau bertipe string (cek ini dulu), panjangnya minimal 10, dan diawali"pk_". Jadipk_abc123xyzsah,not_a_keydanpk_short(8 karakter) gak.matchCredential(credentialId, allowedCredentials)mencari credential denganidyang sama dantype === "public-key"(nilai type asli di WebAuthn), lalu mengembalikan object-nya, ataunull.
📌 Library-nya
Jangan mengurai authenticatorData dan format COSE sendiri. Di Laravel ada paket Laragear WebAuthn, di PHP umum ada web-auth/webauthn-lib, dan di Node ada SimpleWebAuthn (@simplewebauthn/server dan @simplewebauthn/browser). Kamu tetap yang menentukan rpId, origin yang diizinkan, dan aturan counter.
⚠️ Jebakan yang sering ketemu
- Challenge yang bisa ditebak atau dipakai ulang. Buat 32 byte acak tiap upacara, simpan di session, hapus setelah dipakai.
- Origin gak dicek, atau rpId salah. Daftar origin yang diizinkan harus persis, termasuk
https://dan tanpa garis miring di akhir. - Menolak counter 0. Passkey tersinkron selalu mengirim 0. Tolak cuma kalau angka barunya gak naik padahal pernah lebih dari 0.
- Satu passkey saja, tanpa cadangan. Izinkan beberapa passkey per user dan sediakan pemulihan akun (lesson Account Recovery & Lockout).
TL;DR: Passkey: private key tetap di perangkat, public key di server. Daftar: server memberi challenge acak, authenticator membuat pasangan kunci ES256, server mengecek type webauthn.create, challenge, origin, dan rpIdHash, lalu menyimpan credential ID, public key, counter, dan status backup, tanpa rahasia apa pun. Masuk: challenge baru, authenticator menandatanganinya, server memverifikasi dengan public key dan mengecek webauthn.get, origin, rpIdHash, dan counter (0 dan 0 boleh). Passkey terikat ke rpId, jadi tahan phishing, dan bisa tersinkron ke perangkat lain lewat Google Password Manager atau iCloud Keychain. Di latihan: ch_ + userId + _ + operation, expiresIn 60, kunci pk_ minimal 10 karakter, dan credential dengan type public-key atau null.