공개키만 남기고 비밀번호를 걷어내기: Node.js와 SimpleWebAuthn으로 패스키 등록·인증을 구현하고 기존 로그인과 단계적으로 통합하기
인증 시스템을 처음부터 다시 짜야 한다는 얘기를 들으면 솔직히 긴장됩니다. 비밀번호 해싱, 세션 관리, 2FA 연동까지 이미 쌓아온 코드가 있는데, 거기에 WebAuthn이라는 새로운 개념을 얹어야 한다니. 저도 처음에 W3C 명세를 펼쳤다가 CBOR, COSE, attestation 같은 단어들을 보며 탭을 닫아버린 기억이 납니다. 그런데 SimpleWebAuthn 라이브러리를 발견한 순간 생각이 바뀌었습니다. 저 단어들을 직접 파싱할 필요가 없었거든요.
이 글에서는 패스키가 내부적으로 어떻게 동작하는지(공개키·비밀키 쌍, 챌린지-서명 검증 흐름)를 이해하고, @simplewebauthn/server v13으로 4개 엔드포인트를 실제로 구현하는 방법을 다룹니다. 그리고 현실적으로 더 중요한 문제—기존 비밀번호 로그인과 어떻게 점진적으로 공존시키는지—까지 이어서 살펴봅니다. "지금 당장 다 바꾸자"가 아니라, 프로덕션에서 안전하게 전환할 수 있는 경로를 찾는 쪽에 초점을 맞췄습니다.
Node.js LTS 20+, Express, 그리고 세션 스토어(Redis 또는 express-session)가 있는 환경을 기준으로 설명합니다. Fastify나 Hono를 쓰더라도 엔드포인트 로직은 동일하게 적용됩니다.
핵심 개념
WebAuthn이 비밀번호와 근본적으로 다른 이유
비밀번호 인증은 서버와 클라이언트가 같은 비밀(shared secret)을 알고 있다는 전제에서 작동합니다. 그 비밀이 서버 DB에 저장되어 있으니, DB가 털리면 끝입니다. 해싱을 해도 브루트포스, 크리덴셜 스터핑의 대상이 됩니다.
패스키는 구조 자체가 다릅니다. 서버에는 공개키만 저장되고, 비밀키는 인증 과정에서 기기 바깥으로 나가지 않습니다. 인증 과정은 "서버가 낸 문제를 기기만 풀 수 있다"는 구조입니다. 서버 DB가 유출되어도 공개키만 노출될 뿐, 그것으로 인증을 흉내낼 수 없습니다.
그리고 자격증명이 Origin에 바인딩됩니다. example.com에서 만든 패스키는 evil-example.com에서 서명 요청이 와도 응답하지 않습니다. 단, 이 피싱 저항성은 패스키 인증 경로에만 해당됩니다. 비밀번호 폼을 병행 운영하는 동안에는 해당 폼 자체가 여전히 피싱 공격면으로 남아있습니다. 전환 기간에 "패스키는 안전하다"는 메시지를 과장하지 않는 것이 중요합니다.
동기화 패스키와 기기 고정 패스키
"비밀키가 기기를 떠나지 않는다"는 표현은 정확히는 **기기 고정 패스키(device-bound passkey)**에 해당합니다. YubiKey 같은 하드웨어 키나 Windows Hello(TPM 연동)가 여기에 속합니다.
반면 오늘날 소비자 대다수가 쓰는 것은 **동기화 패스키(synced passkey)**입니다. iCloud Keychain이나 Google Password Manager를 통해 같은 계정의 여러 기기에 암호화된 채로 복제됩니다. 비밀키가 플랫폼 공급자 클라우드를 경유하는 셈이라, 보안 모델이 "완전 로컬"이 아니라 "플랫폼 공급자 신뢰"로 이동합니다.
이 구분은 두 가지에서 실질적으로 영향을 미칩니다. 첫째, 계정 복구가 자연스럽습니다. 기기를 잃어도 Apple/Google 계정으로 접근하면 패스키가 살아있습니다. 둘째, counter 기반 복제 감지가 사실상 무력화됩니다. 동기화 패스키는 signCount를 항상 0으로 보고하기 때문입니다. 이 점은 DB 스키마 설명에서 다시 언급합니다.
두 가지 Ceremony: 등록과 인증
WebAuthn에는 두 가지 핵심 흐름이 있습니다. 명세에서는 이를 Ceremony라고 부르는데, 그냥 "등록 플로우"와 "로그인 플로우"라고 생각하면 됩니다.
등록 흐름
인증 흐름
핵심 용어 정리
| 용어 | 설명 |
|---|---|
| Relying Party | 인증을 요청하는 주체, 즉 우리가 만드는 Node.js 서버 |
| Authenticator | Face ID, Touch ID, Windows Hello, YubiKey 등 실제 서명을 수행하는 장치 |
| Challenge | 서버가 발행하는 일회성 랜덤값. 재전송 공격 방지의 핵심 |
| rpID | 보통 도메인명(example.com). 브라우저가 이 값으로 Origin 바인딩을 검증 |
| counter | 인증 횟수 카운터. 기기 고정 패스키에서 복제 감지용. 동기화 패스키는 항상 0을 보고하므로 감지 불가 |
| resident key | 사용자명 없이도 기기에서 계정을 불러올 수 있는 discoverable credential |
DB 스키마: 사용자당 여러 자격증명
기존 비밀번호 시스템과 달리, 패스키는 사용자 한 명이 여러 기기에 여러 자격증명을 등록할 수 있습니다. 테이블 설계가 1:N 관계가 됩니다.
-- 기존 users 테이블은 그대로 두고 credentials 테이블을 추가
CREATE TABLE credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
credential_id TEXT NOT NULL UNIQUE, -- base64url 인코딩
public_key BYTEA NOT NULL, -- COSE 인코딩 공개키
counter BIGINT NOT NULL DEFAULT 0,
transports TEXT[], -- ['internal', 'hybrid', 'usb']
created_at TIMESTAMPTZ DEFAULT now(),
last_used_at TIMESTAMPTZ
);credential_id, public_key, counter, transports 네 가지가 핵심입니다. counter는 기기 고정 패스키(YubiKey 등)에서 복제 감지에 유용하지만, iCloud Keychain·Google Password Manager 같은 동기화 패스키는 signCount를 0으로 고정 보고합니다. 즉 가장 많이 쓰이는 소비자 패스키에서는 counter 증가 여부로 복제를 감지할 수 없습니다. counter 업데이트는 여전히 해야 하지만, 이 한계를 인식한 채로 설계해야 합니다.
실전 적용
패키지 설치
npm install @simplewebauthn/server @simplewebauthn/browser
# 세션 스토어가 없는 경우
npm install express-session connect-redisv13.x는 Node.js LTS 20 이상에서 ESM으로 제공됩니다. tsconfig.json에서 "module": "NodeNext" 또는 "ESNext" 설정을 미리 확인해두세요.
TypeScript 세션 타입 확장
기본 express-session 타입에는 커스텀 필드가 없어서, 아래 선언 없이 req.session.registrationChallenge를 쓰면 TypeScript 컴파일 에러가 납니다. 프로젝트 루트의 session.d.ts에 한 번 추가해두면 됩니다.
// session.d.ts
import 'express-session';
declare module 'express-session' {
interface SessionData {
registrationChallenge?: string;
authChallenge?: string;
userId?: string;
// authUserId는 discoverable 플로우에서 세션에 의존하지 않으므로 포함하지 않음
}
}등록 플로우: 두 개의 엔드포인트
1단계: 등록 옵션 생성 (POST /register/start)
import { generateRegistrationOptions } from '@simplewebauthn/server';
import type { Request, Response } from 'express';
export async function registerStart(req: Request, res: Response) {
// req.user는 Passport.js 또는 직접 구현한 세션 미들웨어가 채워주는 현재 로그인 사용자
const user = req.user;
// 이미 등록된 자격증명은 excludeCredentials로 중복 방지
const existingCredentials = await db.credentials.findByUserId(user.id);
const options = await generateRegistrationOptions({
rpName: 'My App',
rpID: process.env.RP_ID ?? 'localhost',
userID: new Uint8Array(Buffer.from(user.id)),
userName: user.email,
userDisplayName: user.name,
// 'none': 기기 제조사 진위 확인(attestation)을 요구하지 않음
// 포기하는 것: 이 자격증명이 정말 Secure Enclave에서 생성됐는지 서버에서 검증 불가
// 의료·금융·엔터프라이즈 환경에서는 'direct' 또는 'indirect' 검토 필요
attestationType: 'none',
excludeCredentials: existingCredentials.map(c => ({
id: c.credentialId,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: 'preferred',
// 'preferred'는 UV 미지원 장치에서 사용자 검증을 건너뛸 수 있어 AAL1 수준
// NIST SP 800-63-4 AAL2 요건이 필요한 서비스는 'required'로 변경
userVerification: 'preferred',
},
});
// 챌린지를 세션에 저장 — finish 단계 검증에 반드시 필요
// 재전송 공격 방지를 위해 세션 TTL을 5~10분으로 제한할 것 (아래 세션 설정 참고)
req.session.registrationChallenge = options.challenge;
res.json(options);
}2단계: 등록 응답 검증 (POST /register/finish)
import { verifyRegistrationResponse } from '@simplewebauthn/server';
export async function registerFinish(req: Request, res: Response) {
const expectedChallenge = req.session.registrationChallenge;
if (!expectedChallenge) {
return res.status(400).json({ error: '챌린지가 없습니다. 등록을 다시 시작해주세요.' });
}
try {
const { verified, registrationInfo } = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: process.env.EXPECTED_ORIGIN ?? 'http://localhost:3000',
expectedRPID: process.env.RP_ID ?? 'localhost',
});
if (!verified || !registrationInfo) {
return res.status(400).json({ error: '검증 실패' });
}
// v13의 credential 구조 — v12의 credentialID/credentialPublicKey와 이름이 다름
const { credential } = registrationInfo;
await db.credentials.create({
userId: req.user.id,
credentialId: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports ?? [],
});
delete req.session.registrationChallenge;
res.json({ verified: true });
} catch (err) {
// verifyRegistrationResponse는 파싱 오류·서명 불일치 등에서 예외를 던짐
// try-catch 없이 배포하면 Express 기본 핸들러가 스택 트레이스를 노출할 수 있음
console.error('패스키 등록 검증 실패:', err);
return res.status(400).json({ error: '등록 처리 중 오류가 발생했습니다.' });
}
}v13에서 registrationInfo의 구조가 달라졌습니다. v12에서 쓰던 credentialID, credentialPublicKey 같은 이름이 아니라 registrationInfo.credential.id, registrationInfo.credential.publicKey 형태입니다. 구버전 예제와 섞어 쓰다가 한번 고생한 지점이라 강조해두고 싶습니다.
인증 플로우: 두 개의 엔드포인트
3단계: 인증 옵션 생성 (POST /login/start)
이메일을 optional로 받는 것이 포인트입니다. allowCredentials를 생략하면 브라우저가 기기에 저장된 패스키 목록을 알아서 찾아주는 discoverable credential 방식으로 동작해서, 이메일 입력 없이 "패스키로 로그인" 버튼 하나로 처리할 수 있습니다.
import { generateAuthenticationOptions } from '@simplewebauthn/server';
export async function loginStart(req: Request, res: Response) {
const { email } = req.body; // optional — 없으면 discoverable 플로우
let allowCredentials: { id: string; transports: string[] }[] | undefined;
if (email) {
const user = await db.users.findByEmail(email);
if (user) {
const userCredentials = await db.credentials.findByUserId(user.id);
allowCredentials = userCredentials.map(c => ({
id: c.credentialId,
transports: c.transports,
}));
}
// user가 없어도 빈 allowCredentials로 처리 — 사용자 존재 여부 노출 방지
}
const options = await generateAuthenticationOptions({
rpID: process.env.RP_ID ?? 'localhost',
userVerification: 'preferred',
...(allowCredentials && { allowCredentials }),
});
req.session.authChallenge = options.challenge;
// userId는 여기서 세션에 저장하지 않음
// finish 단계에서 credentialId로 DB를 조회해 도출 — discoverable 플로우 지원의 핵심
res.json(options);
}4단계: 인증 응답 검증 (POST /login/finish)
discoverable 플로우를 올바르게 지원하려면 authUserId를 세션에 저장하고 꺼내는 패턴을 쓰면 안 됩니다. 브라우저가 보내온 req.body.id(credentialId)로 DB에서 credential을 먼저 찾고, 거기서 userId를 도출하는 방식이 이메일 있음·없음 두 경로를 모두 처리합니다.
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
export async function loginFinish(req: Request, res: Response) {
const expectedChallenge = req.session.authChallenge;
if (!expectedChallenge) {
return res.status(400).json({ error: '인증 세션이 만료되었습니다.' });
}
try {
// credentialId로 먼저 조회 — discoverable·이메일 플로우 모두 동작
const dbCredential = await db.credentials.findByCredentialId(req.body.id);
if (!dbCredential) {
return res.status(400).json({ error: '자격증명을 찾을 수 없습니다.' });
}
const { verified, authenticationInfo } = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: process.env.EXPECTED_ORIGIN ?? 'http://localhost:3000',
expectedRPID: process.env.RP_ID ?? 'localhost',
credential: { // v13: 파라미터명이 'credential' (v12는 'authenticator')
id: dbCredential.credentialId,
publicKey: dbCredential.publicKey,
counter: dbCredential.counter,
transports: dbCredential.transports,
},
});
if (!verified) {
return res.status(401).json({ error: '인증 실패' });
}
// 낙관적 잠금으로 counter 업데이트 — 동시 요청 두 개가 같은 counter로 통과하는 것을 방지
const updated = await db.credentials.updateCounterIfMatch(
dbCredential.id,
authenticationInfo.newCounter,
dbCredential.counter // 예상 counter 값 — 조회 시점과 다르면 업데이트 실패
);
if (!updated) {
return res.status(401).json({ error: '동시 인증 충돌이 감지되었습니다.' });
}
delete req.session.authChallenge;
// dbCredential에서 userId를 도출 — 이메일 없는 discoverable 플로우도 여기서 처리
req.session.userId = dbCredential.userId;
res.json({ verified: true });
} catch (err) {
console.error('패스키 인증 검증 실패:', err);
return res.status(400).json({ error: '인증 처리 중 오류가 발생했습니다.' });
}
}updateCounterIfMatch는 SQL로 이렇게 구현합니다.
-- WHERE counter = :expectedCounter 조건이 낙관적 잠금의 핵심
-- 다른 요청이 먼저 업데이트했다면 RETURNING 결과가 비어 있음
UPDATE credentials
SET counter = :newCounter, last_used_at = NOW()
WHERE id = :id AND counter = :expectedCounter
RETURNING id;verifyAuthenticationResponse의 파라미터명이 v12에서는 authenticator, v13에서는 credential입니다. TypeScript 타입 에러가 나면 이 이름을 먼저 확인해보세요.
챌린지 TTL 설정
만료되지 않는 챌린지는 재전송 공격 방어를 약화시킵니다. express-session의 cookie maxAge 또는 Redis TTL로 5~10분 제한을 걸어두세요.
// express-session 사용 시
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: { maxAge: 10 * 60 * 1000 }, // 10분
}));
// Redis로 챌린지를 별도 관리할 경우
await redis.set(`challenge:${sessionId}`, challenge, 'EX', 600); // 600초브라우저 측 연동
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
async function registerPasskey() {
const optionsJSON = await fetch('/register/start', {
method: 'POST',
credentials: 'include',
}).then(r => r.json());
// v13: { optionsJSON } 객체 형태로 전달
const registrationResponse = await startRegistration({ optionsJSON });
const result = await fetch('/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registrationResponse),
credentials: 'include',
}).then(r => r.json());
return result.verified;
}
// email은 optional — 없으면 discoverable 플로우, 있으면 allowCredentials 플로우
async function loginWithPasskey(email?: string) {
const optionsJSON = await fetch('/login/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
credentials: 'include',
}).then(r => r.json());
const authenticationResponse = await startAuthentication({ optionsJSON });
const result = await fetch('/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authenticationResponse),
credentials: 'include',
}).then(r => r.json());
return result.verified;
}기존 비밀번호 로그인과 점진적 통합
Corbado가 분석한 Authenticate 2025 사례에 따르면, eBay는 성공적 로그인 직후 패스키 등록 프롬프트를 노출한 결과 설정 메뉴 진입 방식 대비 채택률이 약 102% 높아졌습니다. 강제보다 맥락적 권유가 훨씬 효과적이라는 걸 보여주는 대표적인 예입니다.
4단계 전환 경로를 도식화하면 다음과 같습니다.
비밀번호 로그인 성공 후 패스키 등록을 권유하는 응답 패턴은 이렇게 구성해볼 수 있습니다.
export async function passwordLoginFinish(req: Request, res: Response) {
// ... 기존 비밀번호 검증 로직 ...
const userCredentials = await db.credentials.findByUserId(user.id);
res.json({
success: true,
// 패스키가 없는 사용자에게 등록 권유 플래그 반환
suggestPasskeyRegistration: userCredentials.length === 0,
});
}클라이언트에서 이 플래그를 받아 "더 빠르게 로그인하고 싶으신가요? 패스키를 등록해보세요" 모달을 띄우는 방식입니다. 강제가 아니라 맥락에서 자연스럽게 제안하는 것이 포인트입니다. 그리고 병행 운영 기간에는 비밀번호 폼 자체가 여전히 피싱 공격면이라는 점을 설계에 반영해야 합니다. 패스키만 있는 사용자가 늘어날수록 비밀번호 폼 노출 범위를 점진적으로 줄이는 전략이 유효합니다.
환경별 설정은 환경변수로 분리해두면 실수를 줄일 수 있습니다.
const rpID = process.env.RP_ID ?? 'localhost';
const expectedOrigin = process.env.EXPECTED_ORIGIN ?? 'http://localhost:3000';로컬 개발 환경에서는 rpID: 'localhost', expectedOrigin: 'http://localhost:3000'으로 맞춰야 하는데, 프로덕션 설정을 그대로 가져오다 검증 오류가 나는 경우가 있습니다.
장단점 분석
장점
| 항목 | 실무 의미 |
|---|---|
| 피싱 저항성 | Origin 바인딩으로 패스키 인증 경로에서 가짜 사이트 서명 요청 자체를 차단. 단, 비밀번호 폼 병행 운영 중에는 해당 폼이 피싱 공격면으로 유지됨 |
| 서버 DB 유출 영향 최소화 | 공개키만 저장되므로 유출되어도 인증 불가 |
| 사용자 경험 | Amazon: 기존 로그인 대비 약 6배 빠른 인증 (FIDO Alliance 사례 연구), TikTok: 약 17배 빠른 로그인 (Corbado, Authenticate 2025 분석) |
| 보안 사고 감소 | CVS Health: 모바일 계정 탈취 사기 약 98% 감소 (state-of-passkeys.io 사례 연구) |
| 규제 준수 | userVerification: 'required' 설정 시 NIST SP 800-63-4 AAL2, EU NIS2 피싱 저항성 요건 충족 가능. 'preferred'는 UV 미지원 장치에서 AAL1 수준으로 동작 가능 |
| 비용 절감 | 비밀번호 재설정 1건당 약 $70 추산 (FIDO Alliance), 패스키 전환으로 60~80% 감소 가능 |
단점 및 주의사항
| 항목 | 대응 방안 |
|---|---|
| 계정 복구 설계 복잡 | 복구 코드, 이메일 백업, 다중 패스키 등록 중 하나 이상 설계 필요 |
| 서버 스테이트풀 요구 | 챌린지를 Redis나 세션에 임시 저장하는 구조. 완전 무상태 아키텍처에선 추가 설계 필요 |
| 구형 OS 지원 | iOS 15 이하, Android 8 이하는 플랫폼 인증 미지원. 하드웨어 키 fallback 고려 가능 |
| 동기화 패스키 counter 무력화 | iCloud Keychain·Google Password Manager는 signCount를 0으로 보고. 가장 많이 쓰이는 소비자 패스키에서 counter 기반 복제 감지 불가 |
| UX 교육 비용 | "디지털 자동차 키처럼 기기에 저장된 열쇠"라는 설명이 실제로 효과적 |
실무에서 자주 만나는 실수
챌린지를 저장하지 않거나 TTL을 설정하지 않는 경우
가장 흔한 오류입니다. generateRegistrationOptions 호출 후 반환된 options.challenge를 세션에 저장하지 않으면 finish 단계에서 expectedChallenge를 구성할 수 없습니다. 저장했더라도 TTL을 설정하지 않으면 오래된 챌린지가 재사용될 수 있습니다. 5~10분 제한은 필수입니다.
counter를 업데이트하지 않거나 경쟁 조건을 처리하지 않는 경우
verifyAuthenticationResponse가 성공하면 authenticationInfo.newCounter가 반환됩니다. 이 값을 DB에 반영하지 않으면 기기 고정 패스키에서의 복제 감지 기능이 무력화됩니다. 그리고 SELECT → UPDATE 두 쿼리 패턴은 동시 요청에서 같은 counter 값이 두 번 통과할 수 있어 낙관적 잠금이 필요합니다.
v12 예제를 v13에 그대로 적용하는 경우
npm에서 검색하면 나오는 예제들 상당수가 v12 기준입니다. authenticator → credential, credentialID → credential.id, credentialPublicKey → credential.publicKey 이름이 바뀌었으니 패키지 버전과 문서 버전을 맞춰서 참고하세요.
loginFinish에서 authUserId 세션에 의존하는 경우
loginStart에서 authUserId를 세션에 저장하고 loginFinish에서 꺼내는 패턴을 쓰면, 이메일 없이 시작한 discoverable 플로우에서 400이 됩니다. findByCredentialId로 credential을 먼저 조회하고 dbCredential.userId로 identity를 도출하면 두 경로를 모두 처리할 수 있습니다.
마치며
패스키의 핵심은 비밀키가 인증 과정에서 서버로 전달되지 않는다는 것입니다. 서버에 공개키만 남기고, 인증은 사용자 기기가 챌린지에 서명하는 방식으로 이루어집니다. SimpleWebAuthn v13은 CBOR/COSE 파싱과 암호 검증을 모두 추상화해서, 개발자는 4개 엔드포인트와 DB 스키마 설계에만 집중할 수 있습니다.
짚어둘 포인트가 두 가지 있습니다. userVerification: 'preferred'는 일반 소비자 서비스에 적합하지만 AAL2 요건이 필요한 환경에서는 'required'로 바꿔야 합니다. 그리고 iCloud Keychain·Google Password Manager 기반 동기화 패스키는 counter를 0으로 보고하므로 counter 기반 복제 감지가 실질적으로 동작하지 않습니다.
기존 비밀번호 로그인과 동시에 운영하면서 점진적으로 전환하는 것이 현실적인 경로입니다. 단, 전환 기간 동안 비밀번호 폼이 여전히 피싱 공격면으로 남아있다는 점을 설계에 반영하세요. 하드 컷오버보다 성공 로그인 후 맥락적 권유 방식이 훨씬 높은 채택률로 이어집니다.
지금 바로 시작해볼 수 있는 3단계입니다.
- 스키마 마이그레이션:
credentials테이블을 추가하고, 기존users테이블은 그대로 유지합니다. 하위 호환성을 깨지 않으면서 시작할 수 있습니다. - 4개 엔드포인트 추가: 기존 인증 로직을 건드리지 않고
/register/start,/register/finish,/login/start,/login/finish를 별도 라우터로 추가합니다. - 성공 로그인 후 넛지: 비밀번호 로그인 성공 시
suggestPasskeyRegistration플래그를 반환하고, 클라이언트에서 등록 모달을 자연스럽게 노출합니다.
구현 복잡도보다 계정 복구 플로우와 사용자 교육이 실제로 더 시간이 걸립니다. 기술적인 부분은 1~2 스프린트면 충분하지만, 복구 코드나 백업 이메일 플로우는 별도로 설계 시간을 두세요.
참고 자료
- SimpleWebAuthn 공식 문서
- @simplewebauthn/server NPM
- SimpleWebAuthn GitHub Releases & CHANGELOG
- SimpleWebAuthn 예제 프로젝트
- Node.js Passkeys & WebAuthn in 2026: Production Guide | HireNodeJS
- FreeCodeCamp: Set Up WebAuthn in Node.js for Passwordless Biometric Login
- Leapcell: Passwordless Authentication in Node.js with Passkeys and WebAuthn
- Medium: How to Implement Passwordless Authentication in Node.js Using SimpleWebAuthn
- Authgear: From Passwords to Passkeys - A Phased Migration Plan
- MojoAuth: 90-Day Passkey Migration Playbook
- PasskeyCentral: The Process to Gradually Adopt Passkeys
- state-of-passkeys.io: Case Studies
- Corbado: Passkey Adoption Case Studies from Authenticate 2025
- NIST SP 800-63-4 디지털 아이덴티티 가이드라인
- W3C Web Authentication Level 3 명세