前言

「把密碼換成裝置裡的私鑰」——這就是 Passkey 的核心概念。 我們用最小可行的方式,寫一個可以真的註冊與登入的 WebAuthn 小服務。

目標

  • 公私鑰(FIDO2/WebAuthn)取代傳統密碼。
  • 實作 註冊(建立金鑰對)與 登入(challenge 簽章驗證)。
  • 後端:Go;前端:HTML + CSS + JavaScript,純瀏覽器原生 API(navigator.credentials.create/get)。
  • 用最小依賴打造能跑的 MVP,幫助你理解並能快速 PoC。

Passkey 是什麼?

  • 不再記密碼:使用者只需要用裝置上的生物辨識/PIN 解鎖「私鑰」。
  • 伺服器只存公鑰:挑戰(challenge)由伺服器發出,裝置用私鑰簽章回傳,伺服器用公鑰驗證。
  • 抗釣魚:RP(網站)綁定 + 起源(origin)驗證,降低釣魚風險。

Passkey 工作流程

註冊流程(Registration)

用戶首次建立 Passkey 時的流程:

sequenceDiagram participant User as 使用者 participant Browser as 瀏覽器 participant Server as 後端伺服器 participant Auth as 裝置認證器
(TouchID/FaceID/Windows Hello) User->>Browser: 點擊「註冊 Passkey」 Browser->>Server: POST /api/register/start
{ username } Server->>Browser: 回傳 CreationOptions
{ challenge, user, rp } Browser->>Auth: navigator.credentials.create() Auth->>User: 請求生物辨識驗證 User->>Auth: 指紋/臉部/PIN 驗證 Auth->>Auth: 產生公私鑰對 Auth->>Browser: 回傳 AttestationResponse
{ publicKey, signature } Browser->>Server: POST /api/register/finish
{ attestationObject } Server->>Server: 驗證 & 儲存公鑰 Server->>Browser: 註冊成功

登入流程(Authentication)

用戶使用已註冊的 Passkey 進行登入:

sequenceDiagram participant User as 使用者 participant Browser as 瀏覽器 participant Server as 後端伺服器 participant Auth as 裝置認證器 User->>Browser: 點擊「使用 Passkey 登入」 Browser->>Server: POST /api/login/start
{ username } Server->>Server: 產生隨機 challenge Server->>Browser: 回傳 RequestOptions
{ challenge, allowCredentials } Browser->>Auth: navigator.credentials.get() Auth->>User: 請求生物辨識驗證 User->>Auth: 指紋/臉部/PIN 驗證 Auth->>Auth: 用私鑰簽署 challenge Auth->>Browser: 回傳 AssertionResponse
{ signature, authenticatorData } Browser->>Server: POST /api/login/finish
{ signature, clientData } Server->>Server: 用公鑰驗證簽章 Server->>Browser: 登入成功

我們要做的 MVP

架構與流程

  • /:靜態頁(含兩個按鈕:註冊、登入)。
  • /api/register/start → 前端拿到 PublicKeyCredentialCreationOptions → 呼叫 navigator.credentials.create() → 把結果送到 /api/register/finish 完成註冊。
  • /api/login/start → 前端拿到 PublicKeyCredentialRequestOptions → 呼叫 navigator.credentials.get() → 把結果送到 /api/login/finish 完成登入。

狀態

  • 使用簡單的 in-memory 儲存(使用者與憑證),並用 cookie sid 對應一次性的 WebAuthn SessionData。
  • Demo 夠用,正式環境請改用資料庫與安全的 session store。

後端(Go)

使用社群穩定的 WebAuthn 套件,快速把 ceremony 跑起來。 以下程式以 github.com/go-webauthn/webauthn/webauthn 為例。

需要 Go 1.20+(建議 1.21/1.22)。 執行前先:go mod init passkey-mvp && go get github.com/go-webauthn/webauthn/webauthn github.com/go-webauthn/webauthn/protocol

檔案:main.go

  1package main
  2
  3import (
  4	"crypto/rand"
  5	"encoding/base64"
  6	"encoding/json"
  7	"log"
  8	"net/http"
  9	"sync"
 10	"time"
 11
 12	"github.com/go-webauthn/webauthn/protocol"
 13	"github.com/go-webauthn/webauthn/webauthn"
 14)
 15
 16// ===== In-memory 模擬儲存 =====
 17
 18type User struct {
 19	ID          uint64
 20	Name        string
 21	DisplayName string
 22	Credentials []webauthn.Credential
 23}
 24
 25func (u *User) WebAuthnID() []byte {
 26	b := make([]byte, 8)
 27	for i := 0; i < 8; i++ {
 28		b[i] = byte((u.ID >> (8 * i)) & 0xff)
 29	}
 30	return b
 31}
 32func (u *User) WebAuthnName() string        { return u.Name }
 33func (u *User) WebAuthnDisplayName() string { return u.DisplayName }
 34func (u *User) WebAuthnIcon() string        { return "" }
 35func (u *User) WebAuthnCredentials() []webauthn.Credential {
 36	return u.Credentials
 37}
 38
 39var (
 40	usersMu sync.Mutex
 41	users   = map[string]*User{} // key = username
 42	nextID  uint64 = 1
 43
 44	// 每個 session id 對應 WebAuthn 的暫存資料
 45	sessionMu sync.Mutex
 46	regSess   = map[string]*webauthn.SessionData{} // registration session
 47	authSess  = map[string]*webauthn.SessionData{} // authentication session
 48)
 49
 50func getOrCreateUser(username string) *User {
 51	usersMu.Lock()
 52	defer usersMu.Unlock()
 53	if u, ok := users[username]; ok {
 54		return u
 55	}
 56	u := &User{
 57		ID:          nextID,
 58		Name:        username,
 59		DisplayName: username,
 60	}
 61	nextID++
 62	users[username] = u
 63	return u
 64}
 65
 66func setCookie(w http.ResponseWriter, name, val string) {
 67	http.SetCookie(w, &http.Cookie{
 68		Name:     name,
 69		Value:    val,
 70		Path:     "/",
 71		HttpOnly: true,
 72		SameSite: http.SameSiteLaxMode,
 73		Expires:  time.Now().Add(15 * time.Minute),
 74	})
 75}
 76
 77func getCookie(r *http.Request, name string) string {
 78	c, err := r.Cookie(name)
 79	if err != nil {
 80		return ""
 81	}
 82	return c.Value
 83}
 84
 85// ===== WebAuthn 物件 =====
 86
 87var webAuthn *webauthn.WebAuthn
 88
 89func mustInitWebAuthn() {
 90	var err error
 91	webAuthn, err = webauthn.New(&webauthn.Config{
 92		RPDisplayName: "Passkey MVP",       // RP 顯示名稱
 93		RPID:          "localhost",         // RP ID(要和網域相符)
 94		RPOrigins:     []string{"http://localhost:8080"},
 95	})
 96	if err != nil {
 97		log.Fatalf("webauthn init error: %v", err)
 98	}
 99}
100
101// ===== Util =====
102
103func randomB64(n int) string {
104	b := make([]byte, n)
105	if _, err := rand.Read(b); err != nil {
106		panic(err)
107	}
108	return base64.RawURLEncoding.EncodeToString(b)
109}
110
111// ===== Handlers =====
112
113type startRegisterReq struct {
114	Username string `json:"username"`
115}
116
117func handleRegisterStart(w http.ResponseWriter, r *http.Request) {
118	// 1) 前端傳 username;在真實系統會有登入或帳號建立流程
119	var req startRegisterReq
120	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" {
121		http.Error(w, "bad request", http.StatusBadRequest)
122		return
123	}
124	user := getOrCreateUser(req.Username)
125
126	// 2) BeginRegistration:產生 PublicKeyCredentialCreationOptions + SessionData
127	opts, sessionData, err := webAuthn.BeginRegistration(
128		user,
129		webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
130			RequireResidentKey: protocol.ResidentKeyRequired(),
131			UserVerification:   protocol.VerificationPreferred, // UV 建議 Preferred / Required 視需求
132		}),
133	)
134	if err != nil {
135		http.Error(w, "begin registration error: "+err.Error(), http.StatusInternalServerError)
136		return
137	}
138
139	// 3) 建一個 sid 對應 SessionData,寫入 cookie
140	sid := randomB64(16)
141	sessionMu.Lock()
142	regSess[sid] = sessionData
143	sessionMu.Unlock()
144	setCookie(w, "sid", sid)
145
146	// 4) 回傳 options 給前端做 navigator.credentials.create()
147	w.Header().Set("Content-Type", "application/json")
148	json.NewEncoder(w).Encode(opts)
149}
150
151func handleRegisterFinish(w http.ResponseWriter, r *http.Request) {
152	username := r.URL.Query().Get("username")
153	if username == "" {
154		http.Error(w, "username required", http.StatusBadRequest)
155		return
156	}
157	user := getOrCreateUser(username)
158
159	sid := getCookie(r, "sid")
160	if sid == "" {
161		http.Error(w, "missing sid", http.StatusUnauthorized)
162		return
163	}
164	sessionMu.Lock()
165	sessionData, ok := regSess[sid]
166	delete(regSess, sid) // 一次性
167	sessionMu.Unlock()
168	if !ok {
169		http.Error(w, "session not found", http.StatusUnauthorized)
170		return
171	}
172
173	// 讓套件從 request 解析前端傳來的 attestation response,完成註冊
174	credential, err := webAuthn.FinishRegistration(user, *sessionData, r)
175	if err != nil {
176		http.Error(w, "finish registration error: "+err.Error(), http.StatusBadRequest)
177		return
178	}
179
180	// 把 Credential 存到使用者
181	usersMu.Lock()
182	user.Credentials = append(user.Credentials, *credential)
183	usersMu.Unlock()
184
185	w.Header().Set("Content-Type", "application/json")
186	w.Write([]byte(`{"ok":true}`))
187}
188
189type startLoginReq struct {
190	Username string `json:"username"`
191}
192
193func handleLoginStart(w http.ResponseWriter, r *http.Request) {
194	var req startLoginReq
195	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" {
196		http.Error(w, "bad request", http.StatusBadRequest)
197		return
198	}
199	user, ok := users[req.Username]
200	if !ok || len(user.Credentials) == 0 {
201		http.Error(w, "user not found or no credentials", http.StatusNotFound)
202		return
203	}
204
205	// 產生 PublicKeyCredentialRequestOptions + SessionData
206	opts, sessionData, err := webAuthn.BeginLogin(user)
207	if err != nil {
208		http.Error(w, "begin login error: "+err.Error(), http.StatusInternalServerError)
209		return
210	}
211
212	// 存 session
213	sid := randomB64(16)
214	sessionMu.Lock()
215	authSess[sid] = sessionData
216	sessionMu.Unlock()
217	setCookie(w, "sid", sid)
218
219	// 傳給前端做 navigator.credentials.get()
220	w.Header().Set("Content-Type", "application/json")
221	json.NewEncoder(w).Encode(opts)
222}
223
224func handleLoginFinish(w http.ResponseWriter, r *http.Request) {
225	username := r.URL.Query().Get("username")
226	if username == "" {
227		http.Error(w, "username required", http.StatusBadRequest)
228		return
229	}
230	user, ok := users[username]
231	if !ok {
232		http.Error(w, "user not found", http.StatusNotFound)
233		return
234	}
235
236	sid := getCookie(r, "sid")
237	if sid == "" {
238		http.Error(w, "missing sid", http.StatusUnauthorized)
239		return
240	}
241	sessionMu.Lock()
242	sessionData, ok := authSess[sid]
243	delete(authSess, sid)
244	sessionMu.Unlock()
245	if !ok {
246		http.Error(w, "session not found", http.StatusUnauthorized)
247		return
248	}
249
250	// 解析 assertion response,驗證簽章 + 更新 sign counter
251	_, err := webAuthn.FinishLogin(user, *sessionData, r)
252	if err != nil {
253		http.Error(w, "finish login error: "+err.Error(), http.StatusBadRequest)
254		return
255	}
256
257	w.Header().Set("Content-Type", "application/json")
258	w.Write([]byte(`{"ok":true,"message":"login success"}`))
259}
260
261func main() {
262	mustInitWebAuthn()
263
264	// 靜態頁面
265	fs := http.FileServer(http.Dir("./public"))
266	http.Handle("/", fs)
267
268	// API
269	http.HandleFunc("/api/register/start", handleRegisterStart)
270	http.HandleFunc("/api/register/finish", handleRegisterFinish)
271	http.HandleFunc("/api/login/start", handleLoginStart)
272	http.HandleFunc("/api/login/finish", handleLoginFinish)
273
274	log.Println("listening on http://localhost:8080")
275	log.Fatal(http.ListenAndServe(":8080", nil))
276}

前端(HTML + CSS + JS)

純原生 API;負責把後端提供的 options 轉成 ArrayBuffer / Base64URL 所需型別,並把 browser 產生的憑證送回 server。

檔案:public/index.html

 1<!doctype html>
 2<html lang="zh-Hant">
 3<head>
 4  <meta charset="utf-8" />
 5  <meta name="viewport" content="width=device-width, initial-scale=1" />
 6  <title>Passkey MVP</title>
 7  <link rel="stylesheet" href="/style.css" />
 8</head>
 9<body>
10  <div class="container">
11    <h1>Passkey MVP</h1>
12    <p class="hint">請在支援 WebAuthn 的瀏覽器上測試(Chrome、Edge、Safari 近版)。</p>
13
14    <label>
15      <span>使用者名稱</span>
16      <input id="username" placeholder="alice" value="alice" />
17    </label>
18
19    <div class="actions">
20      <button id="btnReg">註冊 Passkey</button>
21      <button id="btnLogin">使用 Passkey 登入</button>
22    </div>
23
24    <pre id="log"></pre>
25  </div>
26
27  <script src="/utils.js"></script>
28  <script src="/web.js"></script>
29</body>
30</html>

檔案:public/style.css

 1* { box-sizing: border-box; }
 2body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", "PingFang TC", "Microsoft JhengHei", sans-serif; background: #0b1020; color: #e6eefc; }
 3.container { max-width: 760px; margin: 40px auto; padding: 24px; background: #0f1530; border: 1px solid #1f2750; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,.35); }
 4h1 { margin: 0 0 8px; font-size: 28px; }
 5.hint { opacity: .8; margin: 0 0 16px; }
 6label { display: block; margin: 12px 0 20px; }
 7label span { display: block; margin-bottom: 6px; opacity: .9; }
 8input { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid #2b3366; background: #0b122a; color: #e6eefc; }
 9.actions { display: flex; gap: 12px; }
10button { padding: 10px 14px; border-radius: 999px; border: 1px solid #2b3366; background: #1a2350; color: #e6eefc; cursor: pointer; }
11button:hover { background: #223070; }
12pre { margin-top: 16px; padding: 12px; background: #0b122a; border: 1px solid #2b3366; border-radius: 8px; overflow: auto; min-height: 120px; }

檔案:public/utils.js

 1// Base64URL <-> ArrayBuffer helpers
 2const b64urlToArrayBuffer = (b64url) => {
 3  const pad = "=".repeat((4 - (b64url.length % 4)) % 4);
 4  const b64 = (b64url + pad).replace(/-/g, "+").replace(/_/g, "/");
 5  const str = atob(b64);
 6  const buf = new ArrayBuffer(str.length);
 7  const bytes = new Uint8Array(buf);
 8  for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i);
 9  return buf;
10};
11
12const arrayBufferToB64url = (buf) => {
13  const bytes = new Uint8Array(buf);
14  let str = "";
15  for (let i = 0; i < bytes.byteLength; i++) str += String.fromCharCode(bytes[i]);
16  const b64 = btoa(str);
17  return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
18};
19
20const log = (...args) => {
21  const el = document.querySelector("#log");
22  el.textContent += args.map(a => (typeof a === "string" ? a : JSON.stringify(a, null, 2))).join(" ") + "\n";
23};
24const getUsername = () => document.querySelector("#username").value.trim();

檔案:public/web.js

  1async function postJSON(url, data) {
  2  const res = await fetch(url, {
  3    method: "POST",
  4    headers: {"Content-Type":"application/json"},
  5    body: JSON.stringify(data || {})
  6  });
  7  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  8  return res.json();
  9}
 10
 11function decodeCreationOptions(opts) {
 12  // 伺服器傳來的是 JSON(base64url 字串),需轉回 ArrayBuffer
 13  opts.publicKey.challenge = b64urlToArrayBuffer(opts.publicKey.challenge);
 14  opts.publicKey.user.id = b64urlToArrayBuffer(opts.publicKey.user.id);
 15  if (opts.publicKey.excludeCredentials) {
 16    for (const cred of opts.publicKey.excludeCredentials) {
 17      cred.id = b64urlToArrayBuffer(cred.id);
 18    }
 19  }
 20  return opts;
 21}
 22
 23function decodeRequestOptions(opts) {
 24  opts.publicKey.challenge = b64urlToArrayBuffer(opts.publicKey.challenge);
 25  if (opts.publicKey.allowCredentials) {
 26    for (const cred of opts.publicKey.allowCredentials) {
 27      cred.id = b64urlToArrayBuffer(cred.id);
 28    }
 29  }
 30  return opts;
 31}
 32
 33function encodeAttestationResponse(cred) {
 34  return {
 35    id: cred.id,
 36    rawId: arrayBufferToB64url(cred.rawId),
 37    type: cred.type,
 38    response: {
 39      clientDataJSON: arrayBufferToB64url(cred.response.clientDataJSON),
 40      attestationObject: arrayBufferToB64url(cred.response.attestationObject),
 41    }
 42  };
 43}
 44
 45function encodeAssertionResponse(cred) {
 46  return {
 47    id: cred.id,
 48    rawId: arrayBufferToB64url(cred.rawId),
 49    type: cred.type,
 50    response: {
 51      clientDataJSON: arrayBufferToB64url(cred.response.clientDataJSON),
 52      authenticatorData: arrayBufferToB64url(cred.response.authenticatorData),
 53      signature: arrayBufferToB64url(cred.response.signature),
 54      userHandle: cred.response.userHandle ? arrayBufferToB64url(cred.response.userHandle) : null,
 55    }
 56  };
 57}
 58
 59document.querySelector("#btnReg").addEventListener("click", async () => {
 60  try {
 61    const username = getUsername();
 62    if (!username) return log("請輸入使用者名稱");
 63
 64    log("開始註冊:", username);
 65    const creationOptions = await postJSON("/api/register/start", { username });
 66    decodeCreationOptions(creationOptions);
 67
 68    const credential = await navigator.credentials.create(creationOptions);
 69    log("credential created");
 70
 71    const payload = encodeAttestationResponse(credential);
 72    const res = await fetch(`/api/register/finish?username=${encodeURIComponent(username)}`, {
 73      method: "POST",
 74      headers: {"Content-Type":"application/json"},
 75      body: JSON.stringify(payload),
 76    });
 77    if (!res.ok) throw new Error(await res.text());
 78
 79    log("註冊完成 ✅");
 80  } catch (err) {
 81    log("註冊失敗:", String(err));
 82  }
 83});
 84
 85document.querySelector("#btnLogin").addEventListener("click", async () => {
 86  try {
 87    const username = getUsername();
 88    if (!username) return log("請輸入使用者名稱");
 89
 90    log("開始登入:", username);
 91    const requestOptions = await postJSON("/api/login/start", { username });
 92    decodeRequestOptions(requestOptions);
 93
 94    const assertion = await navigator.credentials.get(requestOptions);
 95    log("assertion received");
 96
 97    const payload = encodeAssertionResponse(assertion);
 98    const res = await fetch(`/api/login/finish?username=${encodeURIComponent(username)}`, {
 99      method: "POST",
100      headers: {"Content-Type":"application/json"},
101      body: JSON.stringify(payload),
102    });
103    if (!res.ok) throw new Error(await res.text());
104
105    log("登入成功 🎉");
106  } catch (err) {
107    log("登入失敗:", String(err));
108  }
109});

跑起來

 1# 建立專案
 2mkdir passkey-mvp && cd passkey-mvp
 3go mod init passkey-mvp
 4go get github.com/go-webauthn/webauthn/webauthn github.com/go-webauthn/webauthn/protocol
 5
 6# 建立目錄與檔案
 7mkdir public
 8# 將上面的 main.go 放在專案根目錄
 9# 將 index.html / style.css / utils.js / web.js 放在 ./public
10# ├── go.mod
11# ├── go.sum
12# ├── main.go
13# └── public
14#     ├── index.html
15#     ├── style.css
16#     ├── utils.js
17#     └── web.js
18
19# 啟動
20go run .
21# 打開瀏覽器
22# http://localhost:8080

測試流程:輸入 alice → 點「註冊 Passkey」→ 瀏覽器彈出系統 UI(指紋/臉)→ 成功後再點「使用 Passkey 登入」。

注意

  • RP ID 設為 localhost,Origin 是 http://localhost:8080。如果改成 HTTPS 或自訂網域,請同步調整 webauthn.Config 與前端網址。
  • Safari 通常需要 HTTPS;在本機開發可先用 Chrome/Edge 測試。

常見問題(MVP 版本)

  1. 為什麼我看不到系統生物辨識 UI? 檢查:

    • 瀏覽器版本是否新;
    • 網域/Origin 是否與後端設定一致;
    • 作業系統是否已設定 PIN/指紋/臉部辨識。
  2. 多裝置同步(iCloud/Google Password Manager)怎麼處理? 這屬於使用者端的 Passkey 管理,MVP 無需特別處理。正式環境請設計「Authenticator 生命週期管理」(新增、移除、Device Bound vs Synced)。

  3. 資料該存哪? 本文用 in-memory 存放。實際上要把 user.Credentials 存到資料庫(例如 credential_id, public_key, sign_count 等欄位),FinishLogin 後要更新 sign counter。

  4. 釣魚防護靠什麼? WebAuthn 憑證會綁定 RP ID / Origin,偽造站點無法通過驗證。仍需搭配 HTTPS、正確的 Content Security Policy 與嚴格的 cookie 設定。


延伸與強化

  • 註冊時的策略:Resident Key、User Verification(Required/Preferred)、Authenticator Attachment(platform/cross-platform)。
  • 後端框架化:把帳號、憑證存取抽象成 repository,改以 Postgres/SQLite。
  • Session 安全:改為加密的 Server/DB-backed session 或使用框架(例如 gorilla/sessions)。
  • 混合登入:傳統密碼 + TOTP → 導入 Passkey,提供漸進式升級。
  • 企業整合:可延伸至 Web + 桌面/行動端(如使用 WebView/ASWebAuthenticationSession)的一致登入體驗。

總結

本文以最小可行的方式,把 Passkey/WebAuthn 從原理整到可跑的 Go + Browser MVP。 你現在不只理解 challenge 簽章公私鑰 的核心,更擁有一個能把「無密碼登入」跑起來的基本骨架。接下來,把 in-memory 換成 DB、把 session 換成安全實作、把註冊/登入流程整合到你現有的系統,就能一步步上線。