이 델타 중심 가이드에서는 로그인한 사용자가 민감한 계정 변경 (예: 비밀번호 업데이트, 금융 이체)을 수행하기 전에 단계적 인증 또는 재인증을 구현하는 방법을 자세히 설명합니다.
델타 흐름 아키텍처
일반 인증과 달리 패스키 재인증은 패스키 대화상자 프롬프트를 로그인한 사용자의 사전 등록된 사용자 인증 정보로 엄격하게 제한하여 활성 세션 중에 계정 혼합이나 패스키 스푸핑을 방지합니다.
서버 측
옵션 생성 델타
활성 상태의 알려진 사용자에 대해 허용된 사용자 인증 정보 매개변수를 채우는 엔드포인트를 만듭니다.
사용자 인증 정보 제한: 로그인한 사용자의 등록된 모든 사용자 인증 정보 ID를 매핑하는 특정 PublicKeyCredentialDescriptor 레코드로 allowCredentials 옵션 배열을 채웁니다. 이 값을 비워 두거나 생략하면 검색 가능한 사용자 인증 정보로 회귀하여 세션 안전을 위반합니다.
// Node.js step-up options generation example
router.post("/api/reauth/options", enforceActiveSession, async (req, res) => {
const userPasskeys = await db.findCredentialsByUserId(req.user.id);
const options = {
challenge: serverGeneratedBase64UrlChallenge, // Random challenge stored in user session
rpId: "example.com",
// Enforce allowance strictly limited to the user's credentials list
allowCredentials: userPasskeys.map((cred) => ({
type: "public-key",
id: cred.id,
transports: cred.transports, // Speeds up resolution by indicating platform transports
})),
};
return res.json(options);
});
인증 엔드포인트 델타
클라이언트에서 반환한 어설션을 확인합니다.
계정 소유권 확인: 인증 엔드포인트는 클라이언트가 반환한 결과 인증된 사용자 인증 정보 ID가 연결된 사용자 ID가 활성 상태의 로그인한 사용자(storedCredential.passkeyUserId === req.user.id)와 정확히 일치하는 저장된 사용자 인증 정보 레코드로 확인되는지 명시적으로 확인해야 합니다(MUST). 다른 사용자의 유효한 패스키가 반환되면 인증을 즉시 거부해야 합니다(MUST).
클라이언트 측 플로우 델타
애플리케이션은 거래 UI에 따라 두 가지 재인증 인터페이스 중에서 선택합니다.
A. 버튼 흐름 (입력란 없음)
사용자가 '본인 인증' 또는 '거래 진행' 버튼을 누르면 재인증을 트리거합니다.
<button id="reauth-btn" data-testid="reauth-button">Confirm Transaction</button>
let reauthAbortController = new AbortController();
async function triggerButtonReauth() {
// Abort any background suggestion flows to avoid passkey prompt collisions
reauthAbortController.abort();
reauthAbortController = new AbortController();
const optionsResponse = await fetch("/api/reauth/options", {
method: "POST",
});
const optionsJSON = await optionsResponse.json();
const publicKey =
PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
try {
const credential = await navigator.credentials.get({
publicKey,
signal: reauthAbortController.signal,
});
if (credential) {
const encodedCredential = credential.toJSON();
const verifyResponse = await fetch("/api/reauth/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(encodedCredential),
});
if (verifyResponse.ok) {
showTransactionSuccessUI();
} else if (verifyResponse.status === 404 && PublicKeyCredential.signalUnknownCredential) {
await PublicKeyCredential.signalUnknownCredential({
rpId, // RP ID must match the one defined on the server
credentialId: encodedCredential.id
});
}
}
} catch (err) {
if (err.name === "NotAllowedError") {
console.log("User cancelled reauthentication.");
}
}
}
document
.getElementById("reauth-btn")
.addEventListener("click", triggerButtonReauth);
대체 전략
패스키 기능 감지 대체
항상 'webauthn-polyfills'를 설치하고 컨텍스트에서 가져옵니다.
PublicKeyCredential가 지원되는 한 PublicKeyCredential.getClientCapabilities도 지원됩니다.
import 'webauthn-polyfills';
간편한 JSON 직렬화 대체
항상 'webauthn-polyfills'를 설치하고 컨텍스트에서 가져옵니다.
PublicKeyCredential가 지원되는 한 PublicKeyCredential.parseRequestOptionsFromJSON 및 PublicKeyCredential.prototype.toJSON도 지원됩니다.
import 'webauthn-polyfills';