Better Auth로 TypeScript 인증 시스템 구축하기

Better Auth로 TypeScript 인증 시스템 구축하기

웹 애플리케이션을 만들 때 인증은 거의 빠지지 않는 기능인데요. 직접 구현하자니 보안 허점이 걱정되고 기존 라이브러리를 쓰자니 특정 프레임워크에 묶이거나 설정이 복잡한 경우가 많습니다. Better Auth는 이런 고민에서 출발한 TypeScript 네이티브 인증 라이브러리입니다. 2026년 7월에는 Vercel이 Better Auth를 개발하는 회사를 인수하면서 앞으로의 방향에도 관심이 쏠렸습니다.

프레임워크를 가리지 않고 플러그인으로 기능을 확장하고 데이터베이스 스키마까지 직접 관리해 주는 게 특징인데요. 이 글에서는 Better Auth의 설정부터 이메일/비밀번호 인증, 소셜 로그인, 플러그인 활용까지 단계별로 살펴보겠습니다. Vercel 인수 후에도 그대로 유지되는 부분과 달라질 방향도 함께 짚어볼게요.

Better Auth란

Better Auth는 TypeScript로 작성된 프레임워크 독립적인 인증과 인가 라이브러리입니다. Next.js, Nuxt, SvelteKit, Hono, Express 등 어떤 프레임워크에서든 동일한 API로 인증을 처리할 수 있습니다.

기존 인증 솔루션과 비교했을 때 눈에 띄는 점을 몇 가지 꼽자면, 우선 데이터베이스를 직접 관리한다는 겁니다. Better Auth는 사용자, 세션, 계정 테이블의 스키마를 직접 정의하고 마이그레이션해 주기 때문에 인증 데이터가 어디에 어떤 구조로 저장되는지 명확히 파악할 수 있어요. TypeScript 타입 추론이 서버에서 클라이언트까지 자연스럽게 이어지는 것도 장점입니다. 세션 객체나 사용자 정보에 자동완성이 잘 동작하거든요. 플러그인 시스템도 있어서 2단계 인증이나 조직 관리, 관리자 기능 같은 것을 필요한 만큼만 얹을 수 있습니다.

이 흐름은 2025년 9월에 더 뚜렷해졌습니다. Auth.js가 Better Auth의 일부가 되면서, NextAuth.js 시절부터 이어진 OAuth와 세션 관리 경험이 Better Auth 쪽으로 모이기 시작했거든요. 공식 발표도 기존 Auth.js 사용자는 계속 쓸 수 있지만, 새 프로젝트는 특별한 이유가 없다면 Better Auth로 시작하는 쪽을 권합니다. 그래서 Better Auth를 볼 때는 단순한 이메일/비밀번호 라이브러리라기보다, OAuth 기반 소셜 로그인까지 포함한 TypeScript 인증 스택으로 보는 편이 맞습니다.

Vercel 인수 후 무엇이 달라졌나

2026년 7월 7일 Vercel은 Better Auth를 개발하는 회사를 인수한다고 발표했습니다. 창립자 Bereket Engida와 핵심 팀도 Vercel에 합류해 Better Auth와 에이전트 신원(agent identity) 기술을 계속 개발합니다.

기존 사용자가 가장 궁금한 건 “이제 Vercel에서만 쓸 수 있는가?”일 텐데요. 결론부터 말하면 그렇지 않습니다. Better Auth는 이름을 그대로 유지하고 MIT 라이선스의 무료 오픈 소스로 남습니다. 누구나 참여할 수 있는 기여 방식과 커뮤니티 운영 체계(community governance)도 바뀌지 않습니다. 여러 프레임워크를 계속 지원하고 애플리케이션 안에서 인증 데이터를 직접 소유해 원하는 환경에 배포한다는 원칙도 그대로고요.

따라서 이번 인수 때문에 better-auth 패키지를 교체하거나 아래 설정 코드를 고칠 필요는 없습니다. Vercel 배포도 필수가 아닙니다. 인수 발표는 새 메이저 버전 출시나 관리형 인증 서비스로의 전환 공지가 아니므로 라이브러리 버전 업그레이드와 보안 패치는 기존처럼 별도로 확인하면 됩니다.

달라지는 부분은 팀의 자원과 개발 방향입니다. Better Auth의 공식 발표에 따르면 프레임워크 유지보수와 별도 플랫폼 구축 사이에서 역량을 나누는 대신, 오픈 소스 프레임워크를 확장하는 본래 목표에 다시 집중합니다.

앞으로 특히 힘을 싣는 분야는 Agent Auth입니다. 사용자를 대신해 일하는 에이전트마다 고유한 신원을 부여하고 필요한 범위에만 접근하도록 만드는 기술인데요. 에이전트 하나의 권한만 따로 철회할 수도 있습니다. Better Auth 팀은 이 작업을 Vercel Connect와 eve 같은 Vercel 제품으로 확장할 계획입니다. 당장 일반 웹 로그인 설정이 바뀌는 건 아니지만 에이전트나 MCP 서버의 인증과 권한 위임을 설계한다면 앞으로 눈여겨볼 만한 방향입니다.

설치

Better Auth의 패키지를 설치합니다.

bun add better-auth
npm install better-auth

서버 설정

프로젝트 루트에 auth.ts 파일을 만들고 betterAuth 함수로 인증 인스턴스를 생성합니다. 가장 기본적인 설정은 데이터베이스 연결 정보만 넘겨 주는 것입니다.

auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  database: {
    provider: "sqlite",
    url: "./db.sqlite",
  },
});

Better Auth는 PostgreSQL, MySQL, SQLite를 직접 지원합니다. 연결 문자열만 넘기면 내부적으로 Kysely를 사용해서 테이블을 생성하고 쿼리를 실행해 줍니다.

Drizzle이나 Prisma 같은 ORM을 이미 쓰고 있다면 해당 어댑터를 연결할 수도 있습니다.

auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db"; // Drizzle 인스턴스

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "sqlite",
  }),
});

이렇게 하면 Better Auth가 Drizzle 인스턴스를 통해 데이터베이스에 접근합니다.

API 핸들러 연결

Better Auth의 인증 인스턴스를 웹 프레임워크의 라우트에 연결해야 합니다. auth.handler는 표준 Request를 받아 Response를 반환하는 웹 표준 핸들러라서 대부분의 프레임워크와 쉽게 연동됩니다.

Express에서는 이렇게 연결합니다.

server.ts
import express from "express";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth";

const app = express();

app.all("/api/auth/*", toNodeHandler(auth));

app.listen(3000);

Hono라면 더 간단합니다.

server.ts
import { Hono } from "hono";
import { auth } from "./auth";

const app = new Hono();

app.on(["POST", "GET"], "/api/auth/**", (c) => auth.handler(c.req.raw));

export default app;

요점은 /api/auth/* 경로로 들어오는 모든 요청을 Better Auth 핸들러에 위임하는 겁니다. 로그인, 회원가입, 로그아웃, 콜백 등 인증에 필요한 엔드포인트가 이 경로 아래에 자동으로 생성됩니다.

이메일/비밀번호 인증

가장 기본적인 인증 방식인 이메일/비밀번호를 활성화해 봅시다.

auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  database: {
    provider: "sqlite",
    url: "./db.sqlite",
  },
  emailAndPassword: {
    enabled: true,
  },
});

emailAndPassword.enabledtrue로 설정하면 회원가입과 로그인 엔드포인트가 활성화됩니다. 이것만으로 사용자 등록과 로그인이 가능합니다. Better Auth가 내부적으로 처리하는 소금과 전용 해시의 원리는 Argon2id로 비밀번호를 안전하게 저장하기에서 자세히 살펴볼 수 있습니다.

이메일 인증까지 요구하고 싶다면 설정을 조금 더 추가합니다.

auth.ts
export const auth = betterAuth({
  database: {
    provider: "sqlite",
    url: "./db.sqlite",
  },
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    minPasswordLength: 10,
    sendResetPassword: async ({ user, url, token }, request) => {
      // 비밀번호 재설정 이메일 발송 로직
    },
  },
  emailVerification: {
    sendVerificationEmail: async ({ user, url, token }, request) => {
      // 이메일 인증 메일 발송 로직
    },
  },
});

requireEmailVerification을 켜면 인증 메일을 확인하기 전까지 로그인이 제한됩니다. minPasswordLength로 비밀번호 최소 길이를 지정할 수 있고 sendResetPasswordsendVerificationEmail 콜백에서 실제 이메일을 보내는 로직을 구현하면 됩니다.

클라이언트 설정

서버 쪽 설정을 마쳤으니 이제 클라이언트를 살펴볼 차례입니다. Better Auth는 React, Vue, Svelte, Solid 등 주요 프론트엔드 프레임워크에 맞는 클라이언트를 제공합니다.

React 프로젝트에서는 이렇게 설정합니다.

lib/auth-client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: "http://localhost:3000",
});

baseURL은 Better Auth 서버가 실행되는 주소입니다. 서버와 클라이언트가 같은 도메인에서 동작한다면 생략할 수 있습니다.

이렇게 만든 authClient로 회원가입과 로그인을 호출할 수 있습니다.

SignUp.tsx
import { authClient } from "./lib/auth-client";

function SignUp() {
  const handleSignUp = async () => {
    const result = await authClient.signUp.email({
      email: "user@example.com",
      password: "secure-password",
      name: "홍길동",
    });
  };

  return <button onClick={handleSignUp}>회원가입</button>;
}
SignIn.tsx
import { authClient } from "./lib/auth-client";

function SignIn() {
  const handleSignIn = async () => {
    const result = await authClient.signIn.email({
      email: "user@example.com",
      password: "secure-password",
    });
  };

  return <button onClick={handleSignIn}>로그인</button>;
}

signUp.email로 사용자를 등록하고 signIn.email로 로그인합니다. API 호출 방식이지만 fetch를 직접 다루지 않아도 되고 응답 타입도 자동으로 추론돼서 편합니다.

세션 확인

로그인한 사용자의 세션 정보를 가져오려면 useSession 훅을 사용합니다.

Profile.tsx
import { authClient } from "./lib/auth-client";

function Profile() {
  const { data: session, isPending } = authClient.useSession();

  if (isPending) return <p>로딩 중...</p>;
  if (!session) return <p>로그인이 필요합니다.</p>;

  return (
    <div>
      <p>안녕하세요, {session.user.name}님!</p>
      <p>{session.user.email}</p>
      <button onClick={() => authClient.signOut()}>로그아웃</button>
    </div>
  );
}

useSession은 현재 세션 상태를 실시간으로 반환하는 React 훅입니다. data에 사용자 정보와 세션 정보가 담겨 있고 isPending으로 로딩 상태를 처리할 수 있습니다.

세션은 기본적으로 데이터베이스에 저장되고 쿠키로 클라이언트와 서버 간 세션을 유지합니다. 세션 만료 시간이나 갱신 주기를 조절하고 싶다면 서버 설정에서 session 옵션을 사용합니다.

auth.ts
export const auth = betterAuth({
  // ...기존 설정
  session: {
    expiresIn: 604800, // 7일 (초 단위)
    updateAge: 86400, // 1일마다 세션 갱신
    cookieCache: {
      enabled: true,
      maxAge: 300, // 쿠키 캐시 5분
    },
  },
});

cookieCache를 활성화하면 매 요청마다 데이터베이스를 조회하는 대신 쿠키에 캐싱된 세션 정보를 사용해서 응답 속도를 높일 수 있습니다.

OAuth와 소셜 로그인

Better Auth의 소셜 로그인은 결국 OAuth 설정입니다. 사용자를 제공자의 인가 화면으로 보내고, 콜백으로 돌아온 인가 코드를 토큰으로 교환하고, 그 토큰으로 프로필을 읽어 세션을 만드는 흐름이죠. 직접 구현하면 콜백 라우트, 토큰 교환, 프로필 매핑, 계정 연결을 모두 챙겨야 하는데 Better Auth는 이 반복 작업을 socialProviders 설정으로 묶어줍니다.

우리가 준비할 것은 각 OAuth 제공자에서 발급받은 클라이언트 ID와 클라이언트 시크릿입니다. GitHub라면 Developer settings에서 OAuth App을 만들고, 콜백 URL을 Better Auth의 콜백 경로에 맞춰 등록합니다. 기본 경로는 /api/auth/callback/<provider>라서 로컬 개발에서는 http://localhost:3000/api/auth/callback/github, 배포 환경에서는 https://example.com/api/auth/callback/github 같은 형태가 됩니다.

auth.ts
import { betterAuth } from "better-auth";

export const auth = betterAuth({
  database: {
    provider: "sqlite",
    url: "./db.sqlite",
  },
  emailAndPassword: {
    enabled: true,
  },
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      scope: ["user:email"],
    },
  },
});

GitHub에서 발급받는 GITHUB_CLIENT_IDGITHUB_CLIENT_SECRET은 GitHub OAuth App의 자격 증명입니다. 이름이 비슷한 GitHub App과는 역할이 다른데요. OAuth App은 사용자를 대신해 로그인하고 권한을 위임받는 데 쓰이고, GitHub App은 저장소에 설치되는 봇 자동화 주체에 가깝습니다. 둘의 차이는 GitHub App과 OAuth App 비교 글에서 따로 자세히 다뤘습니다.

특히 GitHub는 사용자의 이메일이 비공개일 수 있습니다. 그래서 로그인 후 이메일을 안정적으로 얻으려면 user:email scope를 명시해 두는 편이 안전합니다. Better Auth의 GitHub 제공자는 프로필에 이메일이 없을 때 GitHub의 이메일 API까지 확인해 사용자 정보를 채워 넣습니다.

클라이언트에서는 signIn.social을 호출하면 그 제공자의 인증 페이지로 리다이렉트됩니다.

const handleGoogleLogin = async () => {
  await authClient.signIn.social({
    provider: "google",
  });
};

const handleGithubLogin = async () => {
  await authClient.signIn.social({
    provider: "github",
  });
};

구글이나 깃허브에서 인증이 완료되면 Better Auth가 콜백을 처리하고 사용자 정보를 데이터베이스에 저장한 뒤 세션을 생성합니다. 이미 이메일/비밀번호로 가입한 사용자가 나중에 GitHub로 로그인하는 경우처럼 여러 로그인 방식을 같은 사용자로 묶고 싶다면 계정 연결(account linking)을 명시적으로 켜는 편이 좋습니다.

auth.ts
export const auth = betterAuth({
  // ...기존 설정
  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: ["email-password", "github", "google"],
    },
  },
});

trustedProviders에는 같은 이메일을 신뢰하고 묶어도 되는 제공자만 넣어야 합니다. 회사 내부 도구처럼 허용된 제공자가 뚜렷한 경우에는 편하지만, 아무 제공자나 믿고 연결하면 계정 탈취 위험이 커질 수 있어요.

제공자별로 추가 스코프를 요청하거나 프로필 정보를 사용자 데이터에 매핑할 수도 있습니다.

socialProviders: {
  github: {
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    scope: ["user:email", "read:user"],
    mapProfileToUser: (profile) => ({
      name: profile.name ?? profile.login,
      image: profile.avatar_url,
    }),
  },
},

scope로 추가 권한을 요청하고 mapProfileToUser로 제공자 프로필에서 어떤 필드를 사용자 데이터로 저장할지 지정할 수 있습니다. 다만 소셜 로그인에 너무 넓은 권한을 섞는 것은 피하는 게 좋습니다. 예를 들어 GitHub의 repo scope는 사용자의 저장소 접근 범위가 매우 넓어지기 때문에, 단순 로그인에는 과합니다. 저장소 자동화가 목적이라면 OAuth App에 repo 권한을 얹기보다 GitHub App을 따로 설계하는 쪽이 보통 더 안전합니다.

플러그인 시스템

Better Auth에서 눈여겨볼 부분이 플러그인 시스템입니다. 코어는 가볍게 유지하면서 필요한 기능만 플러그인으로 추가하는 구조인데요.

2단계 인증을 예로 들어 봅시다.

auth.ts
import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";

export const auth = betterAuth({
  // ...기존 설정
  plugins: [twoFactor()],
});

서버에 플러그인을 추가하면 관련 API 엔드포인트가 자동으로 생성됩니다. 클라이언트에서도 대응하는 플러그인을 추가해야 타입 추론과 메서드가 활성화됩니다.

lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { twoFactorClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  plugins: [twoFactorClient()],
});

이렇게 하면 authClient.twoFactor라는 네임스페이스가 생기고, 시간 기반 일회용 비밀번호(Time-based One-Time Password, TOTP)를 등록하거나 인증 코드를 검증하는 메서드를 쓸 수 있습니다.

Better Auth에서 기본 제공하는 플러그인이 꽤 많습니다.

  • username: 이메일 대신 사용자명으로 로그인
  • twoFactor: 시간 기반 일회용 비밀번호(TOTP)를 이용한 2단계 인증
  • admin: 사용자 관리, 역할 기반 접근 제어
  • organization: 조직과 팀 단위 다중 테넌시
  • magicLink: 이메일 링크로 비밀번호 없이 로그인
  • passkey: 생체 인증이나 보안 키를 이용한 패스키 로그인

각 플러그인은 필요한 데이터베이스 테이블도 알아서 추가하기 때문에 별도로 마이그레이션을 작성할 필요가 없습니다.

데이터베이스 마이그레이션

Better Auth는 설정에 따라 필요한 테이블을 자동으로 관리합니다. CLI로 마이그레이션을 실행할 수 있습니다.

bunx @better-auth/cli migrate
npx @better-auth/cli migrate

이 명령을 실행하면 auth.ts에서 설정한 데이터베이스와 플러그인 정보를 읽어서 필요한 테이블(user, session, account 등)을 생성하거나 업데이트합니다.

어떤 테이블이 생성되는지 미리 확인하고 싶다면 generate 명령을 사용합니다.

bunx @better-auth/cli generate

이 명령은 실제로 데이터베이스를 건드리지 않고 SQL 마이그레이션 파일만 생성합니다. Drizzle이나 Prisma와 함께 쓸 때는 이 SQL을 ORM의 마이그레이션 흐름에 통합할 수 있습니다.

마치며

Better Auth는 TypeScript 생태계에서 인증 시스템을 구축할 때 고려할 만한 라이브러리입니다. 프레임워크에 종속되지 않으면서도 타입 안전성이 뛰어나고 플러그인 시스템 덕분에 프로젝트 규모에 맞춰 기능을 조절할 수 있습니다. Vercel 인수 후에도 이 선택 기준은 달라지지 않았습니다. 기존 프로젝트는 특정 플랫폼으로 옮기지 않고 지금 구성을 유지할 수 있고, 앞으로는 Agent Auth까지 발전 방향을 넓혀갈 것으로 보입니다.

이 글에서는 이메일/비밀번호 인증부터 소셜 로그인, 세션 관리, 플러그인 활용까지 살펴보았는데요. OAuth 2.0의 동작 원리JWT의 구조를 먼저 이해해 두면 Better Auth의 내부 동작을 파악하는 데 도움이 됩니다. 반대로 직접 구현보다 관리형 인증 서비스를 검토하고 싶다면 Auth0로 빠르게 로그인 기능 붙이기WorkOS로 엔터프라이즈 SSO 붙이기와 비교해 보세요.

더 자세한 내용은 Better Auth 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord