Back

Passkey와 WebAuthn: 웹 앱에서 비밀번호를 완전히 없애는 완벽 가이드

2026년인데 아직 유저가 비밀번호를 치고 있어요. Apple, Google, Microsoft 전부 패스키 지원을 내놨는데, 대부분의 웹 앱은 아직도 2005년 방식 그대로예요. 비밀번호 해싱해서 DB에 저장하고, 아무도 안 뚫리길 기도하는 구조.

패스키가 어려워서가 아니에요. 구현하려고 까보면 문서화 안 된 함정이 곳곳에 깔려 있거든요. WebAuthn API가 데모에서는 간단하게 보이는데 프로덕션에서 깨지고, allowCredentials가 정확히 뭘 하는지 헷갈리고, navigator.credentials.get()이 특정 브라우저에서 조용히 실패해요. 기존 유저 50만 명을 세션 안 깨뜨리고 어떻게 마이그레이션 하냐가 진짜 문제죠.

이 글에서는 암호학 기초부터 TypeScript 전체 구현, Conditional UI, DB 스키마, 멀티디바이스 동기화, 유저를 강제로 바꾸지 않고 자연스럽게 전환하는 마이그레이션 전략까지 다 다뤄볼게요.


비밀번호가 여전히 위험한 이유

숫자가 다 말해줘요. 2025년 Verizon DBIR 리포트에 따르면 웹 앱 침해의 80% 이상이 유출되거나 약한 자격증명에서 시작돼요. 피싱이 먹히는 건 비밀번호가 결국 공유 비밀(shared secret)이기 때문이거든요. 서버도 알고, 유저도 알고, 중간에서 가로채거나 DB에서 꺼내면 그냥 끝이에요.

패스키가 진짜 해결하는 것

패스키는 공유 비밀 자체를 없애버리는 구조예요. 뭐가 다른지 까보면:

비밀번호 인증:
  유저 → 비밀번호 전송 → 서버가 해시 비교
  공격 면: 피싱, 크리덴셜 스터핑, DB 유출

패스키 인증:
  유저 → 프라이빗 키로 챌린지 서명 → 서버가 퍼블릭 키로 검증
  공격 면: 물리적 기기 탈취 (생체인증 필요)

프라이빗 키는 유저 기기를 절대 벗어나지 않아요. 서버는 퍼블릭 키만 저장해요. DB가 통째로 털려도 공격자가 얻는 건 쓸모없는 퍼블릭 키뿐이에요.

핵심 세 가지

  1. 피싱이 원천 차단돼요. 패스키는 도메인(Relying Party ID)에 암호학적으로 묶여 있거든요. evil-example.com에서 가짜 로그인 페이지를 아무리 잘 만들어도 example.com용 패스키는 발동 자체가 안 돼요. 브라우저가 프로토콜 레벨에서 막아버려서, 소셜 엔지니어링으로도 뚫을 수가 없어요.

  2. 공유 비밀이 아예 없어요. 서버가 비밀 정보를 보거나 저장하거나 전송하는 일 자체가 없거든요. 해싱할 것도, 솔팅할 것도, 유출될 것도 없으니까 크리덴셜 스터핑도 의미가 없어지는 거죠.

  3. MFA가 기본 내장이에요. 패스키 인증은 "가진 것"(디바이스) + "나인 것"(생체인증) 또는 "아는 것"(기기 PIN)을 알아서 조합해요. 인증 앱 따로 깔라고 할 필요 없이 MFA 수준 보안이 바로 확보되는 거예요.


WebAuthn 플로우 이해하기

Web Authentication API(WebAuthn)에는 두 가지 핵심 세레모니가 있어요: 등록(크리덴셜 생성)과 인증(로그인에 사용).

등록 플로우

┌──────────┐         ┌──────────┐         ┌──────────────┐
│  브라우저  │         │   서버    │         │  인증기(기기)  │
└────┬─────┘         └────┬─────┘         └──────┬───────┘
     │  1. 등록 시작       │                      │
     │ ─────────────────> │                      │
     │                    │                      │
     │  2. 챌린지 + 옵션   │                      │
     │ <───────────────── │                      │
     │                    │                      │
     │  3. 크리덴셜 생성   │                      │
     │ ─────────────────────────────────────────>│
     │                    │                      │
     │  4. 생체인증 프롬프트│                      │
     │ <─────────────────────────────────────────│
     │                    │                      │
     │  5. 퍼블릭 키 + 증명│                      │
     │ ─────────────────> │                      │
     │                    │                      │
     │  6. 검증 & 저장     │                      │
     │ <───────────────── │                      │

인증 플로우

┌──────────┐         ┌──────────┐         ┌──────────────┐
│  브라우저  │         │   서버    │         │  인증기(기기)  │
└────┬─────┘         └────┬─────┘         └──────┬───────┘
     │  1. 로그인 시작     │                      │
     │ ─────────────────> │                      │
     │                    │                      │
     │  2. 챌린지          │                      │
     │ <───────────────── │                      │
     │                    │                      │
     │  3. 챌린지 서명     │                      │
     │ ─────────────────────────────────────────>│
     │                    │                      │
     │  4. 생체인증 프롬프트│                      │
     │ <─────────────────────────────────────────│
     │                    │                      │
     │  5. 서명된 어설션    │                      │
     │ ─────────────────> │                      │
     │                    │                      │
     │  6. 서명 검증       │                      │
     │ <───────────────── │                      │

핵심 포인트: 5단계에서 인증기가 서버의 챌린지를 프라이빗 키로 서명해요. 서버는 저장된 퍼블릭 키로 그 서명을 검증하고요. 비밀이 전송되는 게 아니라, 유저가 프라이빗 키를 가지고 있다는 증명만 보내는 거예요.


SimpleWebAuthn으로 전체 구현하기

WebAuthn을 처음부터 직접 구현하는 건 미묘한 보안 버그의 지름길이에요. CBOR 파싱, 증명 검증, 챌린지 관리가 충분히 복잡해서 검증된 라이브러리를 쓰는 게 유일하게 합리적인 방법입니다. TypeScript에서는 SimpleWebAuthn이 가장 널리 쓰여요.

프로젝트 셋업

# 서버 사이드 npm install @simplewebauthn/server # 클라이언트 사이드 npm install @simplewebauthn/browser

데이터베이스 스키마

인증 코드 작성 전에 저장 레이어부터 설계하세요. 두 개의 테이블이 필요해요:

-- 유저 테이블 CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(255) UNIQUE NOT NULL, display_name VARCHAR(255), -- 마이그레이션 기간 동안 유지할 레거시 비밀번호 필드 password_hash VARCHAR(255), created_at TIMESTAMPTZ DEFAULT NOW() ); -- 패스키 크리덴셜 테이블 (유저 1명이 여러 패스키 등록 가능) CREATE TABLE passkey_credentials ( id VARCHAR(512) PRIMARY KEY, -- 크리덴셜 ID (base64url) user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, public_key BYTEA NOT NULL, -- raw bytes로 저장 counter BIGINT NOT NULL DEFAULT 0, -- 서명 카운터 device_type VARCHAR(50) NOT NULL, -- 'singleDevice' or 'multiDevice' backed_up BOOLEAN NOT NULL DEFAULT false, -- 클라우드 동기화 여부 transports TEXT[], -- ['internal', 'hybrid' 등] display_name VARCHAR(255), -- "MacBook Pro Touch ID" created_at TIMESTAMPTZ DEFAULT NOW(), last_used_at TIMESTAMPTZ ); CREATE INDEX idx_credentials_user_id ON passkey_credentials(user_id);

설계 포인트:

  • 1:N 관계. 유저 하나가 여러 패스키(폰, 노트북, 보안 키)를 등록할 수 있어요. 절대 1개로 제한하지 마세요.
  • counter 필드. 인증기는 사용할 때마다 서명 카운터를 증가시켜요. 저장된 값보다 낮은 카운터가 오면 크리덴셜이 복제됐다는 뜻이니 즉시 차단하고 플래그 걸어야 해요.
  • device_typebacked_up. 패스키가 디바이스 간 동기화되는지(iCloud Keychain, Google 비밀번호 관리자) 아니면 단일 기기 바인딩인지(하드웨어 보안 키) 알려줘요.
  • transports 배열. 인증기 통신 방식을 저장해요('internal'은 플랫폼 생체인증, 'hybrid'는 QR코드 크로스디바이스, 'usb'는 보안 키). 이후 인증 시 어떤 전송 방식을 먼저 시도할지 브라우저에 힌트를 줘서 속도를 높여요.

서버: 등록 엔드포인트

import { generateRegistrationOptions, verifyRegistrationResponse, type GenerateRegistrationOptionsOpts, type VerifiedRegistrationResponse, } from '@simplewebauthn/server'; const RP_NAME = 'My App'; const RP_ID = 'example.com'; const ORIGIN = 'https://example.com'; // 1단계: 옵션 생성 app.post('/api/auth/register/begin', async (req, res) => { const user = await getUserFromSession(req); // 이미 등록된 크리덴셜 조회 (중복 등록 방지) const existingCredentials = await db.query( 'SELECT id, transports FROM passkey_credentials WHERE user_id = $1', [user.id] ); const options = await generateRegistrationOptions({ rpName: RP_NAME, rpID: RP_ID, userName: user.username, userDisplayName: user.display_name || user.username, // 같은 인증기 재등록 방지 excludeCredentials: existingCredentials.rows.map(cred => ({ id: cred.id, transports: cred.transports, })), authenticatorSelection: { residentKey: 'required', // 필수: 이게 패스키로 만드는 핵심 userVerification: 'preferred', // 가능하면 생체인증 }, attestationType: 'none', // 소비자용 앱은 증명 생략 }); // 검증용으로 세션에 챌린지 저장 await setSessionChallenge(req, options.challenge); res.json(options); }); // 2단계: 응답 검증 app.post('/api/auth/register/complete', async (req, res) => { const user = await getUserFromSession(req); const expectedChallenge = await getSessionChallenge(req); try { const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({ response: req.body, expectedChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, }); if (!verification.verified || !verification.registrationInfo) { return res.status(400).json({ error: '검증 실패' }); } const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; // DB에 저장 await db.query( `INSERT INTO passkey_credentials (id, user_id, public_key, counter, device_type, backed_up, transports) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ credential.id, user.id, Buffer.from(credential.publicKey), credential.counter, credentialDeviceType, credentialBackedUp, credential.transports ?? [], ] ); res.json({ verified: true }); } catch (error) { console.error('등록 검증 실패:', error); res.status(400).json({ error: '등록 실패' }); } });

서버: 인증 엔드포인트

import { generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server'; // 1단계: 챌린지 생성 app.post('/api/auth/login/begin', async (req, res) => { const options = await generateAuthenticationOptions({ rpID: RP_ID, userVerification: 'preferred', // allowCredentials 비우기 = discoverable credential 플로우 // 인증기가 이 RP의 모든 크리덴셜을 보여줌 allowCredentials: [], }); // 챌린지 저장 (Redis 같은 단기 저장소, 5분 TTL) await setChallengeStore(options.challenge, { expiresIn: 300 }); res.json(options); }); // 2단계: 어설션 검증 app.post('/api/auth/login/complete', async (req, res) => { const { id: credentialId } = req.body; // 크리덴셜 조회 const credRow = await db.query( `SELECT pc.*, u.id as uid, u.username FROM passkey_credentials pc JOIN users u ON pc.user_id = u.id WHERE pc.id = $1`, [credentialId] ); if (credRow.rows.length === 0) { return res.status(401).json({ error: '알 수 없는 크리덴셜' }); } const cred = credRow.rows[0]; const expectedChallenge = await getChallengeStore(req.body.response.clientDataJSON); try { const verification = await verifyAuthenticationResponse({ response: req.body, expectedChallenge, expectedOrigin: ORIGIN, expectedRPID: RP_ID, credential: { id: cred.id, publicKey: new Uint8Array(cred.public_key), counter: Number(cred.counter), transports: cred.transports, }, }); if (!verification.verified) { return res.status(401).json({ error: '검증 실패' }); } // 카운터 업데이트 (복제 크리덴셜 감지용) const { newCounter } = verification.authenticationInfo; await db.query( `UPDATE passkey_credentials SET counter = $1, last_used_at = NOW() WHERE id = $2`, [newCounter, credentialId] ); // 세션 / JWT 발급 const token = await createSession(cred.uid); res.json({ verified: true, token }); } catch (error) { console.error('인증 실패:', error); res.status(401).json({ error: '인증 실패' }); } });

클라이언트: 브라우저 연동

import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // 등록 async function registerPasskey(): Promise<void> { // 1. 서버에서 옵션 받기 const optionsRes = await fetch('/api/auth/register/begin', { method: 'POST' }); const options = await optionsRes.json(); // 2. 크리덴셜 생성 (생체인증 프롬프트 뜸) const credential = await startRegistration({ optionsJSON: options }); // 3. 서버에 보내서 검증 const verifyRes = await fetch('/api/auth/register/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credential), }); const result = await verifyRes.json(); if (result.verified) { console.log('패스키 등록 완료'); } } // 인증 async function loginWithPasskey(): Promise<void> { const optionsRes = await fetch('/api/auth/login/begin', { method: 'POST' }); const options = await optionsRes.json(); const assertion = await startAuthentication({ optionsJSON: options }); const verifyRes = await fetch('/api/auth/login/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion), }); const result = await verifyRes.json(); if (result.verified) { window.location.href = '/dashboard'; } }

Conditional UI: 자동완성에 패스키 넣기

패스키의 가장 큰 UX 개선점은 등록 플로우가 아니에요. Conditional UI예요. "패스키로 로그인" 버튼을 따로 누르는 게 아니라, 유저명 입력 필드의 자동완성 드롭다운에 저장된 비밀번호 옆에 패스키가 바로 나와요.

작동 방식

<!-- autocomplete 속성이 핵심 --> <form> <input type="text" id="username" name="username" autocomplete="username webauthn" placeholder="이메일 또는 유저명" /> <input type="password" name="password" autocomplete="current-password" /> <button type="submit">로그인</button> </form>

autocomplete 속성의 webauthn 토큰이 브라우저에게 자동완성 드롭다운에 패스키 옵션을 포함하라고 알려줘요. 반드시 속성 값의 마지막에 위치해야 해요.

구현

import { startAuthentication, browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser'; async function initConditionalUI(): Promise<void> { // 기능 감지: 항상 먼저 체크 const supported = await browserSupportsWebAuthnAutofill(); if (!supported) { console.log('Conditional UI 미지원, 버튼 폴백'); return; } // 서버에서 인증 옵션 가져오기 const optionsRes = await fetch('/api/auth/login/begin', { method: 'POST' }); const options = await optionsRes.json(); try { // 이 호출은 유저가 자동완성에서 패스키를 선택할 때까지 "대기" const assertion = await startAuthentication({ optionsJSON: options, useBrowserAutofill: true, // Conditional UI 활성화 }); // 유저가 드롭다운에서 패스키를 선택함: 서버에서 검증 const verifyRes = await fetch('/api/auth/login/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion), }); const result = await verifyRes.json(); if (result.verified) { window.location.href = '/dashboard'; } } catch (error) { // 유저가 자동완성을 닫았거나 비밀번호를 선택한 경우 if ((error as Error).name !== 'AbortError') { console.error('Conditional UI 에러:', error); } } } // 페이지 로드 시 호출. 버튼 클릭이 아님 document.addEventListener('DOMContentLoaded', initConditionalUI);

꼭 알아야 할 구현 디테일

1. 페이지 로드 시 호출하세요. 클릭 시가 아니에요. Conditional UI는 페이지가 뜰 때 시작돼야 해요. 브라우저가 자동완성 드롭다운 인터랙션을 계속 감시하는 구조거든요. 버튼 클릭에서만 호출하면 매끄러운 자동완성 경험을 놓쳐요.

2. AbortController를 쓰세요. 유저가 소셜 로그인이나 매직 링크 같은 다른 방식으로 전환하면, 대기 중인 Conditional UI 요청을 중단해야 해요. 안 그러면 "request already pending" 에러가 터져요.

3. 폴백 전략. Conditional UI를 지원하지 않는 브라우저를 위해 "패스키로 로그인" 버튼을 항상 보이게 해두세요. 표준 모달 플로우가 폴백이 돼요.


단계별 마이그레이션 전략

하루아침에 스위치 눌러서 비밀번호를 날릴 수는 없잖아요. 프로덕션에서 실제로 먹히는 3단계 방법을 살펴볼게요.

1단계: 도입 (자발적 등록)

비밀번호로 로그인 성공하면 그때 패스키 등록을 살짝 권유하는 거예요. 강제는 ❌, 옵션만 보여주세요.

// 비밀번호 로그인 성공 후 async function postLoginPasskeyPrompt(user: User): Promise<void> { const hasPasskey = await userHasPasskey(user.id); if (hasPasskey) return; const dismissedCount = await getPromptDismissals(user.id); if (dismissedCount >= 3) return; // 귀찮게 하지 않기 // 비차단형 UI 프롬프트 표시 showPasskeyEnrollmentBanner({ title: '더 빠른 로그인을 써보세요', description: '비밀번호 대신 Face ID나 지문으로 로그인', onAccept: () => registerPasskey(), onDismiss: () => incrementPromptDismissals(user.id), }); }

2단계: 기본값 전환 (신규 유저는 패스키 우선)

신규 가입은 패스키부터 시작하는 거예요. 비밀번호도 쓸 수 있지만 어디까지나 보조 수단이에요.

async function registerNewUser(userData: NewUser): Promise<void> { const user = await createUser(userData); const webauthnSupported = await isWebAuthnSupported(); if (webauthnSupported) { await registerPasskey(); // 기본: 패스키 // 백업용으로 비밀번호도 선택적으로 수집 가능 } else { await setPassword(user.id, userData.password); // 폴백 } }

3단계: 전환 (비밀번호 없는 옵션)

패스키를 등록한 유저한테 비밀번호를 아예 꺼버릴 수 있는 옵션을 줘요.

app.post('/api/auth/disable-password', async (req, res) => { const user = await getUserFromSession(req); // 비밀번호 삭제 전 안전 장치 확인 const credentials = await getUserCredentials(user.id); if (credentials.length < 2) { return res.status(400).json({ error: '비밀번호 비활성화 전에 패스키를 2개 이상 등록하세요' }); } // 동기화된(클라우드 백업) 패스키가 최소 1개 있는지 확인 const hasSyncedPasskey = credentials.some(c => c.backed_up); if (!hasSyncedPasskey) { return res.status(400).json({ error: '복구를 위해 동기화된 패스키(iCloud/Google)를 1개 이상 등록하세요' }); } // 비밀번호 소프트 삭제 await db.query( 'UPDATE users SET password_hash = NULL WHERE id = $1', [user.id] ); res.json({ success: true }); });

마이그레이션 지표 대시보드

잘 굴러가고 있는지 확인하려면 이 지표들을 꼭 트래킹하세요:

지표목표무엇을 알려주는가
패스키 채택률6개월 내 30% 이상전체 마이그레이션 속도
등록 완료율시작한 유저의 80% 이상등록 UX 마찰도
인증 성공률99% 이상패스키 플로우의 안정성
폴백 사용률하락 추세유저가 비밀번호에서 벗어나고 있는지
프롬프트 무시율60% 미만등록 프롬프트가 너무 공격적인지

프로덕션 보안 함정

1. 챌린지 재사용 공격

챌린지는 반드시 일회용이고 시간 제한이 걸려 있어야 해요. stateless JWT에 챌린지를 넣으면? 공격자가 그대로 재사용할 수 있어요.

// 나쁜 방법: 서명된 쿠키에 챌린지 (재사용 가능) const challenge = signJWT({ challenge: randomBytes(32) }); // 좋은 방법: TTL이 있는 서버 사이드 저장소 const challenge = randomBytes(32).toString('base64url'); await redis.setex(`webauthn:challenge:${sessionId}`, 300, challenge); // 검증 후 즉시 삭제 await redis.del(`webauthn:challenge:${sessionId}`);

2. 카운터 검증

서명 카운터는 크리덴셜 복제를 잡아내는 방어막이에요. 카운터가 뒤로 가면? 뭔가 심각하게 잘못된 거예요.

async function validateCounter( credentialId: string, newCounter: number ): Promise<void> { const stored = await getStoredCounter(credentialId); if (newCounter > 0 && newCounter <= stored) { // 복제 크리덴셜 의심 await flagCredentialAsCompromised(credentialId); await notifySecurityTeam({ event: 'counter_regression', credentialId, storedCounter: stored, receivedCounter: newCounter, }); throw new Error('크리덴셜 카운터 역행 감지'); } }

주의: 일부 플랫폼 인증기(특히 동기화되는 패스키)는 카운터를 항상 0으로 보고해요. 이 경우 카운터 검증이 사실상 비활성화돼요. 카운터가 실제로 사용될 때(0보다 클 때)만 역행을 감지하세요.

3. Origin 검증

Origin 체크는 항상 엄격하게. 잘못 설정하면 패스키의 핵심 가치인 피싱 방어가 무력화돼요.

// 나쁜 방법: 부분 문자열 매칭 (서브도메인 공격에 취약) if (origin.includes('example.com')) { ... } // 좋은 방법: 허용된 Origin 목록과 정확히 매칭 const ALLOWED_ORIGINS = [ 'https://example.com', 'https://app.example.com', ]; if (!ALLOWED_ORIGINS.includes(origin)) { throw new Error('허용되지 않은 Origin'); }

4. 계정 복구

패스워드리스에서 제일 골치 아픈 문제가 이거예요. 유저가 기기를 전부 잃어버리면 어쩌냐는 거죠.

interface RecoveryStrategy { // 권장: 패스키 최소 2개 등록 minimumPasskeys: 2; // 동기화된 크리덴셜 1개 이상 권장 requireSyncedCredential: true; // 백업 수단 (우선순위순) fallbacks: [ 'recovery_codes', // 등록 시 생성된 일회용 코드 'email_magic_link', // 시간 제한 로그인 링크 'identity_verification' // ID 확인 수동 프로세스 ]; }

초기 패스키 등록 시 복구 코드를 생성하고 유저에게 안전하게 보관하라고 안내하세요. 보안 키에서 수년간 사용해온 패턴이고, 잘 작동해요.


크로스 플랫폼 고려사항

디바이스 동기화 동작

플랫폼동기화 메커니즘범위
AppleiCloud Keychain같은 Apple ID의 모든 Apple 기기
GoogleGoogle 비밀번호 관리자같은 Google 계정의 Chrome/Android
MicrosoftMicrosoft Authenticator같은 MS 계정의 Windows 기기
1Password / Bitwarden서드파티 볼트크로스플랫폼, 볼트 접근 가능한 모든 기기

크로스 디바이스 플로우 (하이브리드 전송)

패스키가 없는 기기에서 로그인하려 할 때 "하이브리드" 전송이 작동해요:

  1. 데스크톱 브라우저가 QR 코드를 보여줌
  2. 유저가 패스키가 있는 폰으로 스캔
  3. 폰에서 생체인증 프롬프트
  4. 데스크톱에서 인증 완료

이건 자동으로 작동해요. 별도로 구현할 건 없어요. transports'hybrid'가 포함돼 있으면 브라우저와 인증기가 전체 플로우를 알아서 처리해요.


성능과 비용 모델

패스키 인증은 규모가 커질수록 비밀번호 인증보다 확실히 저렴해요:

요소비밀번호패스키
서버 연산bcrypt/Argon2 해싱 (CPU 집약적)Ed25519 서명 검증 (빠름)
유저당 저장~128 바이트 (해시 + 솔트)~256 바이트 (퍼블릭 키 + 메타데이터)
고객 지원 티켓"비밀번호 까먹었어요" (지원 볼륨의 ~40%)등록 후 거의 제로
침해 비용건당 평균 444만 달러탈취할 수 있는 자격증명 없음
SMS 2FA 비용건당 $0.01-0.05$0 (검증 내장)

유저 10만 명 서비스에서 비밀번호 리셋 플로우만 없애도 월 15-20시간의 고객지원 시간이 절약돼요.


2026년의 현실

패스키는 "언젠가" 쓸 미래 기술이 아니에요. 지금 바로 쓸 수 있는 현재 기술이거든요. 모든 주요 브라우저가 지원하고, 주요 플랫폼 전부 동기화 되고, WebAuthn 스펙도 안정화된 상태예요. SimpleWebAuthn 같은 라이브러리 덕분에 위험한 암호학 디테일은 직접 다룰 필요도 없고요.

진짜 걸림돌은 기술이 아니라 조직의 관성이에요. "유저들이 비밀번호에 익숙한데"라거나 "기존 로그인 플로우를 못 건드려"라고 망설이는 팀이 많거든요. 근데 단계별 마이그레이션이면 해결돼요. 첫날부터 비밀번호를 없앨 필요 없어요. 더 좋은 걸 옆에 하나 놓아주기만 하면 되니까요.

실제로 패스키를 지금 출시하는 팀들은 등록 UX만 매끄러우면 6개월 내 50-70% 자발적 채택률을 찍고 있어요. 유저들이 진짜로 비밀번호 치는 것보다 지문 한 번 터치하는 걸 좋아하거든요. 전환 마찰은 줄고, 지원 부하는 떨어지고, 보안은 극적으로 올라가요.

Conditional UI부터 시작해 보세요. UI 변경이 거의 없어요. autocomplete 속성 하나랑 페이지 로드 스크립트면 끝이에요. 패스키 있는 유저는 알아서 빠른 경로를 타고, 없는 유저는 원래 보던 로그인 폼 그대로 보이거든요. 방해 제로, 이득 최대.

비밀번호는 60년 된 기술이에요. 슬슬 은퇴시켜 줄 때가 됐어요.

AuthenticationSecurityWebAuthnPasskeysTypeScriptWeb Development

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요