Passkeys ล็อกอินไม่ต้องใช้รหัสผ่าน: เซิร์ฟเวอร์ เก็บอะไร ต้องเช็กอะไร
Passkeys (WebAuthn) ไม่มีความลับฝั่งเซิร์ฟเวอร์และหลอก phishing ไม่ได้ ขั้นลงทะเบียน/ล็อกอิน ข้อมูลที่ต้องเก็บ โค้ด SimpleWebAuthn, py_webauthn และข้อผิดพลาดที่พบบ่อย
- passkey คือคู่กุญแจ กุญแจส่วนตัวอยู่ในเครื่องหรือ password manager เซิร์ฟเวอร์เก็บแค่กุญแจสาธารณะ ฐานข้อมูลหลุดก็เอาไปล็อกอินไม่ได้
- เซิร์ฟเวอร์ต้องตรวจ challenge, origin, rpIdHash, flag user present/verified และลายเซ็น แล้วอัปเดต sign count
- เพิ่ม passkey คู่กับการล็อกอินเดิมก่อน เก็บรหัสผ่าน + OTP ไว้เป็นทางสำรองจนผู้ใช้ส่วนใหญ่ลงทะเบียนแล้ว
- RP ID ต้องเท่ากับโดเมนของเว็บหรือโดเมนแม่ที่จดทะเบียนได้ เลือกครั้งเดียว เพราะ passkey ที่สร้างกับ RP ID หนึ่งใช้กับอีกค่าไม่ได้
ระบบล็อกอินที่เราเคยทำทุกระบบเก็บของที่ขโมยได้: hash bcrypt ของรหัสผ่าน หรือเบอร์โทรที่รับรหัสหกหลัก ทั้งสองอย่างโดน phishing ได้ เพราะผู้ใช้พิมพ์ความลับลงหน้าเว็บที่อยู่ตรงหน้า passkey เปลี่ยนรูปปัญหา เครื่องของผู้ใช้ถือกุญแจส่วนตัว เซิร์ฟเวอร์ถือแค่กุญแจสาธารณะคู่กัน และเบราว์เซอร์ไม่ยอมใช้กุญแจกับเว็บอื่นนอกจากเว็บที่สร้างไว้ บทความนี้อธิบายว่ามันทำงานยังไง เซิร์ฟเวอร์เก็บและตรวจอะไรบ้าง และเพิ่มเข้าระบบล็อกอินเดิมด้วยไลบรารีที่ยังดูแลอยู่ เขียนจากมุมคนป้องกัน โค้ดทดลองบน Node 22 และ Python 3.12
passkey คืออะไร
passkey คือชื่อเรียกสำหรับผู้ใช้ของ discoverable credential ใน WebAuthn: คู่กุญแจสาธารณะ/ส่วนตัวที่ authenticator (โทรศัพท์ ชิปปลอดภัยในโน้ตบุ๊ก password manager หรือ hardware key) สร้างให้เว็บหนึ่งเว็บ ลงทะเบียน = สร้างคู่กุญแจ ล็อกอิน = เซิร์ฟเวอร์ส่ง challenge สุ่มมา แล้ว authenticator เซ็นให้ ผู้ใช้ปลดล็อกกุญแจด้วยวิธีเดียวกับปลดล็อกเครื่อง: นิ้ว หน้า หรือ PIN ขั้นนี้คือ user verification และเป็นสิ่งที่ทำให้ท่าทางเดียวนับเป็นสองปัจจัย (สิ่งที่มี + สิ่งที่เป็นหรือรู้)
มีสองแบบ synced passkey เก็บใน credential manager บนคลาวด์ (iCloud Keychain, Google Password Manager, แอป password manager) และโผล่บนเครื่องอื่นของผู้ใช้ด้วย device-bound passkey ไม่ออกจากเครื่องที่สร้าง เคสที่พบบ่อยคือ hardware security key จากมุมเซิร์ฟเวอร์ทั้งสองคือ credential แบบเดียวกัน มี flag สองตัวบอกว่าได้แบบไหน
รหัสผ่าน OTP และ passkey เทียบกัน
| รหัสผ่าน | OTP ทาง SMS / แอป | passkey | |
|---|---|---|---|
| เซิร์ฟเวอร์เก็บอะไร | hash ของความลับ | เบอร์โทร หรือ seed ที่ใช้ร่วมกัน | กุญแจสาธารณะอย่างเดียว |
| ฐานข้อมูลหลุดแล้วคนร้ายได้อะไร | hash ไปถอดแบบออฟไลน์ | seed หรือเบอร์ไป SIM swap | ไม่มีอะไรใช้ล็อกอินได้ |
| หลอกบนเว็บปลอมได้ไหม | ได้ | ได้ รหัสถูกส่งต่อแบบเรียลไทม์ | ไม่ได้ ลายเซ็นผูกกับ origin และ RP ID |
| ใช้ซ้ำข้ามเว็บ | พบบ่อย | ไม่ | เป็นไปไม่ได้ คู่กุญแจต่อหนึ่งเว็บ |
| เครื่องหาย | ไม่กระทบ | ล็อกเอาต์จนย้ายเบอร์เสร็จ | synced: กู้จาก credential manager · device-bound: ต้องมี credential อื่น |
เซิร์ฟเวอร์ไม่เคยถือสิ่งที่ให้ใครล็อกอินเป็นผู้ใช้ได้ คุณสมบัติข้อเดียวนี้ตัด credential stuffing การถอด hash ออฟไลน์ และ phishing แบบเรียลไทม์ไปพร้อมกัน
ลงทะเบียนทีละขั้น
- เซิร์ฟเวอร์สุ่ม challenge (อย่างน้อย 16 ไบต์) จำไว้ใน session แล้วส่งไปพร้อม RP ID ชื่อเว็บ และ user handle
- เบราว์เซอร์เรียก
navigator.credentials.create()authenticator ให้ผู้ใช้ยืนยันตัว สร้างคู่กุญแจ แล้วคืน credential ID กุญแจสาธารณะ และข้อมูลประกอบ - เซิร์ฟเวอร์ตรวจว่า challenge ตรง origin เป็นของตัวเอง rpIdHash คือ SHA-256 ของ RP ID และ credential ID ยังไม่เคยลงทะเบียนกับใคร แล้วบันทึก
ฝั่งเบราว์เซอร์ ถ้าเรียก API ของ W3C ตรง ๆ
// browser — options มาจากเซิร์ฟเวอร์เป็น JSON
const cred = await navigator.credentials.create({
publicKey: {
challenge: b64urlToBuf(opts.challenge),
rp: { id: "example.com", name: "Example" },
user: { id: b64urlToBuf(opts.userId), name: "jamie@example.com", displayName: "Jamie" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }], // ES256, RS256
authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
},
});
ของจริงให้ไลบรารีแปลงไบต์ให้ ตัวอย่าง SimpleWebAuthn (@simplewebauthn/server 14.0.3 ต้อง Node 20+ และ @simplewebauthn/browser บนหน้าเว็บ)
// server (Node) — GET /passkeys/register/options
const options = await generateRegistrationOptions({
rpName: "Example", rpID: "example.com",
userName: user.email,
attestationType: "none",
excludeCredentials: user.passkeys.map(p => ({ id: p.id, transports: p.transports })),
authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
});
session.challenge = options.challenge; // เก็บฝั่งเซิร์ฟเวอร์ ใช้ครั้งเดียว
// browser
const attResp = await startRegistration({ optionsJSON: options });
// server — POST /passkeys/register/verify
const { verified, registrationInfo } = await verifyRegistrationResponse({
response: attResp,
expectedChallenge: session.challenge,
expectedOrigin: "https://example.com",
expectedRPID: "example.com",
});
if (verified) {
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
savePasskey({ id: credential.id, publicKey: credential.publicKey, counter: credential.counter,
transports: credential.transports, deviceType: credentialDeviceType, backedUp: credentialBackedUp });
}
สองขั้นเดียวกันใน Python ด้วย py_webauthn (pip install webauthn เวอร์ชัน 3.0.1 บน PyPI ต้อง Python 3.10+)
# server (Python)
from webauthn import generate_registration_options, verify_registration_response, options_to_json
options = generate_registration_options(rp_id="example.com", rp_name="Example", user_name=user.email)
session["challenge"] = options.challenge # bytes
return options_to_json(options)
v = verify_registration_response(
credential=request.json, expected_challenge=session["challenge"],
expected_rp_id="example.com", expected_origin="https://example.com",
require_user_verification=True,
)
save_passkey(v.credential_id, v.credential_public_key, v.sign_count,
v.credential_device_type, v.credential_backed_up)
ล็อกอินทีละขั้น
ล็อกอินคือภาพสะท้อนของการลงทะเบียน เซิร์ฟเวอร์ส่ง challenge ใหม่ เบราว์เซอร์เรียก navigator.credentials.get() (หรือ startAuthentication({ optionsJSON })) authenticator เซ็น authenticatorData || SHA-256(clientDataJSON) แล้วเซิร์ฟเวอร์ตรวจ สเปก W3C ระบุรายการที่ Relying Party ต้องตรวจ ข้อสำคัญ
clientData.typeเป็นwebauthn.getclientData.challengeเท่ากับ challenge ที่ออกไป และลบทิ้งหลังใช้ครั้งเดียวclientData.originตรงกับ origin ของเราเป๊ะ (https://example.comรวม scheme และ port)rpIdHashใน authenticator data คือ SHA-256 ของ RP ID ของเรา- flag user present ถูกตั้ง และ flag user verified ถูกตั้งถ้าเราบังคับ
- ลายเซ็นตรวจผ่านด้วยกุญแจสาธารณะที่เก็บไว้
- sign count: ถ้าค่าที่เก็บหรือค่าใหม่ไม่เป็นศูนย์ และค่าใหม่ ไม่มากกว่า ค่าเดิม สเปกบอกว่าอาจมี authenticator ที่ถูกโคลน บันทึก log แล้วตัดสินใจ synced passkey มักส่ง 0 ทุกครั้ง ดังนั้น 0 → 0 เป็นเรื่องปกติ
// server (Node) — POST /passkeys/login/verify
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response: asseResp,
expectedChallenge: session.challenge,
expectedOrigin: "https://example.com",
expectedRPID: "example.com",
credential: { id: pk.id, publicKey: pk.publicKey, counter: pk.counter, transports: pk.transports },
requireUserVerification: true,
});
if (verified) { pk.counter = authenticationInfo.newCounter; createSession(pk.userId); }
ถ้าอยากให้ล็อกอินโดยไม่ต้องพิมพ์ชื่อผู้ใช้ ไม่ต้องส่ง allowCredentials และใส่ useBrowserAutofill: true คู่กับ <input autocomplete="username webauthn"> เบราว์เซอร์จะโชว์ passkey ของผู้ใช้ในรายการ autofill เซิร์ฟเวอร์หา record จาก credential ID ใน response แล้วใช้ผู้ใช้ของ record นั้น
ตัวอย่างจริง: record ที่เก็บของผู้ใช้หนึ่งคน
ทุกอย่างที่เซิร์ฟเวอร์เก็บสำหรับ passkey หนึ่งอันของผู้ใช้หนึ่งคน พร้อมขนาดจริง
| ฟิลด์ | ตัวอย่าง | ขนาด |
|---|---|---|
user_handle | สุ่ม 16 ไบต์ base64url Zm9vYmFyYmF6cXV4MTIz | 16 ไบต์ ห้ามใช้อีเมล ไม่มีใครเห็นค่านี้และต้องไม่เปลี่ยน |
credential_id | base64url vJhG… | ปกติ 16–64 ไบต์ สเปกจำกัดสูงสุด 1,023 ไบต์ ใช้ VARCHAR(1400) หรือ BYTEA พร้อม unique index |
public_key | ไบต์แบบ COSE | กุญแจ ES256 ≈ 77 ไบต์, RS256 ≈ 270 ไบต์ เก็บเป็น BYTEA |
sign_count | 0 | จำนวนเต็มไม่ติดลบ 32 บิต |
transports | ["internal","hybrid"] | JSON array สั้น ๆ ส่งกลับใน allowCredentials ให้เบราว์เซอร์เลือกช่องทางถูก |
device_type / backed_up | multiDevice / true | synced passkey ส่วน singleDevice / false คือ device-bound |
name, created_at, last_used_at | “iPhone”, เวลา | สำหรับรายการของผู้ใช้เอง ให้ลบอันที่ไม่ใช้ได้ |
บวกค่าตั้งระดับเว็บอีกหนึ่งชุด: RP ID (example.com) และรายการ origin ที่ยอมรับ เทียบกับแถวรหัสผ่านที่อยู่ข้างกัน: hash bcrypt 60 ไบต์ที่คนร้ายถอดออฟไลน์ได้ แถว passkey ใหญ่กว่า แต่ขโมยไปก็ไร้ค่า
การกู้บัญชีและทางสำรอง
อย่าให้ passkey เป็นประตูเดียวตั้งแต่วันแรก ลำดับที่ใช้ได้ผล
- ให้ผู้ใช้ที่ล็อกอินอยู่ เพิ่ม passkey จากหน้าตั้งค่าความปลอดภัย รหัสผ่าน + OTP ยังอยู่เหมือนเดิม
- โชว์ปุ่ม passkey บนหน้าล็อกอิน ถอยกลับไปฟอร์มเดิมเมื่อไม่มี
navigator.credentialsหรือผู้ใช้กดยกเลิก - ให้มี passkey หลายอันต่อบัญชี (โทรศัพท์ โน้ตบุ๊ก hardware key ในลิ้นชัก) และโชว์รายการพร้อมปุ่มลบ เครื่องหายจะได้ลบแล้วลงทะเบียนเครื่องใหม่
- เก็บทางกู้บัญชีที่ไม่พึ่งเครื่อง: รีเซ็ตทางอีเมล หรือ recovery code ที่พิมพ์เก็บไว้ ถือว่าเป็นจุดอ่อนที่สุดและจำกัดอัตราการลอง
เมื่อบัญชีที่ใช้งานจริงส่วนใหญ่มี passkey แล้ว ค่อยพิจารณาตั้งเป็นค่าเริ่มต้นและลดรหัสผ่านเหลือใช้กู้บัญชี
รองรับแค่ไหนในปี 2026
WebAuthn อยู่ในเบราว์เซอร์หลักทุกตัวตั้งแต่ปี 2021 (MDN ติดป้าย Baseline) synced passkey มากับ iOS/iPadOS 16+, macOS 13+, Android 9+ และ ChromeOS 129+ password manager ค่ายอื่นเก็บ passkey ได้บน iOS 17+, Android 14+ และ Windows 11 25H2+ การสแกน QR เพื่อล็อกอินบนเดสก์ท็อปด้วยโทรศัพท์ (hybrid transport) ใช้ได้ใน Chrome, Edge, Firefox และ Safari ตัวเลขจาก passkeys.dev ณ กันยายน 2026 ตรวจสอบกับเอกสารล่าสุดก่อนสัญญาเวอร์ชันกับผู้ใช้
ข้อผิดพลาดที่เจอบ่อย
- RP ID ผิดบน subdomain passkey ที่สร้างด้วย RP ID
app.example.comใช้ที่account.example.comไม่ได้ ถ้าผู้ใช้อาจล็อกอินหลาย subdomain ตั้ง RP ID เป็นexample.comตั้งแต่แรก RP ID ต้องเท่ากับโดเมนของ origin หรือเป็น registrable suffix ของมัน ใช้comไม่ได้ และใช้โดเมนที่เราไม่ได้ให้บริการก็ไม่ได้ - ไม่ตรวจ origin ลายเซ็นผูกกับ
clientData.originถ้าเซิร์ฟเวอร์ไม่เทียบกับ origin ของตัวเอง เว็บปลอมที่ทำ proxy จะส่งต่อลายเซ็นที่ถูกต้องได้ ส่งexpectedOriginเสมอ - ข้าม user verification ตั้ง
userVerification: "preferred"แต่ไม่ตรวจ flag UV โทรศัพท์ที่ถูกขโมยตอนปลดล็อกอยู่จะล็อกอินได้ด้วยการแตะครั้งเดียว ตัดสินใจว่าจะบังคับไหม แล้วตรวจ flag ฝั่งเซิร์ฟเวอร์ ไม่ใช่แค่ใน options - ใช้ challenge ซ้ำ หรือเก็บไว้ในเบราว์เซอร์ หนึ่ง challenge หนึ่งพิธี เก็บฝั่งเซิร์ฟเวอร์
- ไม่มีทางลงทะเบียนใหม่ ผู้ใช้ที่มี device-bound key อันเดียวแล้วเครื่องหาย ต้องยืนยันตัวทางอื่นแล้วลงทะเบียนใหม่ได้
- ใช้อีเมลเป็น user handle รั่วให้คนที่ถือเครื่อง และพังเมื่อเปลี่ยนอีเมล
แหล่งอ้างอิง
- W3C Web Authentication Level 3, Recommendation, 25 ส.ค. 2026 — กฎ RP ID, §6.1.1 signature counter, §7.1 record ที่ต้องเก็บ
- W3C Web Authentication Level 2, §7.2 Verifying an Authentication Assertion — รายการตรวจตามลำดับ
- FIDO Alliance: Passkeys — synced vs device-bound, phishing resistance
- passkeys.dev: Device Support — ตาราง OS และเบราว์เซอร์ อัปเดต ก.ย. 2026
- MDN: Web Authentication API —
navigator.credentials.create()/get() - SimpleWebAuthn server docs และ py_webauthn docs — ชื่อฟังก์ชันและฟิลด์ที่เก็บ
คำถามที่พบบ่อย
passkey ถูกหลอกเอาไปแบบรหัส OTP ได้ไหม
ไม่ได้ เบราว์เซอร์เซ็น challenge พร้อม origin ที่ได้รับมา และกุญแจใช้ได้กับ RP ID ที่สร้างไว้เท่านั้น เว็บปลอมที่หน้าตาเหมือนจะได้ลายเซ็นที่เซิร์ฟเวอร์จริงปฏิเสธ และไม่มีรหัสให้ผู้ใช้พิมพ์ผิดหน้า
มี passkey แล้วยังต้องเก็บ hash รหัสผ่านอยู่ไหม
ช่วงแรกยังต้องเก็บ ให้การล็อกอินเดิมเป็นทางสำรองและให้ผู้ใช้เพิ่ม passkey เอง เมื่อบัญชีส่วนใหญ่มีแล้วค่อยตั้ง passkey เป็นค่าเริ่มต้นและลดรหัสผ่านเหลือใช้กู้บัญชี
synced passkey กับ device-bound passkey ต่างกันยังไง
synced passkey อยู่ใน credential manager เช่น iCloud Keychain, Google Password Manager หรือ password manager ของค่ายอื่น และตามผู้ใช้ไปเครื่องใหม่ ส่วน device-bound เช่นบน hardware security key ไม่ออกจากเครื่องนั้นเลย เซิร์ฟเวอร์ดูได้จาก backup flag ว่าได้แบบไหน
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ