Passwordless & Passkeys — Auth

Passwordless authentication menghilangkan password sama sekali. Passkeys adalah implementasi modern yang menggunakan kriptografi kunci publik dan biometrik…

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:

  1. Server membuat challenge dan mengirim opsi pendaftaran.
  2. Browser memanggil navigator.credentials.create. Authenticator di HP meminta sidik jari Siti, membuat pasangan kunci ECDSA P-256 baru, dan memberi credential ID acak.
  3. Browser mengirim balik credential ID, public key, clientDataJSON, dan authenticatorData.
  4. 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

  1. Server membuat challenge baru.
  2. Browser memanggil navigator.credentials.get({ publicKey: { challenge, rpId: "portal.sekolah.test", userVerification: "required" } }). Siti menempelkan jari.
  3. Authenticator menandatangani authenticatorData yang disambung dengan SHA-256 dari clientDataJSON, memakai private key.
  4. 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:

📌 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

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.

Yang akan kamu pelajari