Kata sandi adalah titik terlemah di hampir setiap sistem autentikasi yang pernah saya bangun. Pengguna memakai ulang kata sandi yang sama, halaman phishing memanennya, dan setiap alur reset password berujung menjadi tiket support baru. Passkey menyelesaikan akar masalah ini, bukan sekadar menambalnya: passkey menggantikan rahasia bersama dengan pasangan kunci publik yang dibuat dan dikunci ke autentikator perangkat, sehingga tidak ada apa pun di database Anda yang bisa dicuri penyerang untuk login di tempat lain.
Tulisan ini membahas cara menambahkan passkey ke proyek Next.js App Router menggunakan API browser WebAuthn, yang dibungkus oleh library SimpleWebAuthn agar Anda tidak perlu menulis sendiri parsing CBOR dan verifikasi attestation. Saya akan membahas proses registrasi dan login, apa yang perlu disimpan di server, bagaimana pengecekan challenge dan origin benar-benar melindungi Anda, serta opsi cadangan untuk sebagian kecil pengguna dengan browser lama atau perangkat kantor yang dikunci kebijakan.
Apa sebenarnya passkey itu
Passkey adalah nama yang digunakan pengguna untuk menyebut kredensial WebAuthn: pasangan kunci asimetris di mana kunci privat tidak pernah meninggalkan autentikator, baik itu secure enclave di ponsel, autentikator platform di laptop, atau security key fisik. Browser dan sistem operasi yang menangani kriptografinya; server Anda hanya pernah melihat kunci publik dan challenge yang telah ditandatangani. WebAuthn adalah standar dasar dari W3C dan FIDO Alliance, dan menjadi komponen inti dari FIDO2.
- Tidak ada rahasia bersama yang dikirim atau disimpan, sehingga tidak ada padanan hash kata sandi yang bisa dibobol penyerang secara offline
- Tanda tangan terikat ketat pada origin yang memintanya, sehingga halaman phishing klasik yang meniru halaman login Anda tidak bisa memperoleh kredensial yang bisa dipakai
- Mekanisme sinkronisasi dari Apple, Google, dan Microsoft memungkinkan passkey yang dibuat di satu perangkat langsung berfungsi di perangkat lain milik pengguna tanpa langkah pendaftaran tambahan
Jika aplikasi Anda sudah memiliki alur kata sandi atau magic link, perlakukan passkey sebagai opsi tambahan terlebih dahulu. Biarkan pengguna menambahkan passkey dari halaman pengaturan akun, lalu jadikan itu metode default yang disarankan saat login setelah adopsi meningkat, alih-alih memaksa migrasi total sejak hari pertama.
Proses registrasi (registration ceremony)
Mendaftarkan passkey adalah pertukaran empat langkah antara server dan browser Anda. Server Anda tidak pernah membuat pasangan kunci sendiri; server hanya menerbitkan sekumpulan opsi yang ditandatangani lalu memverifikasi hasil yang dibuat oleh autentikator.
- Klien meminta opsi registrasi ke server Anda, dan server harus sudah dalam keadaan terautentikasi karena proses ini menautkan kredensial baru ke akun yang sudah dikenal
- Server memanggil fungsi bantu seperti generateRegistrationOptions untuk membuat challenge acak beserta informasi relying party dan pengguna, lalu menyimpan challenge tersebut di tempat yang bisa diperiksa kembali nanti
- Browser memanggil metode create dari WebAuthn dengan opsi tersebut, memicu prompt biometrik atau PIN dari platform, dan mengembalikan objek attestation yang sudah ditandatangani
- Server memverifikasi attestation tersebut terhadap challenge yang tersimpan dan origin yang diharapkan, lalu menyimpan kunci publik, ID kredensial, dan transport yang didukung ke data pengguna
// app/api/passkeys/register/options/route.ts
import { generateRegistrationOptions } from "@simplewebauthn/server"
import { NextResponse } from "next/server"
import { getSession } from "@/lib/session"
import { getUserAuthenticators } from "@/lib/db/authenticators"
export async function POST() {
const session = await getSession()
if (!session?.user) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 })
}
const existing = await getUserAuthenticators(session.user.id)
const options = await generateRegistrationOptions({
rpName: "Acme App",
rpID: process.env.WEBAUTHN_RP_ID!, // e.g. "app.example.com"
userID: new TextEncoder().encode(session.user.id),
userName: session.user.email,
attestationType: "none",
excludeCredentials: existing.map((a) => ({
id: a.credentialId,
transports: a.transports,
})),
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred",
},
})
// Persist the challenge server-side (session or short-lived DB row)
// so /verify can check it against the response the browser returns.
await saveChallenge(session.user.id, options.challenge)
return NextResponse.json(options)
}Daftar excludeCredentials lebih penting dari yang terlihat: mengirim ID kredensial pengguna yang sudah ada ke generateRegistrationOptions mencegah autentikator yang sama diam-diam mendaftarkan passkey duplikat, yang jika dibiarkan akan membingungkan pengguna karena muncul dua entri hampir identik di pengelola kata sandi mereka. Ada baiknya juga mencatat setiap percobaan verifikasi yang gagal selama masa rollout, karena lonjakan biasanya menandakan masalah selisih waktu (clock skew) antara server Anda dan jendela kedaluwarsa challenge, bukan serangan sungguhan.
Menghubungkan alur registrasi di sisi klien
Di sisi klien, paket browser dari SimpleWebAuthn menyediakan fungsi bantu startRegistration yang membungkus pemanggilan create WebAuthn mentah, mengonversi field base64url yang dikirim server menjadi ArrayBuffer yang diharapkan API browser, dan menyeragamkan penanganan error di berbagai browser. Pola di bawah ini mengambil opsi, memanggil API browser, lalu mengirim hasilnya kembali untuk diverifikasi.
// app/components/RegisterPasskeyButton.tsx
"use client"
import { startRegistration } from "@simplewebauthn/browser"
import { useState } from "react"
export function RegisterPasskeyButton() {
const [status, setStatus] = useState<"idle" | "busy" | "done" | "error">("idle")
async function handleClick() {
setStatus("busy")
try {
const optionsRes = await fetch("/api/passkeys/register/options", { method: "POST" })
const options = await optionsRes.json()
// Browser prompts Face ID / Touch ID / Windows Hello / security key
const attestation = await startRegistration({ optionsJSON: options })
const verifyRes = await fetch("/api/passkeys/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(attestation),
})
if (!verifyRes.ok) throw new Error("verification failed")
setStatus("done")
} catch (err) {
console.error(err)
setStatus("error")
}
}
return (
<button onClick={handleClick} disabled={status === "busy"}>
{status === "busy" ? "Waiting for device..." : "Add a passkey"}
</button>
)
}WebAuthn membutuhkan secure context. Fitur ini berjalan di localhost untuk pengembangan, tetapi di produksi relying party ID harus cocok dengan domain yang dilayani lewat HTTPS, dan tidak bisa lintas domain yang tidak berkaitan. Jika staging Anda berjalan di top-level domain berbeda dari produksi, Anda perlu mendaftarkan kredensial terpisah per relying party ID; passkey tidak berpindah otomatis antar domain.
Apa yang perlu disimpan, dan dari mana keamanannya sebenarnya berasal
Database Anda pada akhirnya hanya menyimpan sedikit data: ID kredensial, kunci publik, penghitung tanda tangan, dan transport yang dilaporkan autentikator. Semua itu tidak sensitif seperti halnya hash kata sandi, karena tidak satu pun dapat digunakan ulang untuk mengautentikasi sebagai pengguna tanpa kunci privat yang bersesuaian. Artinya, kebocoran database yang membocorkan seluruh tabel kredensial Anda pun tidak serta-merta memberi penyerang cara untuk login sebagai siapa pun.
| Data | Lokasi penyimpanan | Mengapa penting |
|---|---|---|
| Kunci publik + ID kredensial | Database Anda, terkait dengan pengguna | Dipakai untuk memverifikasi tanda tangan berikutnya; tidak berguna bagi penyerang tanpa kunci privat |
| Challenge | Sesi server atau cache berumur pendek | Mencegah pemutaran ulang respons registrasi atau login lama |
| Kunci privat | Tidak pernah meninggalkan autentikator | Alasan utama passkey tahan terhadap kebocoran database dan phishing |
Proses login (authentication ceremony)
Autentikasi mirip dengan registrasi tetapi lebih sederhana karena tidak ada attestation yang perlu diurai, hanya assertion yang sudah ditandatangani. Server membuat opsi autentikasi, browser memanggil metode get dari WebAuthn, dan autentikator menandatangani challenge dengan kunci privat yang sudah dimilikinya.
- Mengatur userVerification ke preferred atau required memaksa pemeriksaan biometrik atau PIN, bukan sekadar deteksi kehadiran, dan ini penting untuk hal yang sensitif secara keamanan
- Mengirim allowCredentials dengan ID kredensial yang sudah dikenal mempercepat tampilan saat Anda sudah tahu siapa yang login, tetapi mengosongkannya memungkinkan login discoverable tanpa username di platform yang mendukungnya
- Setelah verifyAuthenticationResponse berhasil, perbarui penghitung tanda tangan yang tersimpan; penghitung yang mundur adalah sinyal bahwa kredensial mungkin telah diduplikasi dan layak ditandai
Kredensial discoverable (resident key) adalah yang memungkinkan pengguna cukup menekan "masuk dengan passkey" tanpa mengetik username terlebih dahulu. Mengatur residentKey ke required saat registrasi adalah yang membuat pengalaman ini mungkin, dan ini adalah bentuk terdekat WebAuthn dengan login tanpa kata sandi dan tanpa username yang sesungguhnya.
Opsi cadangan bagi pengguna tanpa passkey
Belum semua perangkat mendukung passkey dengan baik, dan sebagian pengguna berada di perangkat bersama atau terkunci kebijakan di mana autentikator platform dinonaktifkan. Jangan jadikan passkey satu-satunya pintu masuk akun.
- Pertahankan alur kata sandi atau magic link lewat email sebagai metode kedua, alih-alih menghapusnya begitu passkey diluncurkan
- Sediakan cara mendaftarkan security key portabel seperti YubiKey bagi pengguna yang autentikator platformnya tidak tersedia atau dinonaktifkan kebijakan IT
- Lakukan deteksi fitur dengan PublicKeyCredential sebelum menampilkan antarmuka passkey sama sekali, sehingga browser yang tidak mendukung langsung melihat metode cadangan alih-alih tombol yang rusak
Passkey adalah salah satu peningkatan keamanan langka yang juga memperbaiki pengalaman pengguna, asalkan proses registrasi dan login disambungkan dengan benar. Bagian berat sudah distandarkan oleh WebAuthn sendiri; library seperti SimpleWebAuthn hanya membebaskan Anda dari menulis ulang decoding CBOR dan verifikasi attestation secara manual. Mulailah dengan menawarkan passkey sebagai opsi tambahan, pastikan jalur penyimpanan dan cadangan sudah benar, pantau sinyal penghitung tanda tangan dan kegagalan verifikasi selama rollout, dan kolom kata sandi bisa perlahan menjadi opsional alih-alih wajib.

