
Passkey와 WebAuthn: 비밀번호 없는 인증의 시대
비밀번호 찾기가 CS의 절반을 차지했는데, Passkey를 도입하니 비밀번호 자체가 필요 없어졌다. 근데 구현이 생각보다 복잡했다.

비밀번호 찾기가 CS의 절반을 차지했는데, Passkey를 도입하니 비밀번호 자체가 필요 없어졌다. 근데 구현이 생각보다 복잡했다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

비트코인은 블록체인의 일부입니다. 이중 지불 문제(Double Spending), 작업 증명(PoW)과 지분 증명(PoS)의 차이, 스마트 컨트랙트, 그리고 Web 3.0이 가져올 미래까지. 개발자 관점에서 본 블록체인의 모든 것.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

CS 티켓의 절반이 "비밀번호를 잊어버렸어요"였다. 처음엔 당연하다고 생각했다. 누구나 비밀번호를 잊어버리니까. 그런데 문제는 거기서 끝나지 않았다.
사용자들은 같은 비밀번호를 여러 사이트에 재사용했고, 한 곳에서 데이터 유출이 발생하면 우리 서비스도 위험해졌다. 비밀번호 복잡도 정책을 강화하면 사용자들은 더 자주 비밀번호를 잊어버렸다. 2FA를 도입하면 "로그인이 너무 번거롭다"는 불만이 쏟아졌다.
이게 바로 비밀번호의 본질적 한계였다. 충분히 복잡하면 기억하기 어렵고, 기억하기 쉬우면 보안에 취약하다. 이 딜레마를 해결할 방법이 없을까 고민하던 중, Passkey를 발견했다.
처음엔 "또 하나의 인증 방식"이라고 생각했다. 그런데 이해하고 나니 완전히 달랐다. 이건 비밀번호를 개선하는 게 아니라, 비밀번호 자체를 없애는 기술이었다.
Passkey의 핵심 아이디어는 너무 간단해서 놀라웠다. "서버에 비밀을 저장하지 않는다."
전통적인 비밀번호 시스템은 이렇게 작동한다. 사용자가 비밀번호를 입력하면, 서버에 저장된 해시값과 비교한다. 서버가 데이터베이스에 뭔가를 저장하고 있어야 한다. 이게 문제의 시작이다. 데이터베이스가 해킹당하면 모든 게 끝이다.
Passkey는 정반대로 작동한다. 사용자의 기기(스마트폰, 노트북)에만 비밀 키가 저장되고, 서버는 공개 키만 가지고 있다. 이건 마치 자물쇠와 열쇠의 관계다.
첫 번째 비유: 자물쇠와 열쇠내가 은행에 금고를 만든다고 상상해보자. 전통적인 비밀번호 방식은 은행 직원에게 내 금고 비밀번호를 알려주는 것이다. 내가 방문할 때마다 비밀번호를 말하면, 직원이 맞는지 확인하고 금고를 열어준다. 문제는 은행 직원도 내 비밀번호를 알고 있다는 것이다. 직원이 나쁜 사람이거나, 직원의 메모가 도난당하면 내 금고가 위험하다.
Passkey 방식은 내가 열쇠를 가지고 있고, 은행은 자물쇠만 가지고 있다. 내가 방문할 때마다 내 열쇠로 자물쇠를 열면 된다. 은행은 내 열쇠를 모른다. 자물쇠만 가지고는 금고를 열 수 없다. 이게 공개 키 암호화의 본질이다.
Passkey는 갑자기 나타난 게 아니다. W3C와 FIDO Alliance가 만든 WebAuthn이라는 표준 위에 구축됐다. 이 표준이 없었다면 각 회사가 자기만의 방식으로 인증 시스템을 만들었을 것이고, 호환성 지옥이 펼쳐졌을 것이다.
WebAuthn의 천재성은 브라우저 API로 설계됐다는 점이다. 개발자는 navigator.credentials.create()와 navigator.credentials.get()만 호출하면 된다. 나머지는 브라우저가 알아서 처리한다. 생체인식, 보안 키, PIN 중 무엇을 사용할지는 운영체제와 기기가 결정한다.
편지를 보낼 때 각 동네마다 다른 우편 시스템을 사용한다고 상상해보자. A동네는 빨간 봉투, B동네는 파란 봉투, C동네는 숫자로만 주소를 쓴다. 이러면 전국으로 편지를 보내는 게 거의 불가능하다.
WebAuthn은 전국 공통 우편 시스템이다. 모든 동네가 같은 형식의 주소를 사용하고, 같은 방식으로 우표를 붙인다. 덕분에 어디서든 편지를 보낼 수 있다. Apple, Google, Microsoft가 모두 WebAuthn 표준을 지원하면서, 한 번 만든 Passkey가 모든 플랫폼에서 작동하게 됐다.
Passkey 시스템은 두 단계로 작동한다. Registration(등록)과 Authentication(인증). 처음 구현할 때 이 둘을 헷갈려서 한참 삽질했다.
사용자가 처음 Passkey를 등록할 때:
코드로 보면 이렇다:
// 서버: 등록 챌린지 생성
import { generateRegistrationOptions } from '@simplewebauthn/server';
const options = await generateRegistrationOptions({
rpName: 'My App',
rpID: 'example.com',
userID: user.id,
userName: user.email,
challenge: randomBytes(32),
authenticatorSelection: {
residentKey: 'required', // Discoverable credential
userVerification: 'preferred',
},
});
// 클라이언트: 키 페어 생성
const credential = await navigator.credentials.create({
publicKey: options
});
// 서버: 공개 키 검증 및 저장
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://example.com',
});
if (verification.verified) {
// 공개 키를 데이터베이스에 저장
await savePublicKey(user.id, verification.registrationInfo);
}
사용자가 로그인할 때:
// 서버: 인증 챌린지 생성
const options = await generateAuthenticationOptions({
rpID: 'example.com',
challenge: randomBytes(32),
allowCredentials: userCredentials.map(cred => ({
id: cred.credentialID,
type: 'public-key',
})),
});
// 클라이언트: 비밀 키로 서명
const assertion = await navigator.credentials.get({
publicKey: options
});
// 서버: 서명 검증
const verification = await verifyAuthenticationResponse({
response: assertion,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://example.com',
authenticator: storedPublicKey,
});
if (verification.verified) {
// 로그인 성공
return createSession(user.id);
}
세 번째 비유: 손도장 계약서
옛날 계약서는 도장을 찍었다. 등록(Registration)은 관청에 내 도장을 등록하는 과정이다. "이 도장은 홍길동의 것입니다"라고 기록한다. 관청은 내 도장의 인영(찍힌 모양)만 보관한다. 실제 도장은 내가 가지고 있다.
인증(Authentication)은 계약서에 도장을 찍는 과정이다. 계약서(챌린지)를 받으면, 내 도장으로 찍는다. 관청은 찍힌 도장이 등록된 인영과 일치하는지 확인한다. 일치하면 "진짜 홍길동이 맞다"고 인정한다.
구현하면서 가장 헷갈렸던 부분이 Discoverable Credentials였다. 이건 Passkey가 사용자 정보를 포함하는지 여부다.
Non-Discoverable: 서버가 "이 사용자의 credential ID는 이겁니다"라고 알려줘야 한다. 사용자가 이메일을 먼저 입력하면, 서버가 해당 사용자의 credential 목록을 찾아서 클라이언트에 전달한다.
Discoverable (Resident Key): Passkey 자체에 사용자 정보가 포함돼 있다. 사용자가 아무것도 입력하지 않아도 된다. 기기의 인증기(Face ID, Touch ID)가 "이 사이트에서 사용 가능한 Passkey는 이거, 이거, 이거입니다" 하고 목록을 보여준다.
Discoverable을 사용하면 완전히 비밀번호가 필요 없다. 사이트에 접속 → Face ID 인증 → 끝. 이메일도 입력하지 않는다. 이게 진짜 Passkey의 미래다.
// Discoverable credential 등록
const options = await generateRegistrationOptions({
// ...
authenticatorSelection: {
residentKey: 'required', // Discoverable
userVerification: 'required',
},
});
// Discoverable credential로 인증 (사용자 정보 없이)
const options = await generateAuthenticationOptions({
rpID: 'example.com',
// allowCredentials를 비워두면 discoverable credentials 사용
});
이론은 완벽했다. 그런데 실제로 배포하니 문제가 터졌다. 오래된 Android 폰, Internet Explorer를 고집하는 기업 사용자, Windows 7 노트북. 이런 환경에서는 WebAuthn이 작동하지 않았다.
브라우저 호환성을 체크해보니:
생각보다 광범위하지만, 여전히 5~10%의 사용자는 지원하지 않는 환경을 사용했다. 그래서 Hybrid 전략을 택했다.
// Feature detection
async function checkPasskeySupport() {
if (!window.PublicKeyCredential) {
return false;
}
// Conditional UI 지원 체크 (자동완성 통합)
const available = await PublicKeyCredential
.isConditionalMediationAvailable();
return available;
}
// Hybrid 인증 UI
function LoginForm() {
const [passkeySupported, setPasskeySupported] = useState(false);
useEffect(() => {
checkPasskeySupport().then(setPasskeySupported);
}, []);
return (
<div>
{passkeySupported ? (
<button onClick={loginWithPasskey}>
Sign in with Passkey
</button>
) : null}
<form onSubmit={loginWithPassword}>
<input type="email" name="email" />
<input type="password" name="password" />
<button type="submit">Sign in with Password</button>
</form>
</div>
);
}
가장 까다로운 건 기존 사용자를 어떻게 전환시키느냐였다. 수천 명의 사용자가 이미 비밀번호로 로그인하고 있었다. 갑자기 Passkey만 사용하라고 하면 반발이 클 게 뻔했다.
선택적 마이그레이션 전략:
// 로그인 성공 후 Passkey 제안
async function onPasswordLoginSuccess(user: User) {
const hasPasskey = await checkUserHasPasskey(user.id);
if (!hasPasskey && await checkPasskeySupport()) {
showPasskeyRegistrationPrompt({
onRegister: async () => {
await registerPasskey(user);
grantPremiumTrial(user, 7); // 7일 무료 체험
},
onDismiss: () => {
// 30일 후 다시 표시
setReminderDate(user.id, Date.now() + 30 * 24 * 60 * 60 * 1000);
}
});
}
}
처음엔 WebAuthn spec을 직접 구현하려고 했다. CBOR 인코딩, attestation 검증, ECDSA 서명 검증... 2주를 고생한 끝에 포기했다.
SimpleWebAuthn 라이브러리를 발견한 건 행운이었다. 복잡한 암호학적 작업을 추상화해서, 몇 줄의 코드로 등록과 인증을 구현할 수 있었다.
// Before: 100+ lines of CBOR parsing and crypto validation
// After: SimpleWebAuthn으로 10줄
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
// 그냥 이렇게만 하면 된다
const options = await generateRegistrationOptions({
rpName: 'My App',
rpID: 'example.com',
userID: user.id,
userName: user.email,
attestationType: 'none', // 대부분의 경우 attestation 불필요
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
라이브러리가 처리해주는 것들:
이런 low-level 작업을 직접 하려면 한 달은 걸렸을 것이다.
Passkey의 가장 큰 장점은 보안이 아니라, 피싱 방지였다. 이건 우연히 발견한 놀라운 부수 효과다.
전통적인 비밀번호는 피싱에 취약하다. 가짜 사이트(examp1e.com)를 만들어서 사용자가 비밀번호를 입력하게 하면 끝이다. 2FA도 실시간 relay 공격으로 우회 가능하다.
Passkey는 다르다. WebAuthn spec에 origin 검증이 내장돼 있다. 사용자가 example.com에서 Passkey를 만들었다면, 그 Passkey는 example.com에서만 작동한다. examp1e.com에서는 아예 작동하지 않는다. 브라우저가 origin을 체크하기 때문에, 사용자가 속더라도 기술적으로 불가능하다.
// 서버에서 origin 검증
const verification = await verifyAuthenticationResponse({
response: assertion,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://example.com', // 정확히 일치해야 함
expectedRPID: 'example.com',
authenticator: storedPublicKey,
});
// origin이 다르면 자동으로 실패
// examp1e.com에서 요청하면 검증 실패
이게 얼마나 강력한지 실감한 순간이 있었다. 우리 서비스를 사칭한 피싱 사이트가 발견됐는데, Passkey를 사용하는 사용자는 단 한 명도 피해를 입지 않았다. 가짜 사이트에서 로그인 시도 자체가 불가능했기 때문이다.
Passkey의 진짜 혁명은 동기화였다. 예전의 보안 키(YubiKey 같은)는 물리적 디바이스였다. 잃어버리면 끝이다. Passkey는 클라우드에 암호화되어 저장되고, 내 모든 기기에 동기화된다.
놀라운 건, 크로스 플랫폼도 된다는 것이다. iPhone에서 만든 Passkey를 Android에서 사용할 수 있다. QR 코드를 스캔하면 Bluetooth로 임시 연결해서 인증한다.
// Cross-device authentication
const options = await generateAuthenticationOptions({
rpID: 'example.com',
// transports 지정으로 크로스 디바이스 지원
allowCredentials: credentials.map(cred => ({
id: cred.id,
type: 'public-key',
transports: ['usb', 'nfc', 'ble', 'hybrid'], // hybrid = QR code
})),
});
실제로 테스트해봤다. MacBook에서 로그인할 때 iPhone의 Face ID로 인증했다. QR 코드가 뜨고, iPhone 카메라로 스캔하고, Face ID 인증하면 MacBook에서 로그인됐다. 마법 같았다.
Passkey를 도입하고 3개월이 지났다. CS 티켓에서 "비밀번호 찾기"가 거의 사라졌다. Passkey를 사용하는 사용자는 로그인 실패율이 95% 낮아졌다. 보안 사고도 없었다.
핵심 깨달음:비밀번호는 이제 레거시다. 10년 후에는 "옛날에는 비밀번호라는 걸 사용했다"고 이야기할 것이다. 그때까지 기다릴 필요는 없다. 지금 바로 Passkey를 도입하면 된다. 사용자는 더 편해지고, 개발자는 더 안전해진다. 이보다 좋은 거래는 없다.