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 Passkey

Kita 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 key

2. 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
    ▼
    ✅ Authenticated

Server 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/webauthn

Library 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 Counter

Satu 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
RPOrigins

RP, atau Relying Party, pada dasarnya adalah aplikasi Anda.

Untuk local development:

RPID      = localhost
RPOrigins = http://localhost:5173

Untuk production, Anda bisa menggunakan:

RPID      = example.com
RPOrigins = https://example.com

Library membutuhkan RP ID dan RP origins untuk dikonfigurasi agar operasi WebAuthn dapat berjalan. (GitHub)

Registration: Begin

Ketika user mengklik:

Create Passkey

React memanggil:

POST /register/begin

Go 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
  │
  ▼
Authenticator

Apa yang Terjadi di Dalam Authenticator?

Di sinilah bagian yang menarik terjadi.

Misalnya user mengklik:

Create Passkey

Browser meminta authenticator untuk membuat credential untuk aplikasi Anda.

Authenticator membuat key pair:

Private Key 🔐
      +
Public Key

Private 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: 0

Tetapi 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/finish

Contohnya:

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
Password

mereka mengklik:

Sign in with Passkey

React memanggil:

POST /login/begin

Go 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/finish

Go 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
       ↓
       ✅
   Authenticated

Aplikasi 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│
└────┬─────┘
     │
     ▼
   ✅ Login

Apa Sebenarnya yang Disimpan Server?

Dengan autentikasi berbasis password, database Anda mungkin berisi:

User
 ├── Email
 └── Password Hash

Dengan passkey, database justru menyimpan WebAuthn credential:

User
 └── Passkey
      ├── Credential ID
      ├── Public Key
      └── Sign Counter

Public 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 authentication

Apa Bedanya dengan Password?

Dengan password:

User
  │
  │ password
  ▼
Website

User mengetahui sebuah secret dan mengirimkan secret tersebut ke website.

Dengan passkey:

User
  │
  ▼
Authenticator
  │
  │ private key 🔐
  ▼
Signature
  │
  ▼
Website

User 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
   │
   ▼
Website

Autentikasi 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 counter

Jangan 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]SessionData

mungkin sudah cukup.

Untuk production, Anda bisa menggunakan:

Redis 
Database 
Secure server-side session

HTTPS di Production

WebAuthn dirancang untuk bekerja dalam secure context.

Untuk production, aplikasi Anda seharusnya menggunakan:

https://example.com

bukan:

http://example.com

Local 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 key

Login

Server
  │
  │ "Here's a new challenge."
  ▼
Browser
  │
  │ "Authenticate."
  ▼
Authenticator
  │
  │ signs challenge
  │ using private key 🔐
  ▼
Browser
  │
  │ signed response
  ▼
Server
  │
  │ verifies using public key
  ▼
  ✅ Authenticated

Sesederhana 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.

Kode Sumber

https://github.com/michaelwp/passkey-go-react