用 Go 打造 Passkey(WebAuthn)最小可行產品:從原理到 MVP 實作
前言
「把密碼換成裝置裡的私鑰」——這就是 Passkey 的核心概念。 我們用最小可行的方式,寫一個可以真的註冊與登入的 WebAuthn 小服務。
目標
- 用 公私鑰(FIDO2/WebAuthn)取代傳統密碼。
- 實作 註冊(建立金鑰對)與 登入(challenge 簽章驗證)。
- 後端:Go;前端:HTML + CSS + JavaScript,純瀏覽器原生 API(
navigator.credentials.create/get
)。 - 用最小依賴打造能跑的 MVP,幫助你理解並能快速 PoC。
Passkey 是什麼?
- 不再記密碼:使用者只需要用裝置上的生物辨識/PIN 解鎖「私鑰」。
- 伺服器只存公鑰:挑戰(challenge)由伺服器發出,裝置用私鑰簽章回傳,伺服器用公鑰驗證。
- 抗釣魚:RP(網站)綁定 + 起源(origin)驗證,降低釣魚風險。
Passkey 工作流程
註冊流程(Registration)
用戶首次建立 Passkey 時的流程:
(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 進行登入:
{ 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 版本)
為什麼我看不到系統生物辨識 UI? 檢查:
- 瀏覽器版本是否新;
- 網域/Origin 是否與後端設定一致;
- 作業系統是否已設定 PIN/指紋/臉部辨識。
多裝置同步(iCloud/Google Password Manager)怎麼處理? 這屬於使用者端的 Passkey 管理,MVP 無需特別處理。正式環境請設計「Authenticator 生命週期管理」(新增、移除、Device Bound vs Synced)。
資料該存哪? 本文用 in-memory 存放。實際上要把
user.Credentials
存到資料庫(例如credential_id
,public_key
,sign_count
等欄位),FinishLogin
後要更新 sign counter。釣魚防護靠什麼? 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 換成安全實作、把註冊/登入流程整合到你現有的系統,就能一步步上線。