SMS OTP 양식 인증

SMS를 통해 일회용 비밀번호 (OTP)를 전송하는 것은 전화번호 인증, 계정 복구, 단계적 인증, 결제 확인에 사용되는 일반적인 흐름입니다. 브라우저와 SMS 메시지 앱 간에 전환하여 여러 자리 코드를 기억하거나 복사하여 붙여넣는 과정은 사용자에게 불편을 초래하고, 이탈률을 높이며, 사용자를 스푸핑된 도메인에서 피싱에 취약하게 만듭니다.

시맨틱 <form> 마크업, <input autocomplete="one-time-code" inputmode="numeric">, 출처 바인딩 SMS 형식 (@domain #code), WebOTP API (OTPCredential)를 결합하면 계층화되고 점진적으로 개선된 인증 흐름이 생성됩니다.

  1. 자동 추출 및 제출: WebOTP API를 지원하는 브라우저는 SMS의 @domain #code 줄을 기준으로 출처를 확인하고, <input>를 채우고, 탭 한 번으로 인증을 트리거합니다.
  2. 출처 바인딩 키보드 자동 완성: autocomplete="one-time-code"를 지원하는 브라우저 및 운영체제(예: iOS, iPadOS, macOS의 Safari)는 @domain #code 메시지를 파싱하고 도메인이 일치하는 경우에만 키보드 자동 완성 표시줄에 코드를 추천합니다.
  3. 원활한 수동 입력: 다른 기기에서 코드를 입력하는 사용자는 <input type="number">의 사용성 위험 없이 inputmode="numeric"를 통해 숫자 가상 키패드를 받습니다.

보안 고려사항

SMS OTP는 전화번호 소유를 확인하고 편리한 단계적 인증을 제공하지만 SMS 전송 및 전화번호는 SIM 스와핑, 번호 재활용, 가로채기의 대상이 될 수 있습니다. 브라우저와 OS가 코드를 추천하거나 전송하기 전에 타겟 도메인이 일치하는지 확인할 수 있도록 항상 클라이언트 측 자동 완성 및 WebOTP API를 출처 바인딩 @domain #code SMS 형식과 페어링하세요. 기본 피싱 방지 인증의 경우 SMS 기반 코드보다 패스키를 선호합니다(패스키 방향 참고).

시맨틱 양식 마크업

method="POST" 및 명시적 <button type="submit">가 있는 시맨틱 <form> 요소로 인증 컨트롤을 래핑합니다. 숫자를 여러 상자에 분할하지 말고 항상 단일 <input> 요소를 사용하세요.

  • inputmode="numeric"와 함께 type="text" 사용: OTP 코드에 type="number"를 사용하지 마세요. 일회용 비밀번호는 셀 수 있는 수량이 아니라 앞에 0이 올 수 있는 식별자 문자열 (예: 012345)입니다. type="number"를 사용하면 증가/감소 스피너 버튼이 추가되고, 선행 0을 제거할 수 있으며, 실수로 스크롤 휠 값을 변경할 수 있습니다. 또한 전체 전화번호를 위한 기존 해결 방법인 type="tel"도 사용하지 마세요. type="text"를 inputmode="numeric"와 페어링하여 휴대기기에 숫자 전용 가상 키보드를 표시합니다.
  • autocomplete="one-time-code" 추가: <input>에 포커스가 있을 때 브라우저와 운영체제에서 자동 완성 표시줄에 수신 SMS 인증 코드를 추천하도록 지시합니다.
  • <input> 위에 안내 힌트 배치: for 및 id를 사용하여 표시되는 <label>를 연결하고, 자동 완성 팝오버와 모바일 가상 키보드가 안내를 가리지 않도록 <input> 위에 형식 안내(aria-describedby를 통해 연결됨)를 시각적으로 배치합니다.
  • 여러 <input> 요소에 숫자를 분할하지 마세요. 멀티 박스 OTP 패턴 (6개의 개별 maxlength="1" 입력)은 많은 브라우저에서 기본 autocomplete="one-time-code" 자동 완성을 중단하고 복사-붙여넣기를 방해하며 스크린 리더 탐색을 복잡하게 만듭니다. 세그먼트로 구분된 시각적 디자인이 필요한 경우 letter-spacing 및 고정 폭 글꼴을 사용하여 단일 <input>의 스타일을 지정합니다.
<form id="otp-form" action="/verify-otp" method="POST">
  <div class="form-group">
    <label for="otp-input">Verification code</label>
    <!-- Place the hint above the input so autofill popovers and mobile keyboards do not obscure it -->
    <p id="otp-format-hint" class="field-hint">
      Enter the 6-digit code sent to your phone.
    </p>
    <!-- MANDATORY: Use a single input with type="text", inputmode="numeric", and autocomplete="one-time-code" -->
    <input
      type="text"
      id="otp-input"
      name="otp"
      inputmode="numeric"
      autocomplete="one-time-code"
      pattern="\d{6}"
      maxlength="6"
      aria-describedby="otp-format-hint"
      required>
  </div>
  <button type="submit">Verify code</button>
</form>
/* Optional: Style a single input to visually space digits without splitting the DOM element */
#otp-input {
  font-family: ui-monospace, monospace;
  font-size: 1.25rem; /* Example value: must be at least 1rem (16px) to prevent mobile browser auto-zoom */
  letter-spacing: 0.35em;
  min-height: 48px;
  padding: 0.5rem 0.75rem;
  box-sizing: border-box;
}

출처 바인딩 SMS 메시지 형식

WebOTP API와 Safari의 도메인 바인딩 autocomplete="one-time-code" 자동 완성이 코드가 웹사이트에 속하는지 확인하려면 백엔드 SMS 서비스가 문자 메시지의 마지막 줄 형식을 지정해야 합니다. 이때 원본 도메인 앞에는 @를, OTP 앞에는 #를 붙여야 합니다.

Your verification code is 123456.

@example.com #123456

발신 SMS 메시지에 다음 형식 지정 규칙을 적용합니다.

  • 인간이 읽을 수 있는 텍스트를 먼저 포함: 항상 코드가 포함된 인간이 읽을 수 있는 명확한 문장으로 SMS를 시작하여 다른 기기에서 수동으로 입력하는 사용자가 쉽게 읽을 수 있도록 합니다.
  • 마지막 줄에 @<domain> #<otp> 배치: @ 문자는 마지막 줄의 첫 번째 문자여야 하며 뒤에 후행 줄이나 서명 텍스트가 없어야 합니다.
  • 호스트 도메인만 사용: @domain 토큰에 URL 스키마 (https:// 또는 http://), 포트 (:8080) 또는 경로 (/verify)를 포함하지 마세요.
  • 공백 하나로 토큰 구분: @<domain>와 #<otp> 사이에 공백을 하나만 넣고 도메인 내부 또는 #와 코드 사이에 공백을 포함하지 않습니다.

인증 흐름에 WebOTP API 통합

WebOTP API (OTPCredential)에는 보안 컨텍스트(프로덕션의 경우 https://, 로컬 개발 중에는 http://localhost)가 필요합니다. DOM을 쿼리하고 form.submit()를 호출하는 연결되지 않은 DOMContentLoaded 스크립트를 붙여넣는 대신 navigator.credentials.get()를 인증 UI의 수명 주기 및 제출 핸들러에 직접 통합하세요.

  • 요청 수명 주기를 활성 OTP 단계에 연결: 인증 입력이 렌더링되고 활성화되면 OTP를 수신 대기하기 시작합니다 (예: 사용자가 SMS 코드를 요청한 후 OTP 단계가 마운트될 때, 사용자가 '코드 재전송' 버튼을 클릭할 때마다).
  • 모든 종료 경로에서 AbortController 관리: AbortSignal (signal: ac.signal)를 navigator.credentials.get()에 전달합니다. ac.abort()에 전화해야 하는 경우:
    • 사용자가 양식을 수동으로 제출하거나 autocomplete="one-time-code"를 통해 입력된 코드를 제출합니다.
    • 새 SMS 코드가 요청되면 ('코드 다시 전송') 새 컨트롤러를 만들기 전에 이전 컨트롤러를 중단합니다.
    • 인증 구성요소, 대화상자 또는 경로가 마운트 해제됩니다.
  • 애플리케이션의 제출 핸들러 (form.requestSubmit()) 트리거: navigator.credentials.get()가 otp로 확인되면 otpInput.value = otp.code를 설정하고 form.requestSubmit()를 호출합니다(또는 인증 함수를 직접 호출). form.submit()는 submit 이벤트 리스너 (event.preventDefault()/fetch() 핸들러)를 우회하고 네이티브 제약 조건 검증을 건너뛰므로 form.submit()를 호출하지 마세요.
const otpForm = document.getElementById('otp-form');
const otpInput = document.getElementById('otp-input');

let otpAbortController = null;

function stopSmsOtpListener() {
  if (otpAbortController) {
    otpAbortController.abort();
    otpAbortController = null;
  }
}

async function startSmsOtpListener() {
  // Feature-detect WebOTP support
  if (!otpInput || !('OTPCredential' in window)) {
    return;
  }

  // Cancel any previous pending WebOTP request (e.g., when re-sending an SMS code)
  stopSmsOtpListener();
  const ac = new AbortController();
  otpAbortController = ac;

  try {
    const otp = await navigator.credentials.get({
      otp: { transport: ['sms'] },
      signal: ac.signal,
    });

    if (!otp) return;
    otpInput.value = otp.code;

    // Trigger standard form submission so submit event listeners and validation run
    if (otpForm) {
      otpForm.requestSubmit();
    }
  } catch (err) {
    // Expected when aborted by manual submission, resend, unmount, or user dismissal
    if (err && err.name === 'AbortError') {
      return;
    }
    console.error('WebOTP verification error:', err);
  }
}

otpForm.addEventListener('submit', async (event) => {
  // MANDATORY: Abort any in-flight WebOTP request when the form is submitted
  stopSmsOtpListener();

  // Application-specific submission logic (e.g., AJAX fetch or native POST)
  event.preventDefault();
  const formData = new FormData(otpForm);
  // await fetch(otpForm.action, { method: 'POST', body: formData });
});

// Start listening when the OTP verification step becomes active
startSmsOtpListener();

교차 출처 iframe 인증

교차 출처 <iframe> 내에서 SMS OTP를 수신하는 것은 기본적으로 제한되며 주로 삽입된 결제 확인 흐름 (예: shop.example가 bank.example의 발급기관 확인을 삽입하는 3D Secure 인증)에 사용됩니다. 교차 출처 <iframe> 내에서 WebOTP를 지원하려면 세 가지 조정된 구성이 필요합니다.

  1. SMS의 마지막 줄에 두 출처를 모두 바인딩: 최상위 삽입 도메인 (@shop.example)을 먼저 나열한 다음 #<otp>, 공백, 삽입된 iframe 도메인 (@bank.example)을 차례로 나열합니다.

    Your verification code is 123456.
    
    @shop.example #123456 @bank.example
    
  2. Permissions-Policy HTTP 응답 헤더 전송: 상위 문서의 HTTP 헤더에서 교차 출처 iframe 출처에 otp-credentials를 위임합니다.

    Permissions-Policy: otp-credentials=(self "https://bank.example")
    
  3. <iframe> 요소에 allow="otp-credentials" 추가: 삽입 페이지의 <iframe> 태그에 권한 정책을 명시적으로 부여하고 스크린 리더를 위한 설명이 포함된 title 속성을 포함합니다.

<iframe
  src="https://bank.example/verify"
  allow="otp-credentials"
  title="Payment verification">
</iframe>

https://bank.example에서 제공하는 <iframe> 문서 내에서 위에 표시된 것과 동일한 <form> 마크업 및 navigator.credentials.get({ otp: { transport: ['sms'] }, signal: ac.signal }) 확인 수명 주기를 실행합니다.

대체 전략

WebOTP API 대체

대상 브라우저가 WebOTP API (OTPCredential)를 지원하지 않는 경우 서드 파티 JavaScript 폴리필을 로드하지 마세요. 수신 SMS 메시지를 읽으려면 운영체제 수준 통합이 필요하기 때문입니다. 대신 <input> 마크업에 내장된 3단계 점진적 개선을 사용하세요.

  • 점진적 개선 환경: if ('OTPCredential' in window) 뒤에서 navigator.credentials.get({ otp: { transport: ['sms'] } }) 호출을 보호합니다. OTPCredential를 사용할 수 없는 경우:
    1. autocomplete="one-time-code"를 지원하는 브라우저(예: iOS 14 이상, iPadOS, iCloud SMS 릴레이가 있는 macOS의 Safari)는 @domain #code SMS를 파싱하고 <input>에 포커스가 있을 때 시스템 키보드 자동 완성 표시줄에 코드를 표시합니다.
    2. 다른 모든 브라우저는 단일 <input type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" required>의 수동 입력으로 대체되어 휴대기기에 숫자 키패드를 표시하고 <button type="submit">를 통해 제출합니다.

권한 정책 대체

브라우저가 Permissions-Policy 및 <iframe allow="otp-credentials">를 통한 otp-credentials 위임을 지원하지 않는 경우 교차 출처 <iframe> 내의 삽입된 인증 <form>는 처리되지 않은 예외를 발생시키지 않고 autocomplete="one-time-code" 키보드 추천 및 수동 숫자 입력으로 단계적으로 성능이 저하됩니다.

Inputmode 및 AbortController 지원

HTML inputmode 속성과 AbortController (AbortSignal)는 모두 최신 브라우저에서 지원되며 polyfill 또는 대체 코드가 필요하지 않습니다.