--- id: security-2fa-totp-webauthn title: 2FA — TOTP / WebAuthn / Passkey category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [security, 2fa, totp, webauthn, passkey, vibe-coding] tech_stack: { language: "TS / WebAuthn", applicable_to: ["Backend", "Frontend"] } applied_in: [] aliases: [2FA, TOTP, Google Authenticator, WebAuthn, passkey, FIDO2] --- # 2FA / Passkeys > SMS 2FA = SIM swap 위험. **TOTP (Authenticator) = 무료 + 안전**, **WebAuthn / Passkey = 진짜 phishing-resistant**. iOS/Android Passkey 가 미래. ## 📖 핵심 개념 - TOTP: 6자리 30초 코드 (RFC 6238). HMAC + 시간. - WebAuthn: 공개키 인증. 서버 = 비밀 X. - Passkey: WebAuthn 의 동기화 + UX 개선 — Apple/Google 계정에 sync. - Recovery codes: 백업 — 1회용. ## 💻 코드 패턴 ### TOTP (otplib) ```ts import { authenticator } from 'otplib'; // Setup const secret = authenticator.generateSecret(); // base32 const otpauth = authenticator.keyuri(user.email, 'Acme', secret); // QR: otpauth → qrcode 라이브러리 await db.user2fa.upsert({ userId: user.id, secret, enabled: false }); // Verify (활성화) function verify(code: string, secret: string) { return authenticator.check(code, secret); } // 정상 검증 후 await db.user2fa.update(userId, { enabled: true }); ``` ### Login flow with TOTP ```ts // Step 1: password const u = await verifyPassword(email, password); if (!u) return 401; if (u.twoFactorEnabled) { // 임시 token (TOTP 만 검증 가능, 30초 유효) return { needs2FA: true, partialToken: signPartial({ userId: u.id }) }; } return loginSuccess(u); // Step 2: TOTP app.post('/login/2fa', async (req, res) => { const partial = verifyPartial(req.body.partialToken); const { secret } = await db.user2fa.find(partial.userId); if (!authenticator.check(req.body.code, secret)) return res.status(401).end(); return loginSuccess({ id: partial.userId }); }); ``` ### Recovery codes ```ts import { randomBytes } from 'node:crypto'; function generateRecoveryCodes(n = 10): string[] { return Array.from({ length: n }, () => randomBytes(8).toString('hex')); } const codes = generateRecoveryCodes(); const hashed = await Promise.all(codes.map(c => argon2.hash(c))); await db.recoveryCodes.insertMany(userId, hashed); // codes 한 번만 사용자에게 표시 ``` ### WebAuthn / Passkey (server side) ```ts import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server'; // Registration app.post('/webauthn/register/options', authRequired, async (req, res) => { const opts = await generateRegistrationOptions({ rpName: 'Acme', rpID: 'acme.com', userID: Buffer.from(req.user.id), userName: req.user.email, attestationType: 'none', authenticatorSelection: { residentKey: 'preferred', // passkey 만들기 userVerification: 'preferred', }, }); await redis.set(`webauthn:reg:${req.user.id}`, opts.challenge, 'EX', 300); res.json(opts); }); app.post('/webauthn/register/verify', authRequired, async (req, res) => { const expectedChallenge = await redis.get(`webauthn:reg:${req.user.id}`); const v = await verifyRegistrationResponse({ response: req.body, expectedChallenge: expectedChallenge!, expectedOrigin: 'https://acme.com', expectedRPID: 'acme.com', }); if (!v.verified) return res.status(400).end(); await db.passkeys.insert({ userId: req.user.id, credentialId: v.registrationInfo!.credentialID, publicKey: v.registrationInfo!.credentialPublicKey, counter: v.registrationInfo!.counter, }); res.json({ ok: true }); }); ``` ```ts // Authentication app.post('/webauthn/login/options', async (req, res) => { const opts = await generateAuthenticationOptions({ rpID: 'acme.com', userVerification: 'preferred', }); await redis.set(`webauthn:auth:${opts.challenge}`, '1', 'EX', 300); res.json(opts); }); app.post('/webauthn/login/verify', async (req, res) => { // ... credentialId 로 passkey lookup const passkey = await db.passkeys.findByCredentialId(req.body.id); const v = await verifyAuthenticationResponse({ response: req.body, expectedChallenge: ..., expectedOrigin, expectedRPID, authenticator: { credentialID: passkey.credentialId, credentialPublicKey: passkey.publicKey, counter: passkey.counter }, }); if (!v.verified) return res.status(401).end(); await db.passkeys.update(passkey.id, { counter: v.authenticationInfo.newCounter }); res.json(loginSuccess(passkey.userId)); }); ``` ### Client (browser) ```ts import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; const opts = await fetch('/webauthn/register/options').then(r => r.json()); const att = await startRegistration(opts); // 시스템 dialog await fetch('/webauthn/register/verify', { method: 'POST', body: JSON.stringify(att) }); ``` ### Conditional UI (auto-fill passkey) ```html ``` ```ts const att = await startAuthentication({ ...opts, useBrowserAutofill: true }); ``` ## 🤔 의사결정 기준 | 사용자 / 환경 | 추천 | |---|---| | 일반 사용자 | TOTP + recovery codes | | 보안 강 | TOTP + WebAuthn | | 미래 / UX 최고 | Passkey (Apple/Google) | | Enterprise SSO | SAML / OIDC + MFA on IdP | | SMS | 비추 (SIM swap) — 다른 방법 없을 때만 | | Hardware token | YubiKey + WebAuthn | ## ❌ 안티패턴 - **SMS only**: SIM swap. - **TOTP secret 평문 저장**: 암호화 저장 또는 KMS. - **Recovery codes 무제한 사용**: 1회용 필수. - **Passkey 만 — fallback X**: device 잃으면 lock-out. recovery 같이. - **Counter 검증 안 함 (WebAuthn)**: clone 방어 못 함. - **Challenge 재사용**: replay 가능. - **TOTP window 큼 (5+)**: 30초 코드 무의미. - **2FA 우회 endpoint**: 모두 enforce. ## 🤖 LLM 활용 힌트 - TOTP = otplib + recovery codes. - Passkey = @simplewebauthn (server + browser). - 두 개 모두 지원 + recovery. ## 🔗 관련 문서 - [[Security_OWASP_Top_10_Practical]] - [[Security_OAuth_Flows]] - [[Security_Auth_Authz_Patterns]]