Spring Boot 4 + Spring Security 7 application demonstrating WebAuthn (Passkey) authentication with a REST API backend, JPA persistence, and PostgreSQL.
- WebAuthn Passkey Demo — Spring Boot REST API
ระบบนี้ใช้ Cookie-based Session (ไม่ใช่ JWT) ในการยืนยันตัวตน ซึ่งเป็น default ของ Spring Security
หลักการทำงานแบบง่ายๆ:
คิดเหมือน "บัตรเข้างาน Event"
1. คุณไปที่ประตู (Login) → ยื่นบัตรประชาชน (Email + Password)
2. เจ้าหน้าที่ตรวจสอบ → ถูกต้อง! → ให้ "สายรัดข้อมือ" (Session Cookie)
3. ตลอดทั้งงาน → แค่โชว์สายรัดข้อมือ → เข้าได้เลย ไม่ต้องยื่นบัตรอีก
4. ออกจากงาน (Logout) → ตัดสายรัดข้อมือทิ้ง → เข้าไม่ได้แล้ว
"สายรัดข้อมือ" = JSESSIONID Cookie
"รายชื่อผู้เข้างาน" = Session store ฝั่ง Server (เก็บใน Memory)
ในทาง Technical:
| ส่วนประกอบ | คำอธิบาย |
|---|---|
| JSESSIONID | Cookie ที่ Server สร้างให้ Browser เก็บไว้ เป็นแค่ "ID สุ่ม" เช่น JSESSIONID=A1B2C3D4E5 |
| HttpSession | Object ฝั่ง Server ที่เก็บข้อมูล User (เช่น ใครล็อกอิน, มี Role อะไร) |
| SecurityContext | Object ของ Spring Security ที่เก็บ Authentication info ไว้ใน Session |
Browser Server (Spring Boot)
│ │
│ Request + Cookie: │
│ JSESSIONID=A1B2C3D4E5 │
│ ─────────────────────────────────► │
│ │ Server ดึง: "A1B2C3D4E5 = user@example.com, ROLE_USER"
│ │ จาก HttpSession (เก็บใน Memory/Redis)
│ ◄───────────────────────────────── │
│ Response: 200 OK (ข้อมูล User) │
เมื่อ Login สำเร็จ Spring Security จะทำ 3 สิ่งนี้:
// 1. ยืนยันตัวตน (Authenticate)
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(email, password)
);
// 2. เก็บผลลัพธ์ใน SecurityContext
SecurityContextHolder.getContext().setAuthentication(auth);
// 3. บันทึกลง HttpSession (สร้าง Session ใหม่ถ้ายังไม่มี)
HttpSession session = request.getSession(true);
session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext());ขั้นตอน 3 คือจุดสำคัญ! — Server สร้าง Session ID สุ่มขึ้นมา แล้วเก็บ Authentication object ไว้ในนั้น จากนั้นส่ง Session ID กลับไปเป็น Cookie
Cookie ถูกสร้างโดย Tomcat (Servlet Container ที่ Spring Boot ใช้) อัตโนมัติ:
Login สำเร็จ:
Server Response Headers:
HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=A1B2C3D4E5F6; Path=/; HttpOnly ← ตัวนี้!
Content-Type: application/json
Browser เห็น Header "Set-Cookie" → เก็บ Cookie ไว้อัตโนมัติ
→ ทุก Request ต่อไปที่ส่งไปยัง Server เดียวกัน จะแนบ Cookie นี้ไปด้วย
ในโปรเจ็กต์นี้มี Cookie 2 ตัว:
| Cookie | หน้าที่ | สร้างโดยใคร |
|---|---|---|
JSESSIONID |
Session ID — ใช้ระบุตัวตน User | Tomcat (Servlet Container) สร้างอัตโนมัติ |
XSRF-TOKEN |
CSRF Protection Token — ป้องกันการโจมตี Cross-Site Request Forgery | Spring Security (CookieCsrfTokenRepository) |
JSESSIONID สำคัญที่สุด — ถ้าไม่มี Cookie นี้ Server จะไม่รู้ว่าคุณเป็นใคร → ส่ง 401 Unauthorized กลับมา
XSRF-TOKEN ใช้เฉพาะกับ POST/PUT/DELETE — ต้องอ่าน Cookie แล้วส่งกลับไปใน Header X-XSRF-TOKEN เพื่อพิสูจน์ว่า Request มาจากหน้าเว็บจริงๆ ไม่ใช่จากเว็บอื่นปลอมมา
// ตัวอย่างการอ่าน XSRF-TOKEN แล้วส่งกลับใน Header
const xsrf = document.cookie
.split("; ")
.find(c => c.startsWith("XSRF-TOKEN="))
?.split("=")[1];
fetch("/login/webauthn", {
method: "POST",
credentials: "include", // ← สำคัญ! บอกให้ Browser ส่ง Cookie ไปด้วย
headers: {
"Content-Type": "application/json",
"X-XSRF-TOKEN": decodeURIComponent(xsrf) // ← ส่ง CSRF token กลับไป
},
body: JSON.stringify(data)
});┌─────────────────────────────────────────────────────────────────────────────┐
│ Cookie-based Session (โปรเจ็กต์นี้) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Browser Server │
│ ┌──────────┐ ┌──────────────────────┐ │
│ │ Cookie: │ ── Request ──► │ Session Store: │ │
│ │ JSESSION │ │ A1B2 → {user: "a"} │ │
│ │ =A1B2 │ ◄── Response ── │ C3D4 → {user: "b"} │ │
│ └──────────┘ │ E5F6 → {user: "c"} │ │
│ └──────────────────────┘ │
│ Cookie เก็บแค่ "ID" Server เก็บข้อมูล User ทั้งหมด │
│ ไม่มีข้อมูลอะไรเลย ใน Memory (หรือ Redis) │
│ │
│ ✅ ง่าย, Spring Security ทำให้อัตโนมัติ │
│ ✅ เพิกถอน (Logout) ได้ทันที — ลบ Session ฝั่ง Server │
│ ✅ ปลอดภัย — Cookie HttpOnly ป้องกัน XSS อ่านไม่ได้ │
│ ❌ ไม่ Stateless — Server ต้องจำ Session ทุกคน │
│ ❌ Scale ยาก — ถ้ามีหลาย Server ต้องแชร์ Session (Redis) │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ JWT (JSON Web Token) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Browser Server │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ Header: │ ─ Req ──► │ ไม่ต้องเก็บอะไร! │ │
│ │ Authorization: │ │ แค่ verify signature │ │
│ │ Bearer eyJhbG... │ ◄─ Res ── │ ด้วย Secret Key │ │
│ └──────────────────┘ └──────────────────────┘ │
│ │
│ Token เก็บข้อมูล User Server Stateless │
│ เข้ารหัส + ลายเซ็น ไม่ต้องจำใครเลย │
│ │
│ ✅ Stateless — Server ไม่ต้องเก็บ Session │
│ ✅ Scale ง่าย — Server ตัวไหนก็ verify ได้ │
│ ✅ เหมาะกับ Mobile App / Microservices │
│ ❌ เพิกถอนยาก — Token ยังใช้ได้จนหมดอายุ (ต้องทำ Blacklist) │
│ ❌ Token ใหญ่กว่า Session ID │
│ ❌ ต้อง implement เอง — Spring Security ไม่ได้ทำให้อัตโนมัติ │
└─────────────────────────────────────────────────────────────────────────────┘
Browser (login.html) Server (Spring Boot)
═══════════════════ ════════════════════
┌─ 1. User กรอก Email + Password แล้วกด Login
│
│ POST /api/auth/login
│ Body: {"email":"a@b.com","password":"1234"}
│ ──────────────────────────────────────────►
│ ┌─ 2. AuthController.login()
│ │ authenticationManager.authenticate()
│ │ └─► JpaUserDetailsService.loadUserByUsername("a@b.com")
│ │ └─► SELECT * FROM app_users WHERE email = 'a@b.com'
│ │ └─► เปรียบเทียบ password (BCrypt)
│ │ ✅ ถูกต้อง!
│ │
│ │ SecurityContextHolder.getContext().setAuthentication(auth)
│ │ session.setAttribute("SPRING_SECURITY_CONTEXT", context)
│ │ ← Tomcat สร้าง JSESSIONID Cookie อัตโนมัติ
│ └─
│
│ ◄──────────────────────────────────────────
│ 200 OK
│ Set-Cookie: JSESSIONID=ABC123; Path=/; HttpOnly
│ Body: {"id":1,"email":"a@b.com","displayName":"John"}
│
└─ 3. Browser เก็บ JSESSIONID Cookie
JavaScript: window.location.href = "/index.html"
→ redirect ไปหน้า Profile
┌─ 4. เข้าหน้า index.html → เรียก /api/auth/me
│
│ GET /api/auth/me
│ Cookie: JSESSIONID=ABC123 ← Browser แนบ Cookie ไปอัตโนมัติ
│ ──────────────────────────────────────────►
│ ┌─ 5. Spring Security Filter
│ │ อ่าน JSESSIONID=ABC123
│ │ หา Session → พบ SecurityContext
│ │ → User "a@b.com" ยังล็อกอินอยู่
│ │ ✅ ผ่าน!
│ │
│ │ AuthController.me()
│ │ → ดึง Principal จาก SecurityContext
│ │ → query DB → return User info
│ └─
│
│ ◄──────────────────────────────────────────
│ 200 OK {"id":1,"email":"a@b.com",...}
│
└─ 6. แสดงข้อมูล User บนหน้า Profile ✅
⚠️ ต้อง Login ด้วย Password ก่อน ถึงจะ Register Passkey ได้
Browser (index.html) Server Authenticator
════════════════════ ══════ (Windows Hello /
Touch ID / YubiKey)
═══════════════
┌─ 1. User กดปุ่ม "Register New Passkey"
│
│ POST /webauthn/register/options
│ Cookie: JSESSIONID=ABC123
│ ──────────────────────────►
│ ┌─ 2. Spring Security WebAuthn Filter
│ │ สร้าง Challenge (ค่าสุ่ม)
│ │ + ข้อมูล Relying Party (rpId, rpName)
│ │ + ข้อมูล User (id, name)
│ └─
│ ◄──────────────────────────
│ 200 OK (PublicKeyCredentialCreationOptions)
│ {challenge, rp, user, pubKeyCredParams,...}
│
│ 3. JavaScript เรียก Web API:
│ navigator.credentials.create({publicKey: options})
│ ────────────────────────────────────────────────────────►
│ ┌─ 4. Authenticator
│ │ แสดง Prompt
│ │ (สแกนนิ้ว/Face/PIN)
│ │ ✅ User ยืนยัน!
│ │ สร้าง Key Pair:
│ │ - Private Key (เก็บในอุปกรณ์)
│ │ - Public Key (ส่งกลับ)
│ └─
│ ◄────────────────────────────────────────────────────────
│ credential (id, publicKey, attestation)
│
│ POST /webauthn/register
│ Body: {credential, label: "My Passkey"}
│ ──────────────────────────►
│ ┌─ 5. Spring Security WebAuthn Filter
│ │ ตรวจสอบ Challenge
│ │ ตรวจสอบ Attestation
│ │ บันทึก Public Key ลง DB
│ │ (table: user_credentials)
│ └─
│ ◄──────────────────────────
│ 200 OK (Passkey saved!)
│
└─ 6. แสดง "Passkey registered! ✅"
🔑 ไม่ต้องกรอก Email/Password เลย!
Browser (login.html) Server Authenticator
════════════════════ ══════ ═══════════════
┌─ 1. User กดปุ่ม "Login with Passkey"
│
│ POST /webauthn/authenticate/options
│ Body: {}
│ ──────────────────────────►
│ ┌─ 2. Spring Security WebAuthn Filter
│ │ สร้าง Challenge (ค่าสุ่มใหม่)
│ │ + allowCredentials (Passkey ที่ลงทะเบียนไว้)
│ └─
│ ◄──────────────────────────
│ 200 OK (PublicKeyCredentialRequestOptions)
│ {challenge, allowCredentials,...}
│
│ 3. JavaScript เรียก Web API:
│ navigator.credentials.get({publicKey: options})
│ ────────────────────────────────────────────────────────►
│ ┌─ 4. Authenticator
│ │ แสดง Prompt
│ │ "เลือก Passkey"
│ │ (สแกนนิ้ว/Face/PIN)
│ │ ✅ User ยืนยัน!
│ │ ใช้ Private Key ที่เก็บไว้
│ │ เซ็น Challenge → Signature
│ └─
│ ◄────────────────────────────────────────────────────────
│ assertion (id, signature, authenticatorData, clientDataJSON)
│
│ POST /login/webauthn
│ Body: {id, rawId, response: {signature, authenticatorData,...}}
│ ──────────────────────────►
│ ┌─ 5. Spring Security WebAuthn Filter
│ │ ค้นหา Credential จาก DB
│ │ ดึง Public Key ที่เก็บไว้
│ │ Verify: signature + challenge + publicKey
│ │ ✅ ถูกต้อง!
│ │
│ │ สร้าง WebAuthnAuthentication
│ │ Principal = PublicKeyCredentialUserEntity ← ‼️ ไม่ใช่ UserDetails!
│ │ บันทึกลง Session (เหมือน Password Login)
│ │ ← สร้าง JSESSIONID Cookie
│ └─
│ ◄──────────────────────────
│ 200 OK
│ Set-Cookie: JSESSIONID=XYZ789; Path=/; HttpOnly
│ Body: {"redirectUrl":"/","authenticated":true}
│
└─ 6. JavaScript: window.location.href = "/index.html"
→ redirect ไปหน้า Profile ✅
⚠️ จุดที่ต้องระวัง (Bug ที่เราแก้ไป):เมื่อ Login ด้วย Password → Principal เป็น
UserDetailsเมื่อ Login ด้วย Passkey → Principal เป็นPublicKeyCredentialUserEntityดังนั้น Controller ต้องรองรับ ทั้ง 2 ประเภท (ดูใน
AuthController.me()และPasskeyController)
Browser (index.html) Server
════════════════════ ══════
┌─ 1. Browser โหลด index.html (permitAll — ไม่ต้อง auth)
│
│ 2. JavaScript ทำงาน:
│ loadUser() → GET /api/auth/me + Cookie: JSESSIONID=XYZ789
│ loadPasskeys() → GET /api/passkeys + Cookie: JSESSIONID=XYZ789
│ (Browser ส่ง Cookie ไปอัตโนมัติเพราะเป็น same-origin)
│
│ ──────────────────────────────────────────►
│ ┌─ 3. Spring Security Filter Chain
│ │ อ่าน JSESSIONID=XYZ789
│ │ หา Session → พบ SecurityContext
│ │ → User authenticated ✅
│ │
│ │ AuthController.me()
│ │ → ดึง Principal (UserDetails หรือ WebAuthnUser)
│ │ → resolveUsername() → หา email
│ │ → query app_users → return User info
│ │
│ │ PasskeyController.listPasskeys()
│ │ → resolveUsername() → หา email
│ │ → query user_credentials → return passkey list
│ └─
│ ◄──────────────────────────────────────────
│ /api/auth/me → 200 OK {email, displayName,...}
│ /api/passkeys → 200 OK [{credentialId, label,...}]
│
└─ 4. แสดงข้อมูล User + Passkey List บนหน้า Profile ✅
ถ้าต้องการเปลี่ยนจาก Cookie-based Session ไปใช้ JWT (เช่น สำหรับ Mobile App หรือ Microservices) ต้องเปลี่ยนส่วนต่างๆ ดังนี้:
<!-- เพิ่ม dependency -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>📁 สร้างไฟล์ใหม่: src/main/java/.../util/JwtUtil.java
// สร้าง class ที่สามารถ:
// - generateToken(String email) → สร้าง JWT Token
// - validateToken(String token) → ตรวจสอบว่า Token ถูกต้องไหม
// - getEmailFromToken(String token) → ดึง email จาก Token📁 สร้างไฟล์ใหม่: src/main/java/.../config/JwtAuthenticationFilter.java
// สร้าง Filter ที่ทำงานทุก Request:
// - อ่าน Header "Authorization: Bearer <token>"
// - Validate Token
// - ถ้าถูกต้อง → ตั้ง SecurityContext
//
// extends OncePerRequestFilter// เปลี่ยนจาก:
.formLogin(form -> form.loginPage("/login.html")...)
// เป็น:
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // ← ไม่สร้าง Session!
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
// ลบ .formLogin() ออก
// ลบ .logout() ออก (JWT ไม่ต้อง logout ฝั่ง Server)// เปลี่ยน login() ให้ return JWT Token แทน Session:
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
// Authenticate เหมือนเดิม
Authentication auth = authenticationManager.authenticate(...);
// ❌ ลบ: session.setAttribute(...)
// ✅ เพิ่ม: สร้าง JWT Token
String token = jwtUtil.generateToken(request.email());
return ResponseEntity.ok(Map.of("token", token));
}
// เปลี่ยน me() ให้อ่านจาก SecurityContext (ไม่เปลี่ยนเยอะ เพราะแก้ไปแล้ว)// เปลี่ยนจาก:
// (ไม่ต้องทำอะไร — Cookie ส่งอัตโนมัติ)
// เป็น:
// ต้องเก็บ Token ใน localStorage แล้วแนบทุก Request
// Login:
const data = await res.json();
localStorage.setItem("token", data.token);
// ทุก API call:
fetch("/api/auth/me", {
headers: {
"Authorization": "Bearer " + localStorage.getItem("token")
}
});
// Logout:
localStorage.removeItem("token");ปัญหา: Spring Security's WebAuthn filter ถูกออกแบบมาให้ใช้กับ Session
ไม่ได้ออกแบบมาให้ทำงานกับ JWT โดยตรง
ทางเลือก:
A) ใช้ "Hybrid" — WebAuthn ยังใช้ Session, แต่หลัง login สำเร็จ
ให้สร้าง JWT Token แล้วส่งกลับไป (แนะนำ)
B) Override WebAuthnAuthenticationSuccessHandler ให้ return JWT
แทนที่จะสร้าง Session
C) เขียน WebAuthn authentication เอง ไม่ใช้ built-in filter ของ Spring Security
ไฟล์ที่ต้องแก้/เพิ่ม ระดับความยาก
═══════════════════════════════════════════════════════════
📄 pom.xml → เพิ่ม jjwt dependency 🟢 ง่าย
🆕 JwtUtil.java → สร้างใหม่ 🟡 ปานกลาง
🆕 JwtAuthenticationFilter.java → สร้างใหม่ 🟡 ปานกลาง
📄 SecurityConfig.java → เปลี่ยนเยอะ (STATELESS + Filter) 🔴 ยาก
📄 AuthController.java → แก้ login() return token 🟢 ง่าย
📄 login.html → เก็บ token + ส่ง Header 🟡 ปานกลาง
📄 index.html → ส่ง Authorization Header 🟡 ปานกลาง
📄 WebAuthn Integration → ปรับ success handler 🔴 ยาก
💡 คำแนะนำ: ถ้าเป็นเว็บ (Browser-based application) แนะนำให้ใช้ Cookie-based Session เพราะปลอดภัยกว่า (Cookie HttpOnly ป้องกัน XSS ได้) และ Spring Security ทำให้อัตโนมัติ ใช้ JWT เมื่อต้อง support Mobile App หรือ Microservices จริงๆ
┌─────────────────────────────────────────────────────────┐
│ Spring Security WebAuthn │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ PublicKeyCredentialUserEntityRepository (interface) │ │
│ │ UserCredentialRepository (interface) │ │
│ └──────────────┬──────────────────────────────────────┘ │
│ │ implements │
│ ┌──────────────▼──────────────────────────────────────┐ │
│ │ JpaPublicKeyCredentialUserEntityRepository (@Component)│
│ │ JpaUserCredentialRepository (@Component) │ │
│ └──────────────┬──────────────────────────────────────┘ │
│ │ uses │
│ ┌──────────────▼──────────────────────────────────────┐ │
│ │ UserEntityJpaRepository (Spring Data JPA) │ │
│ │ CredentialRecordJpaRepository (Spring Data JPA) │ │
│ └──────────────┬──────────────────────────────────────┘ │
│ │ maps │
│ ┌──────────────▼──────────────────────────────────────┐ │
│ │ UserEntityRecord (@Entity) │ │
│ │ CredentialRecordEntity (@Entity) │ │
│ └──────────────┬──────────────────────────────────────┘ │
│ │ Hibernate auto-DDL │
│ ┌────▼────┐ │
│ │PostgreSQL│ │
│ └─────────┘ │
└─────────────────────────────────────────────────────────┘
- Java 21+
- Docker & Docker Compose
- Maven (or use the included
mvnwwrapper)
# 1. Start PostgreSQL
docker compose up -d
# 2. Run the application
./mvnw spring-boot:run
# Windows: .\mvnw spring-boot:run
# 3. Open test UI
# http://localhost:8080/login.html| Setting | Value |
|---|---|
| Host | localhost:5432 |
| Database | webauthn |
| Username | webauthn |
| Password | webauthn |
| JDBC URL | jdbc:postgresql://localhost:5432/webauthn |
Tables are auto-created by Hibernate (ddl-auto: update):
| Table | Description |
|---|---|
app_users |
Application users (email/password) |
user_entities |
WebAuthn user entities (passkey owner) |
user_credentials |
WebAuthn credentials (passkeys) |
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "MyPassword123",
"displayName": "John Doe"
}'Response 201 Created:
{
"id": 1,
"email": "user@example.com",
"displayName": "John Doe",
"createdAt": "2026-02-12T07:00:00Z"
}Errors:
400— Validation error (missing email, password < 8 chars)409— Email already registered
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{
"email": "user@example.com",
"password": "MyPassword123"
}'Response 200 OK + Set-Cookie: JSESSIONID=...:
{
"id": 1,
"email": "user@example.com",
"displayName": "John Doe",
"createdAt": "2026-02-12T07:00:00Z"
}Error 401:
{ "error": "AUTH_FAILED", "message": "Invalid email or password" }curl http://localhost:8080/api/auth/me -b cookies.txtResponse 200 OK:
{
"id": 1,
"email": "user@example.com",
"displayName": "John Doe",
"createdAt": "2026-02-12T07:00:00Z"
}curl -X POST http://localhost:8080/api/auth/logout -b cookies.txtResponse 200 OK:
{ "message": "Logged out successfully" }curl http://localhost:8080/api/passkeys -b cookies.txtResponse 200 OK:
[
{
"credentialId": "NxxppSNYduUBLSESigyfCA",
"label": "My Passkey",
"created": "2026-02-12T07:05:00Z",
"lastUsed": "2026-02-12T07:10:00Z"
}
]curl -X DELETE http://localhost:8080/api/passkeys/{credentialId} \
-b cookies.txt \
-H "X-XSRF-TOKEN: <token>"Response 200 OK:
{ "message": "Passkey deleted successfully" }These endpoints are handled by Spring Security's WebAuthn filters.
Client Server
│ │
│ POST /webauthn/register/options
│ (authenticated, + XSRF token) │
│ ──────────────────────────────►│
│ │
│ ◄─── PublicKeyCredentialCreationOptions (challenge, rp, user)
│ │
│ navigator.credentials.create()│
│ (browser prompts biometric) │
│ │
│ POST /webauthn/register │
│ (credential + XSRF token) │
│ ──────────────────────────────►│
│ │
│ ◄─── 200 OK (passkey saved) │
Client Server
│ │
│ POST /webauthn/authenticate/options
│ (public, no auth needed) │
│ ──────────────────────────────►│
│ │
│ ◄─── PublicKeyCredentialRequestOptions (challenge, allowCredentials)
│ │
│ navigator.credentials.get() │
│ (browser prompts biometric) │
│ │
│ POST /login/webauthn │
│ (assertion response) │
│ ──────────────────────────────►│
│ │
│ ◄─── 200 OK + session cookie │
src/main/java/dev/yutsuki/webauthn/
├── WebauthnApplication.java # Spring Boot entry point
├── config/
│ └── SecurityConfig.java # Security: CORS, CSRF, auth, WebAuthn
├── controller/
│ ├── AuthController.java # /api/auth/* (register, login, logout, me)
│ └── PasskeyController.java # /api/passkeys (list, delete)
├── dto/
│ ├── RegisterRequest.java # { email, password, displayName }
│ ├── LoginRequest.java # { email, password }
│ ├── UserResponse.java # { id, email, displayName, createdAt }
│ ├── PasskeyResponse.java # { credentialId, label, created, lastUsed }
│ └── ApiError.java # { error, message }
├── entity/
│ ├── AppUser.java # app_users table
│ ├── UserEntityRecord.java # user_entities table (WebAuthn)
│ └── CredentialRecordEntity.java # user_credentials table (WebAuthn)
├── repository/
│ ├── AppUserRepository.java # Spring Data JPA for AppUser
│ ├── UserEntityJpaRepository.java # Spring Data JPA for UserEntityRecord
│ ├── CredentialRecordJpaRepository.java # Spring Data JPA for CredentialRecordEntity
│ ├── JpaPublicKeyCredentialUserEntityRepository.java # Adapter → Spring Security
│ └── JpaUserCredentialRepository.java # Adapter → Spring Security
└── service/
└── JpaUserDetailsService.java # UserDetailsService backed by PostgreSQL
Static HTML pages are included at /login.html and /index.html for testing.
They call the REST API endpoints — they are not required for production use.
- W3C Web Authentication (WebAuthn) Spec — มาตรฐาน WebAuthn จาก W3C (spec เต็ม)
- MDN Web Docs — Web Authentication API — คำอธิบาย WebAuthn API ที่เข้าใจง่าย
- passkeys.dev — แหล่งข้อมูลเกี่ยวกับ Passkeys รวมทุกอย่าง
- FIDO Alliance — Passkeys — ข้อมูลจาก FIDO Alliance ผู้กำหนดมาตรฐาน
- Spring Security Reference — WebAuthn — เอกสาร Official: วิธีตั้งค่า Passkeys ใน Spring Security
- Spring Blog — Passkeys Support — Blog อธิบายการ support Passkeys ใน Spring Security 6.4+
- Spring Security — Session Management — อธิบาย HttpSession, SecurityContext, และ Session Fixation Protection
- Spring Security — Architecture — Filter Chain Architecture (SecurityFilterChain, DelegatingFilterProxy)
- MDN — HTTP Cookies — อธิบาย Cookie คืออะไร, HttpOnly, Secure, SameSite
- Spring Security — CSRF Protection — อธิบาย CSRF Protection ใน Spring Security
- OWASP — Cross-Site Request Forgery (CSRF) — อธิบายการโจมตี CSRF
- JWT.io — เว็บอธิบาย JWT + Debugger
- RFC 7519 — JSON Web Token — Spec ของ JWT
- JJWT (Java JWT) — Library สำหรับ Java ที่แนะนำ
- Baeldung — Spring Security JWT — Tutorial สร้าง JWT Authentication กับ Spring Security