요즘, 모든 것이 온라인화된 시대에 살고 있다보니, 온라인을 통한 비밀번호 유출 사고가 심심치 않게 들립니다. 이렇게 유출된 비밀번호는 다른 웹사이트 로그인이 사용되기도 하고, 아이디/비밀번호 구조의 로그인은 사살 무한 대입법으로 뚫릴 수 있는 취약점이 여전히 존재합니다. 물론 이를 막고자 여러 방법을 사용하기는 하지만, 비밀번호를 서버에 저장하는 방식 자체가 좋은 방법은 아닌 것 같습니다. 물론 해시값으로 저장되겠지만, 해쉬도 완전히 안전하다고 보기 어렵죠.
그러다보니, MFA나 패스키같은 기술이 주목받고 있습니다. 둘 다 공개키 암호화 방식을 사용하는것인데요. 오늘은 패스키의 실제 동작 방식과 예제 코드까지 알아보도록 하겠습니다.
패스키란 무엇인가?
패스키(Passkey)는 비밀번호 없는 인증을 실현하는 최신 기술로, Apple, Google, Microsoft가 협력하여 만든 FIDO2/WebAuthn 기반 인증 방식입니다. 사용자 인증은 생체인식 또는 PIN으로 이루어지고, 서버에는 더 이상 비밀번호가 저장되지 않습니다.
비밀번호 탈취, 피싱, 무차별 대입 공격 등 기존 인증 방식의 취약점을 보완하는 매우 강력한 보안 수단으로 주목받고 있습니다.
https://www.google.com/intl/ko/account/about/passkeys
패스키의 동작 원리 – 단계별 설명
패스키는 공개키 기반 비대칭 암호화(PKI)를 기반으로 작동합니다. 핵심은 다음 단계와 같습니다.
🔐 1. 패스키 등록 (Registration)
절차
- 사용자가 패스키 생성을 요청합니다.
- 브라우저 또는 OS 레벨에서 공개키/개인키 쌍이 생성됩니다.
- 개인키는 안전하게 디바이스 내에 저장되며, 공개키만 서버로 전송됩니다.
- 개인키 – 디바이스, 공개키 – 서버
예제 코드 (서버: Node.js + Express + @simplewebauthn/server)
import { generateRegistrationOptions } from '@simplewebauthn/server';
const registrationOptions = generateRegistrationOptions({
rpName: 'IoT Things Maker',
rpID: 'iothingsmaker.com',
userID: 'user-1234',
userName: 'john.doe@example.com',
timeout: 60000,
attestationType: 'direct',
});
res.json(registrationOptions);
서버는 registrationOptions를 클라이언트(브라우저)에 전송하고, 브라우저는 보안 모듈을 통해 키 쌍을 생성하고 서버에 다시 공개키를 반환합니다.
🔑 2. 로그인 (Authentication)
절차
- 사용자가 로그인 시도 → 서버는 challenge 값을 생성합니다.
- 디바이스는 이 challenge에 대해 개인키로 서명합니다.
- 서버는 등록된 공개키로 서명을 검증하여 로그인 여부를 결정합니다.
예제 코드 (서버: Node.js)
import { generateAuthenticationOptions } from '@simplewebauthn/server';
const authOptions = generateAuthenticationOptions({
rpID: 'iothingsmaker.com',
userVerification: 'preferred',
});
res.json(authOptions);
사용자 디바이스는 생체인식으로 인증을 완료한 후, 개인키로 challenge에 서명하여
credential.response를 생성하고 서버에 보냅니다.
서버에서는 다음과 같이 서명을 검증합니다:
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
const verification = await verifyAuthenticationResponse({
response: clientCredential,
expectedChallenge: storedChallenge,
expectedOrigin: 'https://iothingsmaker.com',
expectedRPID: 'iothingsmaker.com',
authenticator: storedAuthenticator,
});
검증이 통과되면 로그인 성공입니다.
왜 패스키가 강력할까요?
| 보안 이점 | 설명 |
|---|---|
| 🔒 피싱 방지 | 공개키는 도메인에 귀속되므로 가짜 사이트에서 사용 불가 |
| 🧠 기억할 필요 없음 | 사용자는 비밀번호 대신 생체 인증 또는 PIN 사용 |
| 🗄️ 서버에 비밀번호 저장 안함 | 유출 위험 감소 |
| 📱 다중 디바이스 간 연동 | Apple iCloud, Google 계정으로 다른 기기와 동기화 가능 |
IoT 환경에서의 적용 사례
패스키는 IoT 디바이스와 매우 궁합이 좋습니다. 대부분의 IoT 장치는 키보드 입력이 불가능하거나 제한적인데, QR 코드 또는 BLE를 통한 패스키 인증 연동이 가능합니다.
- 스마트 도어락: 스마트폰으로 패스키 인증 후 잠금 해제
- 산업 IoT: 관리자 로그인 없이 단말기 근접 인증
- 스마트 홈: 집 안에서 자동 로그인을 통한 보안 인증
결론
패스키는 단순한 비밀번호 대체를 넘어, 개인정보 보호와 사용자 경험의 혁신을 동시에 이끄는 기술입니다. 안전하고 직관적인 인증 수단입니다.
부록
클라이언트 예제 코드를 추가했습니다.
📌 1. 패스키 등록 (Registration) – 클라이언트 코드
async function startRegistration() {
// 서버에서 등록 옵션 받아오기
const resp = await fetch('/webauthn/register-options');
const options = await resp.json();
// base64 → ArrayBuffer 변환
options.challenge = base64urlToBuffer(options.challenge);
options.user.id = base64urlToBuffer(options.user.id);
// 브라우저가 패스키 생성 (publicKeyCredential 생성)
const credential = await navigator.credentials.create({
publicKey: options,
});
// 결과를 서버로 전송 (base64로 인코딩)
const credentialData = {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
attestationObject: bufferToBase64url(credential.response.attestationObject),
},
};
await fetch('/webauthn/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentialData),
});
}
대부분의 모던 브라우저는 패스키를 지원합니다. 지원되는 브라우저 버전이 궁금하시면, https://caniuse.com/?search=passkey 를 방문해보세요.
📌 2. 패스키 인증 (Authentication) – 클라이언트 코드
async function startAuthentication() {
// 서버에서 로그인 옵션 받아오기
const resp = await fetch('/webauthn/authenticate-options');
const options = await resp.json();
options.challenge = base64urlToBuffer(options.challenge);
options.allowCredentials = options.allowCredentials.map((cred) => ({
...cred,
id: base64urlToBuffer(cred.id),
}));
// 패스키로 로그인 (privateKey로 서명)
const assertion = await navigator.credentials.get({
publicKey: options,
});
// 결과 전송
const authData = {
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64url(assertion.response.userHandle)
: null,
},
};
await fetch('/webauthn/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authData),
});
}
🔧 함수: Base64url 변환
function bufferToBase64url(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
function base64urlToBuffer(base64url) {
const base64 = base64url
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(base64url.length + (4 - (base64url.length % 4)) % 4, '=');
const binary = atob(base64);
const buffer = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buffer[i] = binary.charCodeAt(i);
return buffer;
}
✅ 클라이언트/서버 흐름 요약
| 단계 | 서버 응답 | 클라이언트 동작 |
|---|---|---|
| 등록 | registrationOptions | navigator.credentials.create() |
| 인증 | authenticationOptions | navigator.credentials.get() |



