บทความ › ความปลอดภัย

Passkeys ล็อกอินไม่ต้องใช้รหัสผ่าน: เซิร์ฟเวอร์ เก็บอะไร ต้องเช็กอะไร

Passkeys (WebAuthn) ไม่มีความลับฝั่งเซิร์ฟเวอร์และหลอก phishing ไม่ได้ ขั้นลงทะเบียน/ล็อกอิน ข้อมูลที่ต้องเก็บ โค้ด SimpleWebAuthn, py_webauthn และข้อผิดพลาดที่พบบ่อย

กลาง อ่าน 15 นาที 21 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • 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 แบบเรียลไทม์ไปพร้อมกัน

เครื่องผู้ใช้กุญแจลับเซิร์ฟเวอร์กุญแจสาธารณะ1 ส่ง challenge2 ปลดล็อกด้วยนิ้ว แล้วเซ็น3 ส่งลายเซ็นกลับ4 ตรวจด้วยกุญแจสาธารณะไม่มีรหัสผ่านวิ่งผ่านเน็ต เว็บปลอมได้ลายเซ็นที่ใช้ไม่ได้
เซิร์ฟเวอร์เก็บแค่กุญแจสาธารณะ รั่วไปก็ล็อกอินแทนไม่ได้

ลงทะเบียนทีละขั้น

  1. เซิร์ฟเวอร์สุ่ม challenge (อย่างน้อย 16 ไบต์) จำไว้ใน session แล้วส่งไปพร้อม RP ID ชื่อเว็บ และ user handle
  2. เบราว์เซอร์เรียก navigator.credentials.create() authenticator ให้ผู้ใช้ยืนยันตัว สร้างคู่กุญแจ แล้วคืน credential ID กุญแจสาธารณะ และข้อมูลประกอบ
  3. เซิร์ฟเวอร์ตรวจว่า 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.get
  • clientData.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 Zm9vYmFyYmF6cXV4MTIz16 ไบต์ ห้ามใช้อีเมล ไม่มีใครเห็นค่านี้และต้องไม่เปลี่ยน
credential_idbase64url vJhG…ปกติ 16–64 ไบต์ สเปกจำกัดสูงสุด 1,023 ไบต์ ใช้ VARCHAR(1400) หรือ BYTEA พร้อม unique index
public_keyไบต์แบบ COSEกุญแจ ES256 ≈ 77 ไบต์, RS256 ≈ 270 ไบต์ เก็บเป็น BYTEA
sign_count0จำนวนเต็มไม่ติดลบ 32 บิต
transports["internal","hybrid"]JSON array สั้น ๆ ส่งกลับใน allowCredentials ให้เบราว์เซอร์เลือกช่องทางถูก
device_type / backed_upmultiDevice / truesynced passkey ส่วน singleDevice / false คือ device-bound
name, created_at, last_used_at“iPhone”, เวลาสำหรับรายการของผู้ใช้เอง ให้ลบอันที่ไม่ใช้ได้

บวกค่าตั้งระดับเว็บอีกหนึ่งชุด: RP ID (example.com) และรายการ origin ที่ยอมรับ เทียบกับแถวรหัสผ่านที่อยู่ข้างกัน: hash bcrypt 60 ไบต์ที่คนร้ายถอดออฟไลน์ได้ แถว passkey ใหญ่กว่า แต่ขโมยไปก็ไร้ค่า

การกู้บัญชีและทางสำรอง

อย่าให้ passkey เป็นประตูเดียวตั้งแต่วันแรก ลำดับที่ใช้ได้ผล

  1. ให้ผู้ใช้ที่ล็อกอินอยู่ เพิ่ม passkey จากหน้าตั้งค่าความปลอดภัย รหัสผ่าน + OTP ยังอยู่เหมือนเดิม
  2. โชว์ปุ่ม passkey บนหน้าล็อกอิน ถอยกลับไปฟอร์มเดิมเมื่อไม่มี navigator.credentials หรือผู้ใช้กดยกเลิก
  3. ให้มี passkey หลายอันต่อบัญชี (โทรศัพท์ โน้ตบุ๊ก hardware key ในลิ้นชัก) และโชว์รายการพร้อมปุ่มลบ เครื่องหายจะได้ลบแล้วลงทะเบียนเครื่องใหม่
  4. เก็บทางกู้บัญชีที่ไม่พึ่งเครื่อง: รีเซ็ตทางอีเมล หรือ 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 รั่วให้คนที่ถือเครื่อง และพังเมื่อเปลี่ยนอีเมล

แหล่งอ้างอิง

คำถามที่พบบ่อย

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 ว่าได้แบบไหน

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#Securityกลาง

แยกกุญแจตามหน้าที่ ระบบเดียวใช้ 4 แบบ หลุดดอกหนึ่ง อีกสามดอกยังปลอดภัย

เครื่องที่ส่งข้อมูล คนที่ล็อกอิน คนที่ดูหน้าสาธารณะ และคนที่ขอตามไม้ ต้องใช้กุญแจคนละแบบ ออกแบบ API key ผูกกับบัญชี session cookie และลิงก์สาธารณะ จากระบบที่รันจริง

อ่าน 6 นาที
#Securityกลาง

รับ Webhook ให้ปลอดภัย ตรวจ HMAC กัน replay ตอบเร็ว และกัน event ซ้ำ

endpoint รับ webhook เปิดให้ทั้งโลกยิงได้ ตรวจลายเซ็น HMAC บน raw body แบบ constant-time เช็กเวลากัน replay ตอบ 2xx ทันที แล้วกัน event ซ้ำและสลับลำดับ

อ่าน 12 นาที
#Securityกลาง

กัน CSRF โดยไม่ต้องใช้ token ด้วย Sec-Fetch-Site และ Origin (Go 1.25)

เบราว์เซอร์ยุคนี้บอกเซิร์ฟเวอร์เองว่าคำขอมาจากเว็บอื่นหรือเปล่า เช็กหัว Sec-Fetch-Site กับ Origin แทน CSRF token ได้ วิธีทำใน Go 1.25 และข้อแม้ที่ต้องรู้

อ่าน 10 นาที