WebAuthn
Web Authentication API
W3C와 FIDO Alliance가 표준화한 웹 인증 API. 생체인식, 보안키 등을 활용해 비밀번호 없는 강력한 인증을 웹에서 구현합니다. 피싱 공격을 원천 차단하며 패스키(Passkey)의 기반 기술입니다.
Web Authentication API
W3C와 FIDO Alliance가 표준화한 웹 인증 API. 생체인식, 보안키 등을 활용해 비밀번호 없는 강력한 인증을 웹에서 구현합니다. 피싱 공격을 원천 차단하며 패스키(Passkey)의 기반 기술입니다.
WebAuthn(Web Authentication)은 W3C와 FIDO Alliance가 공동 개발한 웹 표준 인증 API입니다. 기존 비밀번호 기반 인증의 근본적 한계(피싱, 크레덴셜 스터핑, 재사용 공격)를 해결하기 위해 설계되었습니다. 공개키 암호화를 기반으로 하며, 사용자의 디바이스에 개인키를 안전하게 저장하고 서버에는 공개키만 전송합니다. 이 구조 덕분에 서버가 해킹당해도 사용자 인증 정보는 유출되지 않습니다.
WebAuthn의 핵심 개념은 Authenticator(인증기)입니다. 플랫폼 인증기(Platform Authenticator)는 디바이스 내장 생체인식(Face ID, Touch ID, Windows Hello)을 활용하고, 로밍 인증기(Roaming Authenticator)는 YubiKey 같은 외부 보안키를 사용합니다. 인증 과정에서 챌린지-응답 방식으로 사용자를 검증하며, 각 사이트별로 고유한 키 쌍이 생성되어 크로스 사이트 추적이 불가능합니다.
FIDO2 프로토콜의 핵심 구성요소로, WebAuthn은 브라우저 API를 담당하고 CTAP(Client to Authenticator Protocol)은 디바이스와 인증기 간 통신을 담당합니다. 2023년부터 Apple, Google, Microsoft가 연합하여 '패스키(Passkey)'라는 브랜드로 대중화하고 있습니다. 패스키는 WebAuthn 크레덴셜을 클라우드 동기화하여 여러 디바이스에서 동일한 인증 정보를 사용할 수 있게 합니다.
AI/ML 시스템에서 WebAuthn은 특히 중요합니다. 모델 학습 파이프라인, 데이터 접근, 추론 API 호출 등 민감한 작업에 비밀번호 없는 강력한 인증을 제공합니다. 자동화된 공격에 취약한 비밀번호 대신 하드웨어 기반 인증을 사용하면 ML 자산의 무단 접근을 효과적으로 차단할 수 있습니다. 엔터프라이즈 MLOps 플랫폼에서 WebAuthn MFA는 필수 보안 요구사항으로 자리잡고 있습니다.
// WebAuthn 등록 (Registration) - 브라우저
async function registerWebAuthn(username) {
// 서버에서 챌린지와 사용자 정보 요청
const response = await fetch('/api/webauthn/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
});
const options = await response.json();
// Base64URL 디코딩
options.challenge = base64urlToBuffer(options.challenge);
options.user.id = base64urlToBuffer(options.user.id);
// 브라우저 WebAuthn API 호출 - 생체인식/보안키 프롬프트
const credential = await navigator.credentials.create({
publicKey: {
rp: {
name: "KAITRUST",
id: "kaitrust.ai" // 도메인에 바인딩
},
user: {
id: options.user.id,
name: username,
displayName: username
},
challenge: options.challenge,
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 } // RS256
],
authenticatorSelection: {
authenticatorAttachment: "platform", // 내장 인증기
userVerification: "required", // 생체인증 필수
residentKey: "required" // 패스키 지원
},
timeout: 60000,
attestation: "none" // 개인정보 보호
}
});
// 서버로 크레덴셜 전송
const attestationResponse = {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
attestationObject: bufferToBase64url(credential.response.attestationObject)
}
};
await fetch('/api/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attestationResponse)
});
console.log('✅ WebAuthn 등록 완료');
}
// WebAuthn 인증 (Authentication) - 브라우저
async function authenticateWebAuthn() {
const response = await fetch('/api/webauthn/authenticate/options');
const options = await response.json();
options.challenge = base64urlToBuffer(options.challenge);
const assertion = await navigator.credentials.get({
publicKey: {
challenge: options.challenge,
rpId: "kaitrust.ai",
userVerification: "required",
timeout: 60000
}
});
// 서버로 어설션 전송
const assertionResponse = {
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64url(assertion.response.userHandle)
: null
}
};
const result = await fetch('/api/webauthn/authenticate/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertionResponse)
});
return result.json();
}
// WebAuthn 서버 구현 - Node.js (SimpleWebAuthn 라이브러리)
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
const rpName = 'KAITRUST';
const rpID = 'kaitrust.ai';
const origin = 'https://kaitrust.ai';
// 등록 옵션 생성
app.post('/api/webauthn/register/options', async (req, res) => {
const { username } = req.body;
const user = await db.findOrCreateUser(username);
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: user.id,
userName: username,
userDisplayName: username,
attestationType: 'none',
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required',
residentKey: 'required'
},
supportedAlgorithmIDs: [-7, -257] // ES256, RS256
});
// 챌린지 임시 저장 (세션 또는 Redis)
await redis.set(`webauthn:challenge:${user.id}`, options.challenge, 'EX', 300);
res.json(options);
});
// 등록 검증
app.post('/api/webauthn/register/verify', async (req, res) => {
const { body } = req;
const user = req.session.user;
const expectedChallenge = await redis.get(`webauthn:challenge:${user.id}`);
try {
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID
});
if (verification.verified) {
// 공개키 저장
await db.saveCredential({
credentialID: verification.registrationInfo.credentialID,
credentialPublicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
userId: user.id
});
res.json({ success: true });
}
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// 인증 옵션 생성
app.get('/api/webauthn/authenticate/options', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID,
userVerification: 'required',
timeout: 60000
});
await redis.set(`webauthn:auth:challenge`, options.challenge, 'EX', 300);
res.json(options);
});
// 인증 검증
app.post('/api/webauthn/authenticate/verify', async (req, res) => {
const { body } = req;
const expectedChallenge = await redis.get(`webauthn:auth:challenge`);
const credential = await db.findCredential(body.id);
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator: {
credentialID: credential.credentialID,
credentialPublicKey: credential.credentialPublicKey,
counter: credential.counter
}
});
if (verification.verified) {
// 카운터 업데이트 (리플레이 공격 방지)
await db.updateCredentialCounter(
credential.id,
verification.authenticationInfo.newCounter
);
// JWT 토큰 발급
const token = jwt.sign({ userId: credential.userId }, process.env.JWT_SECRET);
res.json({ success: true, token });
}
});
# WebAuthn 서버 검증 - Python (py_webauthn 라이브러리)
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
options_to_json
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
UserVerificationRequirement,
ResidentKeyRequirement,
PublicKeyCredentialDescriptor
)
import json
RP_ID = "kaitrust.ai"
RP_NAME = "KAITRUST"
ORIGIN = "https://kaitrust.ai"
# 등록 옵션 생성
def create_registration_options(user_id: str, username: str):
options = generate_registration_options(
rp_id=RP_ID,
rp_name=RP_NAME,
user_id=user_id.encode(),
user_name=username,
user_display_name=username,
authenticator_selection=AuthenticatorSelectionCriteria(
user_verification=UserVerificationRequirement.REQUIRED,
resident_key=ResidentKeyRequirement.REQUIRED
)
)
# 챌린지 저장 (Redis 또는 세션)
redis_client.setex(
f"webauthn:register:{user_id}",
300,
options.challenge
)
return options_to_json(options)
# 등록 응답 검증
def verify_registration(user_id: str, credential: dict):
expected_challenge = redis_client.get(f"webauthn:register:{user_id}")
verification = verify_registration_response(
credential=credential,
expected_challenge=expected_challenge,
expected_rp_id=RP_ID,
expected_origin=ORIGIN,
require_user_verification=True
)
# DB에 크레덴셜 저장
db.credentials.insert_one({
"user_id": user_id,
"credential_id": verification.credential_id,
"public_key": verification.credential_public_key,
"sign_count": verification.sign_count,
"created_at": datetime.utcnow()
})
return {"success": True, "credential_id": verification.credential_id.hex()}
# 인증 옵션 생성
def create_authentication_options(user_id: str = None):
# 사용자의 등록된 크레덴셜 조회
allow_credentials = []
if user_id:
creds = db.credentials.find({"user_id": user_id})
allow_credentials = [
PublicKeyCredentialDescriptor(id=c["credential_id"])
for c in creds
]
options = generate_authentication_options(
rp_id=RP_ID,
user_verification=UserVerificationRequirement.REQUIRED,
allow_credentials=allow_credentials
)
redis_client.setex("webauthn:auth:challenge", 300, options.challenge)
return options_to_json(options)
# 인증 응답 검증
def verify_authentication(credential: dict):
expected_challenge = redis_client.get("webauthn:auth:challenge")
stored_cred = db.credentials.find_one({
"credential_id": bytes.fromhex(credential["id"])
})
verification = verify_authentication_response(
credential=credential,
expected_challenge=expected_challenge,
expected_rp_id=RP_ID,
expected_origin=ORIGIN,
credential_public_key=stored_cred["public_key"],
credential_current_sign_count=stored_cred["sign_count"],
require_user_verification=True
)
# 서명 카운터 업데이트
db.credentials.update_one(
{"_id": stored_cred["_id"]},
{"$set": {"sign_count": verification.new_sign_count}}
)
return {
"success": True,
"user_id": stored_cred["user_id"]
}
"비밀번호 유출 사고가 계속되니 WebAuthn으로 패스키 로그인을 도입합시다. 사용자는 Face ID나 Touch ID로 한 번 터치하면 되고, 서버에는 공개키만 저장되니까 유출되어도 문제없어요. 피싱도 원천 차단됩니다."
"모델 학습 파이프라인 접근에 WebAuthn MFA를 적용해야 합니다. 자동화 스크립트로 비밀번호 탈취하는 공격이 늘고 있는데, 하드웨어 인증기 기반 WebAuthn은 물리적 접근 없이는 뚫을 수 없어요."
"WebAuthn 패스키는 비밀번호보다 UX가 훨씬 좋습니다. 사용자가 복잡한 비밀번호 기억할 필요 없이 생체인식 한 번이면 끝이에요. 가입 전환율과 로그인 성공률 모두 올라갈 겁니다."
아직 모든 브라우저/디바이스가 WebAuthn을 지원하지 않습니다. 초기에는 비밀번호 + WebAuthn MFA 옵션으로 시작하고, 점진적으로 패스키 전용으로 전환하세요. 계정 복구 방안도 필수입니다.
챌린지는 매 요청마다 새로 생성하고 짧은 TTL로 관리해야 합니다. 리플레이 공격 방지를 위해 서명 카운터를 반드시 확인하고 업데이트하세요. 카운터가 감소하면 복제 공격 의심입니다.
rpId는 도메인에 바인딩되며, 서브도메인은 상위 도메인의 rpId를 사용할 수 있지만 반대는 불가능합니다. 멀티 사이트 환경에서는 rpId 전략을 신중하게 계획하세요.
residentKey: required로 패스키를 지원하고, userVerification: required로 생체인증을 강제하세요. attestation은 개인정보 보호를 위해 none으로 설정하고, 여러 인증기 등록을 허용하여 디바이스 분실에 대비하세요.