인증되지 않은 사용자가 '로그인' 링크를 클릭하거나, 결제를 시작하거나, 항목을 즐겨찾기에 저장하거나, 기사 페이월에 도달할 때 전용 로그인 페이지로 이동하면 컨텍스트가 손실되고 사용자 이탈이 증가합니다. 즉시 UI 모드 (navigator.credentials.get()의 uiMode: 'immediate')를 사용하면 기기에 로컬 패스키 (publicKey) 또는 저장된 비밀번호 (password: true)가 있는 경우 통합 브라우저 계정 선택 도구를 즉시 표시하여 상황에 맞는 빠른 로그인을 지원합니다.
항상 브라우저 UI를 표시하고 교차 기기 QR 코드 또는 외부 보안 키로 대체되는 표준 모달 WebAuthn 프롬프트와 달리, 즉시 UI 모드는 기기에서 로컬로 사용할 수 있는 적격 사용자 인증 정보가 없는 경우 NotAllowedError로 빠르게 실패합니다. 이렇게 하면 애플리케이션에서 탭 한 번으로 재방문 사용자를 인증하는 동시에 로컬 사용자 인증 정보가 없는 사용자를 표준 로그인 페이지, 비회원 결제 또는 인라인 로그인 양식으로 원활하게 전환할 수 있습니다.
권장 UX 패턴 및 안티패턴
현재 페이지에서 벗어나기 직전에 명확한 사용자 의도가 있는 순간에 사용자 시작 이벤트 핸들러 내에서 즉시 UI 모드를 트리거합니다 (일시적인 사용자 활성화 필요). 즉시 인증에 성공하면 사용자의 의도를 즉시 완료합니다 (현재 페이지를 제자리에서 업데이트하거나 인증된 대상으로 직접 이동). NotAllowedError로 실패하면 완료 시 사용자의 의도를 완료하는 경로가 있는 로그인 또는 등록 흐름을 표시합니다.
잠재적 사용 사례:
- 헤더 로그인 링크 또는 버튼 가로채기: 전역 '로그인' 링크 (
event.preventDefault()) 클릭을 가로채 재방문 사용자를 즉시 인증하거나NotAllowedError의 전용 로그인 페이지로 계속 이동합니다. - 결제 전 동적 로그인: 재구매 쇼핑객이 '결제 진행'을 클릭할 때 탭 한 번으로 인증하여 배송 및 결제 단계로 바로 이동하거나 대체 시 표준 로그인 또는 비회원 결제 화면으로 전환합니다.
- 마이크로 액션 (즐겨찾기, 좋아요, 북마크, 저장)을 위한 인라인 로그인: 로그아웃한 사용자가 마이크로 액션 버튼을 클릭할 때 즉시 인증하고 작업을 완료하거나 (예:
aria-pressed="true"전환) 반환 URL 매개변수를 사용하여 대체 로그인 모달 또는 로그인 페이지를 엽니다. - 인아티클 소프트 페이월: 독자가 페이월 배너에서 '계정으로 계속 읽기'를 클릭하면 그 자리에서 독자를 인증하거나, 대체 시 인라인 로그인 또는 등록 양식을 펼칩니다.
피해야 할 안티패턴:
- 전용 로그인 페이지에서 즉시 UI 모드를 사용하지 마세요. 최종 도착 페이지 로그인 페이지의 기본 '로그인' 또는 '패스키로 로그인' 버튼에
uiMode: 'immediate'를 연결하지 마세요. 사용자는 현재 기기에 로컬 사용자 인증 정보가 없거나 교차 기기 패스키 (휴대전화로 QR 코드 스캔) 또는 외부 하드웨어 보안 키를 사용하려는 경우에 전용 로그인 페이지에 도달합니다.uiMode: 'immediate'는 교차 기기 QR 코드 및 보안 키 옵션을 억제하고 로컬 사용자 인증 정보가 없는 경우 즉시NotAllowedError를 발생시키므로 전용 로그인 페이지에서 이를 사용하면 사용자가 버튼을 클릭해도 아무 일도 일어나지 않는 막다른 상황에 갇히게 됩니다. 전용 로그인 페이지에서 표준 모달navigator.credentials.get({ publicKey })버튼과 페어링된 양식 입력에 조건부 UI (autocomplete="username webauthn"가 있는mediation: 'conditional')를 사용합니다(패스키 인증 참고). - 단계적 재인증에 즉시 UI 모드를 사용하지 마세요. 민감한 작업 (예: 비밀번호 변경, 배송지 주소 업데이트, 저장된 결제 수단 수정, 새 패스키 추가)을 수행하기 전에 이미 로그인한 사용자를 재인증하는 데
uiMode: 'immediate'를 사용하지 마세요. 재인증은 비어 있지 않은allowCredentials목록을 통해 로그인한 사용자의 사용자 인증 정보로 프롬프트를 제한해야 합니다. 즉시 UI 모드는NotAllowedError로 거부하며,allowCredentials를 생략하면 사용자가 실수로 현재 로그인한 계정이 아닌 다른 계정을 선택할 수 있습니다. 단계적 재인증의 경우 패스키 재인증을 따르세요.
개인 정보 보호 및 보안 제약 조건
uiMode: 'immediate'가 요청되면 브라우저는 엄격한 개인 정보 보호 및 보안 규칙을 적용합니다.
- 일시적인 사용자 활성화 필요: 사용자 시작 이벤트 핸들러(예:
<button>또는<a>요소의click리스너) 내에서navigator.credentials.get({ uiMode: 'immediate' })를 호출하여 자동 사용자 인증 정보 프로빙을 방지해야 합니다(MUST). 페이지 로드 시 자동으로 호출하지 마세요 (DOMContentLoaded). API를 호출해도 일시적인 활성화가 소모되지 않습니다. - 비어 있지 않은
allowCredentials목록을 전달하지 않음:uiMode: 'immediate'가 지정된 경우publicKey.allowCredentials를 채우지 않습니다. 브라우저는NotAllowedError를 발생시켜 비어 있지 않은allowCredentials목록이 있는 즉시 UI 모드 요청을 즉시 거부하므로 신뢰 당사자는 특정 사용자 인증 정보 ID를 조사하여 이전 방문을 추론하거나 세션 전반에서 사용자를 추적할 수 없습니다. AbortSignal(signal)를 전달하지 않음:uiMode: 'immediate'가 지정된 경우navigator.credentials.get()에서signal속성을 설정하지 마세요. 브라우저에서 즉시 로그인 대화상자의 프로그래매틱 닫기를 허용하지 않기 때문입니다. 페이지에서 백그라운드 조건부 UI (mediation: 'conditional') 요청도 실행하는 경우 즉시 UI 모드를 호출하기 전에 해당 조건부 UIAbortController에서abort()를 호출하고 즉시 요청 자체에서signal를 생략합니다.- 시크릿 모드 및 시크릿 브라우징 세션: 시크릿 모드 또는 시크릿 브라우징 창에서 이루어진 요청은 항상
NotAllowedError로 거부되므로 비공개 세션은 로컬 사용자 인증 정보가 없는 세션과 구별할 수 없습니다.
서버 측 설정
옵션 생성 엔드포인트
인증되지 않은 방문자를 위해 검색 가능한 WebAuthn 요청 옵션을 생성하는 엔드포인트를 노출합니다.
- 세션 바인딩 챌린지 생성: 암호화된 무작위 챌린지 버퍼를 만들어 서버 세션에 저장하고 Base64URL로 인코딩합니다.
- 사용자 인증 환경설정 유지:
userVerification: 'preferred'를 설정하고 세션에expectedUserVerification: 'preferred'를 저장합니다. allowCredentials생략: 브라우저가NotAllowedError가 있는 비어 있지 않은 허용 목록을 거부하므로 즉시 UI 모드의 옵션에 비어 있지 않은allowCredentials목록을 포함하지 않습니다.
// Server-side options generation example (/api/login/options)
router.post('/api/login/options', (req, res) => {
const options = {
challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
rpId: 'example.com',
userVerification: 'preferred',
};
req.session.challenge = options.challenge;
req.session.expectedUserVerification = 'preferred';
return res.json(options);
});
이중 사용자 인증 정보 확인 엔드포인트
navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' })는 패스키 (PublicKeyCredential) 또는 저장된 비밀번호 (PasswordCredential)로 처리할 수 있으므로 두 사용자 인증 정보 유형 모두에 인증 핸들러를 제공합니다.
- WebAuthn 어설션 확인 (
/api/login/verify): 세션 챌린지 및 저장된 공개 키를 기준으로 어설션을 확인합니다. 데이터베이스에서 사용자 인증 정보 ID를 찾을 수 없는 경우 클라이언트가PublicKeyCredential.signalUnknownCredential()를 통해 비밀번호 관리자에게 알릴 수 있도록 HTTP 상태404를 반환합니다. - 비밀번호 확인 (
/api/login/password): 사용자 데이터베이스에서username(cred.id) 및password(cred.password)을 확인하고 인증된 세션을 설정합니다.
클라이언트 측 구현
통합 사용자 인증 정보 요청
- 패스키와 비밀번호 모두 요청:
PublicKeyCredential.parseRequestOptionsFromJSON()를 사용하여 서버 옵션을 디코딩하고password: true,publicKey,uiMode: 'immediate'를 사용하여 사용자가 시작한 클릭 핸들러 내에서navigator.credentials.get()를 호출합니다(signal전달 안 함). cred.type의 브랜치 확인:cred.type === 'public-key'인 경우cred.toJSON()를 사용하여 어설션을 직렬화하고 WebAuthn 인증 엔드포인트에 POST합니다. 서버가 HTTP404로 응답하면PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, credentialId: encoded.id })를 호출합니다.cred.type === 'password'인 경우 비밀번호 확인 엔드포인트에{ username: cred.id, password: cred.password }를 POST합니다.
- 로컬 사용자 인증 정보가 없는 경우
NotAllowedError처리:NotAllowedError(기기에 로컬 패스키 또는 저장된 비밀번호가 없거나 사용자가 선택 도구를 닫을 때 즉시 발생)를 포착하고 표준 로그인 흐름으로 전환합니다 (예: 인라인 로그인 대화상자 열기 또는 반환 URL을 사용하여/signin로 이동).
let isAuthenticating = false;
async function authenticateWithImmediateUi() {
const optionsResponse = await fetch('/api/login/options', { method: 'POST' });
if (!optionsResponse.ok) {
throw new Error('Failed to fetch authentication options');
}
const optionsJSON = await optionsResponse.json();
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
// MANDATORY: Request both password and publicKey with uiMode: 'immediate' and NO signal parameter
const cred = await navigator.credentials.get({
password: true,
publicKey,
uiMode: 'immediate',
});
if (!cred) {
throw new DOMException('No credential returned', 'NotAllowedError');
}
if (cred.type === 'public-key') {
const encoded = cred.toJSON();
const verifyResponse = await fetch('/api/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(encoded),
});
if (verifyResponse.status === 404) {
await PublicKeyCredential.signalUnknownCredential({
rpId: publicKey.rpId || window.location.hostname,
credentialId: encoded.id, // Base64URL-encoded credential ID string
});
throw new Error('Passkey credential is no longer recognized by the server');
}
if (!verifyResponse.ok) {
throw new Error('Passkey verification failed');
}
return await verifyResponse.json();
}
if (cred.type === 'password') {
const verifyResponse = await fetch('/api/login/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: cred.id,
password: cred.password,
}),
});
if (!verifyResponse.ok) {
throw new Error('Password verification failed');
}
return await verifyResponse.json();
}
throw new Error(`Unsupported credential type: ${cred.type}`);
}
// Example: Intercepting an inline micro-action (Favorite button) or Header Sign-in click
const favoriteButton = document.getElementById('favorite-btn');
favoriteButton.addEventListener('click', async (event) => {
event.preventDefault();
if (isAuthenticating) return;
// If already signed in, toggle the micro-action state immediately
if (document.body.dataset.signedIn === 'true') {
const nextPressed = favoriteButton.getAttribute('aria-pressed') !== 'true';
favoriteButton.setAttribute('aria-pressed', String(nextPressed));
return;
}
isAuthenticating = true;
try {
const user = await authenticateWithImmediateUi();
// Complete the user's intended action immediately
document.body.dataset.signedIn = 'true';
favoriteButton.setAttribute('aria-pressed', 'true');
updateHeaderUserState(user);
} catch (err) {
if (err.name === 'NotAllowedError') {
// No local credentials available or user dismissed dialog:
// transition smoothly to fallback sign-in UI or redirect with return URL
showFallbackSignInUi({ returnAction: 'favorite' });
return;
}
console.error('Authentication error:', err);
} finally {
isAuthenticating = false;
}
});
대체 전략
즉시 UI 모드 대체
- 점진적 개선 환경:
uiMode: 'immediate'를 점진적 개선으로 취급합니다. 브라우저 이름이나 버전에서 지원을 추론하지 마세요. 유일하게 신뢰할 수 있는 지원 신호는PublicKeyCredential.getClientCapabilities()를 통한capabilities.immediateGet런타임 검사입니다. 필수:navigator.credentials.get({ uiMode: 'immediate' })를 호출하기 전에 항상capabilities.immediateGet를 확인하세요.uiMode: 'immediate'를 인식하지 못하는 브라우저는 알 수 없는 사전 키를 무시하고 차단 모달 패스키 및 QR 코드 대화상자를 엽니다.capabilities.immediateGet가 거짓이거나navigator.credentials.get()가NotAllowedError로 거부되면 애플리케이션의 표준 로그인 환경 (예: 인라인 로그인 모달 열기 또는 반환 URL 매개변수를 사용하여 전용 로그인 페이지로 이동)으로 대체되므로 지원되지 않는 브라우저의 사용자에게는 기존 로그인 흐름이 변경되지 않은 상태로 표시됩니다.
async function isImmediateUiSupported() {
if (!window.PublicKeyCredential || !PublicKeyCredential.getClientCapabilities) {
return false;
}
try {
const capabilities = await PublicKeyCredential.getClientCapabilities();
return Boolean(capabilities?.immediateGet);
} catch {
return false;
}
}
async function authenticateWithImmediateUi() {
// MANDATORY: Check immediateGet support so unsupported browsers do not open a modal QR dialog
const supported = await isImmediateUiSupported();
if (!supported) {
// Throw NotAllowedError so the caller's single catch path treats "unsupported" like "no local credentials"
throw new DOMException('Immediate UI mode is not supported', 'NotAllowedError');
}
// Proceed with fetching options and calling navigator.credentials.get({ password: true, publicKey, uiMode: 'immediate' })
}
클라이언트 기능 감지 대체
항상 'webauthn-polyfills'를 설치하고 클라이언트 모듈에서 가져와 PublicKeyCredential가 지원될 때마다 PublicKeyCredential.getClientCapabilities()를 사용할 수 있도록 합니다.
import 'webauthn-polyfills';
비밀번호 사용자 인증 정보 대체
navigator.credentials.get()에서 publicKey와 함께 password: true를 전달하면 지원되는 브라우저가 브라우저의 비밀번호 관리자에 저장된 비밀번호를 통합된 즉시 계정 선택 도구에 포함할 수 있습니다.
- 대체 환경:
PasswordCredential를 지원하지 않는 브라우저는password: true옵션을 무시하거나capabilities.immediateGet기능 확인을 통해 표준 로그인 양식으로 대체됩니다. 여기서<input autocomplete="username webauthn">및<input type="password" autocomplete="current-password">는 네이티브 비밀번호 자동 완성을 제공합니다.
간편한 JSON 직렬화 대체
항상 'webauthn-polyfills'를 설치하고 클라이언트 번들로 가져와서 PublicKeyCredential.parseRequestOptionsFromJSON() 및 PublicKeyCredential.prototype.toJSON()가 PublicKeyCredential를 지원하는 브라우저에서 일관되게 작동하도록 합니다.
import 'webauthn-polyfills';
Signal API 동기화 대체
WebAuthn Signal API (webauthn-signals)는 서버가 HTTP 404로 응답할 때 사용자의 비밀번호 관리자에서 오래된 패스키를 삭제하는 점진적 개선입니다.
- 대체 환경:
if (PublicKeyCredential.signalUnknownCredential)뒤에서 통화를 제어합니다. 지원되지 않는 경우 신호 호출을 원활하게 건너뛰고 사용자에게 다른 방법으로 로그인하도록 안내하는 명확한 메시지를 표시합니다.