Better Auth에서 OTP를 이용한 안전한 비밀번호 찾기 흐름 구축 방법

발행: (2026년 1월 4일 오후 04:45 GMT+9)
8 min read
원문: Dev.to

Source: Dev.to

Cover image for How to Build a Secure Forgot Password Flow with OTP in Better Auth

rogasper

우리의 previous guide에서는 Better Auth와 Nodemailer를 설정해 이메일 인증 및 초대를 처리했습니다. 오늘은 모든 프로덕션 앱에 필수적인 기능인 “Forgot Password” flow를 다룹니다.

Magic Links(즉시 로그인하거나 비밀번호를 재설정하는 링크)는 인기가 있지만, 사용자를 앱 밖으로 내보내 이메일을 열고 새 탭을 여는 링크를 클릭하도록 강제합니다.

때때로 사용자를 현재 위치에 그대로 두고 싶을 때가 있습니다. 이 가이드에서는 OTP (One‑Time Password) 재설정 흐름을 구축합니다. 사용자는 이메일을 입력하고, 6자리 코드를 받아 탭을 닫지 않은 채 비밀번호를 재설정합니다.

백엔드 구성

Good news: if you followed the previous guide, your backend is already ready.

The emailOTP plugin we installed previously handles the forget-password type automatically. When we call the forgot‑password function from the client, Better Auth will trigger the sendVerificationOTP hook we defined in auth.ts with the type set to "forget-password".

Just ensure your email.ts template handles the subject line dynamically (or is generic enough) for password resets.

Source:

클라이언트‑사이드 구현

우리 UI에서는 두 가지 별도 단계를 처리해야 합니다:

  • 요청 단계 – 사용자가 이메일을 입력 → 시스템이 OTP를 전송합니다.
  • 재설정 단계 – 사용자가 OTP와 새 비밀번호를 입력 → 시스템이 자격 증명을 업데이트합니다.

1. 로직 훅

200줄짜리 UI 코드를 모두 나열하기보다, authClient를 이용한 핵심 함수를 살펴보겠습니다.

import { authClient } from "@/lib/auth-client";
import { useState } from "react";

export const usePasswordReset = () => {
  const [stage, setStage] = useState("request");
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  // Step 1: Send the OTP
  const sendResetOtp = async (email: string) => {
    setIsLoading(true);
    setError(null);

    const { error } = await authClient.forgetPassword.emailOtp({
      email,
      redirectTo: "/dashboard", // Optional: strictly for flow control
    });

    setIsLoading(false);

    if (error) {
      setError(error.message);
      return false;
    }

    setStage("reset"); // Move to next UI stage
    return true;
  };

  // Step 2: Validate OTP and set new password
  const resetPassword = async (
    email: string,
    otp: string,
    password: string
  ) => {
    setIsLoading(true);
    setError(null);

    const { error } = await authClient.emailOtp.resetPassword({
      email,
      otp,
      password,
    });

    setIsLoading(false);

    if (error) {
      setError(error.message);
      return false;
    }

    return true; // Success!
  };

  return {
    stage,
    setStage,
    isLoading,
    error,
    sendResetOtp,
    resetPassword,
  };
};

2. UI 구현

아래는 간소화된 UI 예시입니다. 프리미엄 느낌을 위해 Tailwind 그라디언트를 그대로 사용해도 좋습니다.

요청 폼 (단계 1)

if (stage === "request") {
  return (
    <>
      <h2>Forgot Password</h2>
      <p>Enter your email to receive a verification code.</p>

      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="name@example.com"
      />

      <button
        onClick={() => sendResetOtp(email)}
        disabled={isLoading || !email}
      >
        {isLoading ? "Sending Code…" : "Send OTP"}
      </button>

      {error && <p className="error">{error}</p>}
    </>
  );
}

재설정 폼 (단계 2)

if (stage === "reset") {
  return (
    <>
      <h2>Reset Password</h2>
      <p>Code sent to {email}</p>

      {/* OTP Input – limit to 6 numbers */}
      <label>OTP Code</label>
      <input
        type="text"
        value={otp}
        onChange={(e) =>
          setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))
        }
        className="tracking-widest text-center text-lg"
        placeholder="000000"
      />

      <label>New Password</label>
      <input
        type="password"
        value={newPassword}
        onChange={(e) => setNewPassword(e.target.value)}
      />

      <label>Confirm Password</label>
      <input
        type="password"
        value={confirmPassword}
        onChange={(e) => setConfirmPassword(e.target.value)}
      />

      <button
        onClick={() => {
          if (newPassword === confirmPassword) {
            resetPassword(email, otp, newPassword);
          }
        }}
        disabled={isLoading}
      >
        {isLoading ? "Resetting…" : "Set New Password"}
      </button>

      {/* Resend Logic */}
      <button
        onClick={() => sendResetOtp(email)}
        className="text-sm underline mt-4"
      >
        Resend Code
      </button>
    </>
  );
}

매직 링크 vs. OTP: 어느 것을 선택해야 할까요?

Better Auth는 두 가지 접근 방식을 모두 지원합니다. 아래는 어떤 방식이 귀하의 앱에 더 적합한지 결정하는 데 도움이 되는 간략한 비교표입니다.

기능매직 링크OTP
사용자 경험앱을 떠나 이메일을 엽니다앱 내에 머무릅니다
구현 복잡도간단 – 링크만 클릭코드 입력 UI 필요
보안한 번 클릭, 이메일 탈취에 취약코드가 빠르게 만료, 피싱이 어려움
사용 사례빠른 로그인, 낮은 마찰 흐름비밀번호 재설정, 고보안 상황

제품의 UX 목표와 보안 요구 사항에 맞는 방식을 선택하세요. 즐거운 코딩 되세요!

OTP 접근 방식 (이 가이드)

장점

  • 원활한 컨텍스트: 사용자가 애플리케이션 탭을 떠나지 않아 이탈률이 감소합니다.
  • 모바일 친화적: 딥링크(Universal Links)를 처리하기가 버그가 있거나 설정이 복잡한 모바일 앱에 이상적입니다.
  • 보안 인식: 사용자는 2FA 코드에 익숙하므로 코드를 입력하는 것이 안전하게 느껴집니다.

단점

  • 마찰: 사용자가 코드를 직접 복사‑붙여넣기(또는 입력)해야 합니다.
  • 오타: 잘못 입력된 코드나 비밀번호는 추가 오류 처리가 필요합니다.

매직 링크 접근법

장점

  • 제로 마찰: 한 번 클릭하면 사용자는 완료됩니다.
  • 단순함: 사용자가 “코드”가 무엇인지 이해할 필요가 없습니다.

단점

  • 컨텍스트 전환: 사용자가 새 브라우저 창/탭을 열도록 강제합니다.
  • 이메일 스캐너: 공격적인 기업 이메일 스캐너가 악성코드 검사를 위해 링크를 “클릭”할 수 있어, 사용자가 보기 전에 일회용 토큰이 무효화될 수 있습니다.

결론

Better AuthemailOTP 플러그인을 사용하여, 사용자를 애플리케이션 내에 머무르게 하는 비밀번호 재설정 흐름을 만들었습니다. 작은 UX 디테일이지만, 사용자를 앱 생태계 안에 머무르게 하면 전환율과 유지율이 항상 높아집니다.

자세한 내용은 우리의 블로그를 참고하세요.

Back to Blog

관련 글

더 보기 »