Di artikel sebelumnya, Passkeys: Cara Login yang Lebih Sederhana dan Lebih Aman, kita sudah membahas keunggulan passkey dibandingkan metode login konvensional menggunakan username dan password. Kita melihat bagaimana passkey menggunakan kriptografi kunci publik, mengapa passkey tahan terhadap phishing, dan mengapa private key Anda tidak pernah perlu dikirim ke sebuah website.
Sekarang, mari kita mulai praktiknya.
Dalam artikel ini, kita akan membuat sistem autentikasi passkey yang sangat sederhana menggunakan Go dan React. Kita akan melihat apa yang sebenarnya terjadi ketika pengguna mendaftarkan passkey, bagaimana browser berkomunikasi dengan authenticator, dan bagaimana server memverifikasi login tanpa pernah menerima password atau private key.
Kita akan membuat implementasinya sengaja sederhana agar kita bisa fokus memahami alurnya, bukan membangun sistem autentikasi yang siap digunakan di production.
Apa yang Akan Kita Bangun
Aplikasi kita akan mendukung dua operasi:
Register Passkey
↓
Login with PasskeyKita akan menggunakan:
- React untuk frontend
- Go untuk backend
- WebAuthn untuk autentikasi passkey
- go-webauthn/webauthn untuk implementasi di Go
- Database sederhana atau in-memory store untuk demonstrasi
Library Go WebAuthn menyediakan API tingkat tinggi seperti BeginRegistration, FinishRegistration, BeginLogin, dan FinishLogin, sehingga kita tidak perlu mengimplementasikan protokol WebAuthn sendiri. (Go Packages)
Bagaimana Passkey Masuk ke Dalam Arsitektur
Sebelum melihat kodenya, mari kita pahami dulu arsitekturnya.
┌──────────────────────┐
│ React App │
│ │
│ Register Passkey │
│ Login with Passkey │
└──────────┬───────────┘
│
HTTPS / JSON
│
▼
┌──────────────────────┐
│ Go Server │
│ │
│ WebAuthn │
│ │
│ Create challenge │
│ Verify response │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Database │
│ │
│ User │
│ Credential ID │
│ Public Key │
│ Sign Counter │
└──────────────────────┘
┌─────────────────────────────┐
│ User's Authenticator │
│ │
│ iPhone / Android │
│ Touch ID / Face ID │
│ Windows Hello │
│ Security Key │
│ │
│ 🔐 Private Key │
└─────────────────────────────┘Ada satu detail yang sangat penting dalam diagram ini:
Private key tetap berada di authenticator.
Go server Anda menyimpan public key, tetapi tidak pernah menerima private key.
Dua WebAuthn Ceremony
Autentikasi passkey terdiri dari dua ceremony utama.
1. Registration
Registration terjadi ketika pengguna membuat passkey untuk aplikasi Anda.
Go Server
│
│ Generate challenge
▼
React
│
│ navigator.credentials.create()
▼
Authenticator
│
│ Generate key pair
│
│ Private Key 🔐
│ Public Key
▼
React
│
│ Send credential
▼
Go Server
│
│ Verify credential
▼
Database
│
└── Store public key2. Login
Login terjadi ketika pengguna ingin melakukan autentikasi di kemudian hari.
Go Server
│
│ Generate challenge
▼
React
│
│ navigator.credentials.get()
▼
Authenticator
│
│ Sign challenge
│ using private key 🔐
▼
React
│
│ Send signed response
▼
Go Server
│
│ Verify signature
│ using public key
▼
✅ AuthenticatedServer membuat challenge baru untuk setiap ceremony. Authenticator kemudian menggunakan private key untuk menghasilkan response yang dapat diverifikasi oleh server.
Menyiapkan Library Go WebAuthn
Kita akan menggunakan go-webauthn/webauthn.
Install:
go mod init example.com/passkey-demo
go get github.com/go-webauthn/webauthnLibrary ini menyediakan implementasi WebAuthn di sisi server dan menyediakan API tingkat tinggi untuk memulai dan menyelesaikan ceremony registration dan authentication. (Go Packages)
Membuat User
Model user sederhana bisa terlihat seperti ini:
type User struct {
ID []byte
Name string
DisplayName string
Credentials []webauthn.Credential
}User perlu mengimplementasikan interface yang diharapkan oleh library WebAuthn:
func (u *User) WebAuthnID() []byte {
return u.ID
}
func (u *User) WebAuthnName() string {
return u.Name
}
func (u *User) WebAuthnDisplayName() string {
return u.DisplayName
}
func (u *User) WebAuthnCredentials() []webauthn.Credential {
return u.Credentials
}Hubungan yang paling penting adalah:
User
│
└── Credentials
│
├── Credential ID
├── Public Key
└── Sign CounterSatu user bisa memiliki beberapa passkey.
Contohnya:
Michael
│
├── MacBook passkey
├── iPhone passkey
└── Security keyJadi, jangan mendesain database dengan asumsi bahwa satu user hanya bisa memiliki satu credential.Mengonfigurasi WebAuthn
Buat instance WebAuthn:
webAuthn, err := webauthn.New(&webauthn.Config{
RPDisplayName: "Passkey Demo",
RPID: "localhost",
RPOrigins: []string{
"http://localhost:5173",
},
})
if err != nil {
log.Fatal(err)
}Ada dua nilai konfigurasi yang sangat penting:
RPID
RPOriginsRP, atau Relying Party, pada dasarnya adalah aplikasi Anda.
Untuk local development:
RPID = localhost
RPOrigins = http://localhost:5173Untuk production, Anda bisa menggunakan:
RPID = example.com
RPOrigins = https://example.comLibrary membutuhkan RP ID dan RP origins untuk dikonfigurasi agar operasi WebAuthn dapat berjalan. (GitHub)
Registration: Begin
Ketika user mengklik:
Create PasskeyReact memanggil:
POST /register/beginGo server mencari user dan memulai registration ceremony:
func beginRegistration(w http.ResponseWriter, r *http.Request) {
user := getUser()
options, session, err :=
webAuthn.BeginRegistration(user)
if err != nil {
http.Error(
w,
err.Error(),
http.StatusInternalServerError,
)
return
}
saveSession(session)
json.NewEncoder(w).Encode(options)
}Bagian yang paling penting adalah:
options, session, err :=
webAuthn.BeginRegistration(user)Library membuat registration options dan session data, termasuk challenge.SessionData perlu disimpan dengan aman karena nantinya akan dibutuhkan ketika registration ceremony diselesaikan. (Go Packages)
React Memulai Registration
WebAuthn API pada browser menyediakan:
navigator.credentials.create()Secara konsep:
const credential =
await navigator.credentials.create({
publicKey: options.publicKey
});Browser kemudian berkomunikasi dengan authenticator milik sistem operasi.
Dalam aplikasi React yang sebenarnya, biasanya Anda akan menggunakan library WebAuthn untuk browser seperti @simplewebauthn/browser daripada menangani sendiri seluruh proses konversi binary dan Base64URL.
Secara konsep, alurnya tetap:
React
│
│ credentials.create()
▼
Browser
│
▼
AuthenticatorApa yang Terjadi di Dalam Authenticator?
Di sinilah bagian yang menarik terjadi.
Misalnya user mengklik:
Create PasskeyBrowser meminta authenticator untuk membuat credential untuk aplikasi Anda.
Authenticator membuat key pair:
Private Key 🔐
+
Public KeyPrivate key tetap terlindungi di dalam authenticator.
Public key dapat didaftarkan ke server.
Jadi, database Anda nantinya mungkin berisi:
User ID: 123
Credential ID: abc123...
Public Key: ...
Sign Counter: 0Tetapi tidak berisi:
Private Key ❌Inilah perbedaan fundamental antara passkey dan sistem berbasis password.
Registration: Finish
Setelah browser membuat credential, React mengirimkan hasilnya kembali:
POST /register/finishContohnya:
await fetch("/register/finish", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(result)
});Go server kemudian memverifikasinya:
func finishRegistration(w http.ResponseWriter, r *http.Request) {
user := getUser()
session := loadSession()
credential, err :=
webAuthn.FinishRegistration(
user,
*session,
r,
)
if err != nil {
http.Error(
w,
"registration failed",
http.StatusBadRequest,
)
return
}
user.Credentials =
append(user.Credentials, *credential)
saveUser(user)
json.NewEncoder(w).Encode(map[string]string{
"message": "passkey registered",
})
}FinishRegistration melakukan parsing dan validasi terhadap response dari authenticator berdasarkan user dan session data yang tersimpan. (GitHub)
Kita tidak perlu mengimplementasikan secara manual hal-hal seperti:
- validasi challenge
- parsing credential
- validasi origin
- validasi RP ID
- parsing authenticator data
- validasi cryptographic signature
Itulah tepatnya alasan kita menggunakan library WebAuthn.
Login: Begin
Sekarang bayangkan user kembali ke aplikasi.
Alih-alih:
Email
Passwordmereka mengklik:
Sign in with PasskeyReact memanggil:
POST /login/beginGo server memulai authentication ceremony:
func beginLogin(w http.ResponseWriter, r *http.Request) {
user := getUser()
options, session, err :=
webAuthn.BeginLogin(user)
if err != nil {
http.Error(
w,
err.Error(),
http.StatusInternalServerError,
)
return
}
saveSession(session)
json.NewEncoder(w).Encode(options)
}Sekali lagi, server membuat challenge baru.
Dokumentasi library Go saat ini menjelaskan BeginLogin sebagai fungsi yang membuat assertion data yang dikirim ke user agent dan mengembalikan SessionData yang nantinya harus diberikan ke FinishLogin. (Go Packages)
React Meminta Authenticator untuk Sign In
Browser memanggil:
navigator.credentials.get()Secara konsep:
const credential =
await navigator.credentials.get({
publicKey: options.publicKey
});Authenticator mencari passkey yang sesuai dan meminta user untuk melakukan autentikasi.
Contohnya:
┌──────────────────────────────┐
│ Sign in to Example │
│ │
│ 👤 Michael │
│ │
│ [ Touch ID ] │
└──────────────────────────────┘Setelah user menyetujuinya, authenticator menandatangani challenge menggunakan private key.
Secara konsep:
signature = Sign(privateKey, challenge)Private key itu sendiri tidak pernah meninggalkan authenticator.
Login: Finish
React mengirim authentication response ke:
POST /login/finishGo server memvalidasinya:
func finishLogin(w http.ResponseWriter, r *http.Request) {
user := getUser()
session := loadSession()
_, err :=
webAuthn.FinishLogin(
user,
*session,
r,
)
if err != nil {
http.Error(
w,
"authentication failed",
http.StatusUnauthorized,
)
return
}
createApplicationSession(user)
json.NewEncoder(w).Encode(map[string]string{
"message": "login successful",
})
}Library memvalidasi authentication response berdasarkan credential milik user dan session data yang tersimpan. ((Go Packages)
Jika semuanya valid:
Signature valid
+
Challenge valid
+
Credential valid
+
RP validation successful
↓
✅
AuthenticatedAplikasi Anda kemudian dapat membuat application session seperti biasa, misalnya menggunakan secure HTTP-only cookie.
Alur Registration Lengkap
Sekarang kita bisa menggabungkan seluruh proses registration:
┌──────────┐
│ React │
└────┬─────┘
│
│ POST /register/begin
▼
┌──────────┐
│ Go │
└────┬─────┘
│
│ challenge + options
▼
┌──────────┐
│ React │
└────┬─────┘
│
│ credentials.create()
▼
┌──────────────────┐
│ Authenticator │
│ │
│ Private Key 🔐 │
│ Public Key │
└────────┬─────────┘
│
│ credential
▼
┌──────────┐
│ React │
└────┬─────┘
│
│ POST /register/finish
▼
┌──────────┐
│ Go │
│ │
│ Verify │
└────┬─────┘
│
│ Store credential
▼
┌──────────┐
│ Database │
└──────────┘Alur Login Lengkap
Dan untuk login:
┌──────────┐
│ React │
└────┬─────┘
│
│ POST /login/begin
▼
┌──────────┐
│ Go │
└────┬─────┘
│
│ challenge
▼
┌──────────┐
│ React │
└────┬─────┘
│
│ credentials.get()
▼
┌──────────────────┐
│ Authenticator │
│ │
│ Private Key 🔐 │
│ │ │
│ ▼ │
│ Sign challenge │
└────────┬─────────┘
│
│ signed response
▼
┌──────────┐
│ React │
└────┬─────┘
│
│ POST /login/finish
▼
┌──────────┐
│ Go │
│ │
│ Verify │
│ signature│
└────┬─────┘
│
▼
✅ LoginApa Sebenarnya yang Disimpan Server?
Dengan autentikasi berbasis password, database Anda mungkin berisi:
User
├── Email
└── Password HashDengan passkey, database justru menyimpan WebAuthn credential:
User
└── Passkey
├── Credential ID
├── Public Key
└── Sign CounterPublic key bukanlah sesuatu yang rahasia.
Memang itu intinya.
Server dapat menggunakannya untuk memverifikasi signature tanpa pernah memiliki private key yang bersesuaian.
Mengapa Challenge Penting?
Anda mungkin bertanya:
Kalau server sudah memiliki public key, kenapa seseorang tidak bisa begitu saja melakukan replay terhadap login response yang lama?
Di sinilah challenge berperan.
Server membuat challenge baru yang acak dan sulit ditebak:
challenge = "7f83a1c9..."Authenticator menandatanganinya:
signature = Sign(privateKey, challenge)Server kemudian memverifikasi:
Verify(
publicKey,
challenge,
signature
)Response lama tidak akan valid untuk challenge yang baru.
Jadi, secara konsep:
Old challenge
│
▼
Old signature
│
└── ❌ Can't simply replay it
New challenge
│
▼
New signature
│
└── ✅ Valid authenticationApa Bedanya dengan Password?
Dengan password:
User
│
│ password
▼
WebsiteUser mengetahui sebuah secret dan mengirimkan secret tersebut ke website.
Dengan passkey:
User
│
▼
Authenticator
│
│ private key 🔐
▼
Signature
│
▼
WebsiteUser tidak mengirimkan secret itu sendiri.
Authenticator membuktikan bahwa ia memiliki private key.
Inilah salah satu alasan fundamental mengapa passkey dapat memberikan autentikasi yang tahan terhadap phishing.
Bagaimana dengan Face ID dan Sidik Jari?
Ada satu hal penting yang perlu diklarifikasi:
Website Anda tidak menerima data sidik jari atau wajah user.
Contohnya:
Face ID
│
▼
Authenticator
│
│ unlock/use credential
▼
Private Key 🔐
│
▼
Signature
│
▼
WebsiteAutentikasi biometrik terjadi secara lokal di perangkat user.
Server Anda menerima WebAuthn authentication response, bukan data biometrik. (GoBlog)
Skema Database Sederhana
Database sederhana bisa terlihat seperti:
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL
);
CREATE TABLE passkeys (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
credential_id BYTEA NOT NULL,
public_key BYTEA NOT NULL,
sign_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL
);Dalam implementasi nyata, gunakan struktur credential yang dibutuhkan oleh library WebAuthn Anda, bukan berasumsi bahwa field-field ini saja sudah cukup.
Hubungan konseptual yang penting adalah:
users
│
└── passkeys
│
├── credential ID
├── public key
└── sign counterJangan Mengimplementasikan WebAuthn Sendiri
Pada titik ini, Anda mungkin berpikir:
“Kelihatannya tidak terlalu rumit. Saya bisa implementasikan sendiri cryptography-nya.”
Jangan.
WebAuthn jauh lebih kompleks daripada sekadar membuat key pair dan memverifikasi signature.
Ada:
- challenges
- origins
- RP IDs
- client data
- authenticator data
- credential IDs
- COSE keys
- user verification
- flags
- signature counters
- extensions
- attestation
- discoverable credentials
Library WebAuthn yang sudah matang menangani detail protokol dan proses validasi tersebut untuk Anda.
Untuk aplikasi Go kita, go-webauthn/webauthn menyediakan API tingkat tinggi yang menangani ceremony tersebut. (Go Packages)
Satu Detail Penting: Session Data
Ada satu bagian yang sengaja kita sederhanakan:
saveSession(session)Ketika Anda memanggil:
BeginRegistration()atau:
BeginLogin()library mengembalikan SessionData.
Anda perlu menyimpan data tersebut sampai fungsi Finish… yang sesuai dipanggil.
Contohnya:
/register/begin
│
├── challenge
└── SessionData
│
▼
storage
│
▼
/register/finish
│
└── FinishRegistration()Session data harus disimpan dengan aman dan dikaitkan dengan user/browser session yang sesuai. Dokumentasi library secara khusus memperingatkan bahwa user agent tidak boleh dapat memodifikasi data tersebut. (Go Packages)
Untuk demo:
map[sessionID]SessionDatamungkin sudah cukup.
Untuk production, Anda bisa menggunakan:
Redis
Database
Secure server-side sessionHTTPS di Production
WebAuthn dirancang untuk bekerja dalam secure context.
Untuk production, aplikasi Anda seharusnya menggunakan:
https://example.combukan:
http://example.comLocal development umumnya menggunakan localhost, yang diperlakukan secara khusus oleh browser untuk keperluan development.
Arsitektur production yang umum bisa terlihat seperti:
Internet
│
▼
┌───────────┐
│ HTTPS │
│ Proxy │
└─────┬─────┘
│
▼
┌──────────────┐
│ Go API │
│ │
│ WebAuthn │
└──────┬───────┘
│
▼
┌───────────┐
│ PostgreSQL│
└───────────┘Mental Model
Kalau Anda hanya mengingat satu hal dari artikel ini, ingatlah ini:
Registration
Server
│
│ "Here's a challenge."
▼
Browser
│
│ "Create a passkey."
▼
Authenticator
│
├── Private key 🔐
└── Public key
│
▼
Server
│
└── stores public keyLogin
Server
│
│ "Here's a new challenge."
▼
Browser
│
│ "Authenticate."
▼
Authenticator
│
│ signs challenge
│ using private key 🔐
▼
Browser
│
│ signed response
▼
Server
│
│ verifies using public key
▼
✅ AuthenticatedSesederhana itulah passkey secara garis besar.
Server tidak membutuhkan password Anda.
Server tidak membutuhkan private key Anda.
Server tidak membutuhkan sidik jari Anda.
Server hanya perlu memverifikasi bahwa authenticator yang memiliki private key dapat menghasilkan response yang valid terhadap challenge dari server.
Arsitektur Akhir
Jika semuanya digabungkan:
┌───────────────────┐
│ React App │
│ │
│ Register │
│ Login │
└─────────┬─────────┘
│
WebAuthn API│
│
┌──────────────▼──────────────┐
│ Browser / OS │
│ │
│ navigator.credentials │
│ .create() │
│ .get() │
└──────────────┬──────────────┘
│
▼
┌──────────────────┐
│ Authenticator │
│ │
│ 🔐 Private Key │
│ │
│ Face ID │
│ Touch ID │
│ Windows Hello │
│ Security Key │
└──────────────────┘
HTTPS
│
▼
┌─────────────────────────┐
│ Go API │
│ │
│ go-webauthn │
│ │
│ BeginRegistration() │
│ FinishRegistration() │
│ │
│ BeginLogin() │
│ FinishLogin() │
└────────────┬────────────┘
│
▼
┌──────────────────┐
│ Database │
│ │
│ User │
│ Credential ID │
│ Public Key │
│ Sign Counter │
└──────────────────┘Ide utamanya sederhana:
Passkey tidak mengirimkan sebuah secret ke server. Sebaliknya, authenticator membuktikan bahwa ia memiliki private key, sementara server memverifikasi bukti tersebut menggunakan public key yang bersesuaian.
Dan itulah sisi praktis dari apa yang kita bahas di artikel sebelumnya: passkey bukan sekadar tombol login yang tampilannya berbeda. Passkey adalah model autentikasi yang secara fundamental berbeda.
