비밀번호(지식 요소) 외에 추가 인증 수단(소유 요소, 생체 요소)을 요구하여 계정 보안을 강화하는 인증 방식입니다. OTP, SMS, 인증 앱, 하드웨어 보안키, 생체인식 등 다양한 방법이 있으며, FIDO2/Passkey가 피싱 방지 MFA의 표준으로 부상하고 있습니다. Passkey 사용 시 기존 비밀번호+MFA 조합 대비 14배 빠른 로그인(3초 vs 69초)이 가능합니다.
MFA(Multi-Factor Authentication)는 "알고 있는 것(비밀번호)", "가지고 있는 것(스마트폰, 보안키)", "본인인 것(지문, 얼굴)"이라는 세 가지 인증 요소 중 두 가지 이상을 조합하여 신원을 확인하는 보안 방식입니다. 비밀번호만으로는 피싱, 크리덴셜 스터핑, 브루트포스 공격에 취약하기 때문에, 추가 인증 계층을 도입해 보안을 강화합니다.
전통적인 MFA 방식으로는 SMS OTP, 이메일 인증 코드, TOTP(Time-based One-Time Password) 앱(Google Authenticator, Microsoft Authenticator)이 있습니다. 그러나 SMS는 SIM 스와핑 공격에 취약하고, TOTP는 피싱 사이트에 코드를 입력하면 공격자에게 탈취될 수 있습니다. 이러한 한계로 인해 "피싱 방지 MFA(Phishing-Resistant MFA)"의 필요성이 대두되었습니다.
FIDO2(WebAuthn + CTAP2) 표준은 피싱 방지 MFA의 핵심입니다. 사용자 기기에서 공개키/개인키 쌍을 생성하고, 개인키는 기기의 보안 하드웨어(TPM, Secure Enclave)에 저장됩니다. 인증 시 서버는 챌린지를 보내고, 기기는 등록된 도메인(origin)과 일치할 때만 개인키로 서명합니다. 피싱 사이트는 정당한 도메인이 아니므로 인증이 불가능합니다. YubiKey 같은 하드웨어 보안키나 Windows Hello, Apple Face ID 같은 플랫폼 인증기가 FIDO2를 지원합니다.
Passkey는 FIDO2의 사용자 친화적 진화형으로, 비밀번호를 완전히 대체하도록 설계되었습니다. Synced Passkey는 iCloud, Google Password Manager 등을 통해 여러 기기에서 동기화되어 편의성을 높입니다. Device-bound Passkey는 단일 기기에만 저장되어 보안성이 더 높습니다. PCI DSS v4.0.1은 2025년 3월 31일부터 결제 카드 환경의 모든 접근에 MFA를 의무화하며, 규제 준수 측면에서도 피싱 방지 MFA 도입이 필수가 되고 있습니다.
💻 코드 예제
// TOTP (Time-based OTP) MFA 구현 - Node.js
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
const express = require('express');
const app = express();
// 1. MFA 등록 - 시크릿 생성 및 QR 코드 제공
app.post('/api/mfa/setup', async (req, res) => {
const userId = req.user.id;
// TOTP 시크릿 생성
const secret = speakeasy.generateSecret({
name: `MyApp:${req.user.email}`,
issuer: 'MyApp',
length: 32
});
// 시크릿을 DB에 임시 저장 (검증 전까지는 활성화하지 않음)
await db.users.update(userId, {
mfa_secret_temp: secret.base32,
mfa_enabled: false
});
// QR 코드 생성 (사용자가 인증 앱으로 스캔)
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
res.json({
secret: secret.base32, // 수동 입력용
qrCode: qrCodeUrl, // 앱 스캔용
message: 'MFA 앱에서 QR 코드를 스캔한 후 코드를 입력하세요'
});
});
// 2. MFA 등록 확인 - 첫 번째 코드 검증
app.post('/api/mfa/verify-setup', async (req, res) => {
const { token } = req.body;
const userId = req.user.id;
const user = await db.users.findById(userId);
// TOTP 검증 (30초 윈도우, 전후 1개 허용)
const verified = speakeasy.totp.verify({
secret: user.mfa_secret_temp,
encoding: 'base32',
token: token,
window: 1 // 시간 드리프트 허용
});
if (!verified) {
return res.status(400).json({ error: '잘못된 인증 코드입니다' });
}
// MFA 활성화 및 복구 코드 생성
const recoveryCodes = generateRecoveryCodes(10);
await db.users.update(userId, {
mfa_secret: user.mfa_secret_temp,
mfa_secret_temp: null,
mfa_enabled: true,
mfa_recovery_codes: hashRecoveryCodes(recoveryCodes)
});
res.json({
success: true,
recoveryCodes: recoveryCodes,
message: '복구 코드를 안전한 곳에 저장하세요. 다시 표시되지 않습니다.'
});
});
// 3. 로그인 시 MFA 검증
app.post('/api/auth/verify-mfa', async (req, res) => {
const { sessionToken, mfaToken } = req.body;
// 1단계 인증(비밀번호) 완료된 세션 확인
const pendingSession = await getPendingMfaSession(sessionToken);
if (!pendingSession) {
return res.status(401).json({ error: '세션이 만료되었습니다' });
}
const user = await db.users.findById(pendingSession.userId);
// TOTP 검증
const verified = speakeasy.totp.verify({
secret: user.mfa_secret,
encoding: 'base32',
token: mfaToken,
window: 1
});
if (!verified) {
// 복구 코드인지 확인
const isRecoveryCode = await verifyRecoveryCode(user, mfaToken);
if (!isRecoveryCode) {
await logFailedMfa(user.id, req.ip);
return res.status(401).json({ error: '잘못된 인증 코드입니다' });
}
}
// 인증 성공 - 전체 세션 발급
const fullSession = await createFullSession(user);
res.json({
success: true,
accessToken: fullSession.accessToken,
refreshToken: fullSession.refreshToken
});
});
// 복구 코드 생성 함수
function generateRecoveryCodes(count) {
const codes = [];
for (let i = 0; i < count; i++) {
// 8자리 영숫자 코드 (예: ABCD-1234)
const code = require('crypto').randomBytes(4)
.toString('hex').toUpperCase();
codes.push(`${code.slice(0, 4)}-${code.slice(4)}`);
}
return codes;
}
app.listen(3000);
# TOTP MFA 구현 - Python/Flask
import pyotp
import qrcode
import io
import base64
import secrets
from flask import Flask, request, jsonify, session
from functools import wraps
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
# 1. MFA 등록 시작
@app.route('/api/mfa/setup', methods=['POST'])
def setup_mfa():
user_id = session.get('user_id')
user = db.get_user(user_id)
# TOTP 시크릿 생성 (Base32 인코딩)
secret = pyotp.random_base32()
# OTP URI 생성 (인증 앱용)
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(
name=user['email'],
issuer_name='MyApp'
)
# QR 코드 생성
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(provisioning_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format='PNG')
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
# 임시 저장 (검증 전)
db.update_user(user_id, {
'mfa_secret_temp': secret,
'mfa_pending': True
})
return jsonify({
'secret': secret,
'qr_code': f'data:image/png;base64,{qr_base64}',
'message': 'MFA 앱에서 QR 코드를 스캔하세요'
})
# 2. MFA 등록 확인
@app.route('/api/mfa/verify-setup', methods=['POST'])
def verify_mfa_setup():
user_id = session.get('user_id')
token = request.json.get('token')
user = db.get_user(user_id)
secret = user.get('mfa_secret_temp')
if not secret:
return jsonify({'error': 'MFA 설정을 먼저 시작하세요'}), 400
# TOTP 검증
totp = pyotp.TOTP(secret)
if not totp.verify(token, valid_window=1):
return jsonify({'error': '잘못된 인증 코드입니다'}), 400
# 복구 코드 생성
recovery_codes = [
f"{secrets.token_hex(2).upper()}-{secrets.token_hex(2).upper()}"
for _ in range(10)
]
# MFA 활성화
db.update_user(user_id, {
'mfa_secret': secret,
'mfa_secret_temp': None,
'mfa_enabled': True,
'mfa_pending': False,
'mfa_recovery_codes': hash_recovery_codes(recovery_codes)
})
return jsonify({
'success': True,
'recovery_codes': recovery_codes,
'message': '복구 코드를 안전하게 저장하세요'
})
# 3. MFA 검증 데코레이터
def require_mfa(f):
@wraps(f)
def decorated(*args, **kwargs):
user_id = session.get('user_id')
mfa_verified = session.get('mfa_verified')
if not user_id:
return jsonify({'error': 'Unauthorized'}), 401
user = db.get_user(user_id)
if user.get('mfa_enabled') and not mfa_verified:
return jsonify({
'error': 'MFA required',
'mfa_required': True
}), 403
return f(*args, **kwargs)
return decorated
# 4. 로그인 시 MFA 검증
@app.route('/api/auth/verify-mfa', methods=['POST'])
def verify_mfa():
token = request.json.get('token')
user_id = session.get('pending_user_id') # 1단계 인증 완료 상태
if not user_id:
return jsonify({'error': '먼저 로그인하세요'}), 401
user = db.get_user(user_id)
totp = pyotp.TOTP(user['mfa_secret'])
if totp.verify(token, valid_window=1):
# MFA 성공
session['user_id'] = user_id
session['mfa_verified'] = True
session.pop('pending_user_id', None)
return jsonify({'success': True})
# 복구 코드 확인
if verify_recovery_code(user, token):
session['user_id'] = user_id
session['mfa_verified'] = True
return jsonify({'success': True, 'used_recovery_code': True})
return jsonify({'error': '잘못된 인증 코드입니다'}), 401
# 보호된 엔드포인트 예시
@app.route('/api/sensitive-data')
@require_mfa
def get_sensitive_data():
return jsonify({'data': 'This is protected data'})
if __name__ == '__main__':
app.run(debug=True)
🗣️ 실무에서 이렇게 말하세요
💬 보안 정책 회의에서
"SMS OTP는 SIM 스와핑 공격에 취약하니 단계적으로 폐지합시다. 관리자와 개발자는 즉시 FIDO2 보안키로 전환하고, 일반 사용자는 6개월 내에 인증 앱(TOTP) 또는 Passkey로 마이그레이션하는 로드맵을 제안합니다. PCI DSS 4.0 준수 기한도 고려해야 합니다."
💬 사용자 경험 논의에서
"Passkey를 도입하면 로그인 시간이 69초에서 3초로 단축되고, 성공률도 30%에서 95%로 올라갑니다. 사용자 이탈 문제를 해결하면서 동시에 피싱 방어도 강화할 수 있어요. 초기에는 비밀번호와 Passkey를 병행하다가 점진적으로 전환하면 됩니다."
💬 인시던트 리뷰에서
"이번 피싱 공격에서 TOTP를 사용하던 계정 3개가 탈취되었습니다. 공격자가 실시간으로 OTP를 중계하는 방식이었어요. 반면 FIDO2 보안키를 사용하던 계정은 안전했습니다. WebAuthn의 origin binding 덕분에 피싱 사이트에서는 인증 자체가 불가능했기 때문입니다."
⚠️ 주의사항 & 베스트 프랙티스
❌
SMS OTP 의존
SMS는 SIM 스와핑, SS7 취약점으로 인해 가장 약한 MFA 방식입니다. NIST도 SMS OTP를 "제한적(restricted)" 인증기로 분류합니다. 신규 구현 시 SMS OTP는 피하고, 기존 시스템은 TOTP나 Passkey로 전환하세요.
❌
복구 방법 미비
MFA 기기 분실 시 계정 접근이 불가능해지는 상황을 대비해야 합니다. 복구 코드(10개 이상)를 발급하고, 백업 MFA 방법을 등록하도록 권장하세요. 복구 절차도 충분히 안전해야 합니다.
❌
MFA 피로 공격 (MFA Fatigue)
푸시 알림 기반 MFA는 공격자가 반복적으로 인증 요청을 보내 사용자가 실수로 승인하게 만들 수 있습니다. 번호 매칭(Number Matching)이나 FIDO2처럼 사용자 상호작용이 필요한 방식으로 전환하세요.
✅
MFA 베스트 프랙티스
고위험 사용자(관리자, 개발자, 재무)에게 FIDO2 보안키를 먼저 배포하고, 일반 사용자는 Passkey/TOTP로 확대하세요. MFA 등록률을 모니터링하고, 미등록 사용자에게 주기적으로 알림을 보내세요. WebAuthn 구현 시 origin binding이 제대로 작동하는지 테스트하세요.