Passkey di Next.js: Autentikasi Tanpa Kata Sandi dengan WebAuthn

Foto oleh Tony Webster via Openverse (CC BY 2.0)
Passkey adalah kredensial WebAuthn yang dibangun di atas pasangan kunci asimetris, bukan rahasia bersama. Kunci privat tidak pernah meninggalkan autentikator perangkat pengguna, sehingga berbeda dari kata sandi, tidak ada apa pun yang bisa dipakai ulang oleh penyerang jika terjadi kebocoran server atau halaman phishing.
Anda bisa memanggil API browser WebAuthn secara langsung, tetapi kebanyakan tim memakai library bantu seperti SimpleWebAuthn untuk membuat opsi registrasi dan autentikasi serta memverifikasi responsnya, karena library ini menangani parsing CBOR dan verifikasi attestation untuk Anda. Route handler di Next.js App Router adalah tempat yang wajar untuk menaruh endpoint opsi dan verifikasi di sisi server.
Ya, dan penting untuk selalu menyediakan opsi cadangan. Lakukan deteksi fitur PublicKeyCredential sebelum menampilkan antarmuka passkey, dan pertahankan alur kata sandi atau magic link di samping passkey agar pengguna dengan browser yang tidak mendukung atau perangkat terkunci kebijakan tidak pernah terblokir.
WebAuthn mengikat setiap kredensial ke secure context dan relying party ID tertentu yang terkait domain Anda, yang mencegah penyerang di jaringan tidak aman mencegat atau memutar ulang proses tersebut. Fitur ini berjalan di localhost untuk pengembangan lokal, tetapi trafik produksi harus dilayani lewat HTTPS dengan relying party ID yang sesuai.
Yang tersimpan hanya kunci publik, ID kredensial, penghitung tanda tangan, dan transport yang dilaporkan autentikator. Semua data ini tidak sensitif seperti hash kata sandi, karena tidak bisa dipakai untuk mengautentikasi sebagai pengguna tanpa kunci privat yang bersesuaian, yang tetap berada di perangkat pengguna.

Foto oleh Tony Webster via Openverse (CC BY 2.0)
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.
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.
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.
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.
// 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.
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.
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 |
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.
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.
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.
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.