Auth0로 빠르게 로그인 기능 붙이기

Auth0로 빠르게 로그인 기능 붙이기

새 제품을 만들 때 로그인 기능은 거의 예외 없이 등장하는 요구사항인데요. 막상 직접 구현하려고 보면 비밀번호 해싱, 세션, 소셜 로그인 연동, 비밀번호 재설정 메일, 2단계 인증까지 할 일이 끝도 없이 늘어납니다.

Auth0는 이 귀찮은 일들을 외주 주듯 맡길 수 있는 관리형 인증 서비스예요. 설정 몇 번에 소셜 로그인과 MFA까지 붙여주기 때문에, 인증 자체가 제품의 핵심이 아니라면 시간을 크게 아낄 수 있습니다. 이 글에서는 Auth0가 어떻게 생긴 서비스인지, 실제로 SPA에 어떻게 붙이는지, 그리고 언제 쓰면 좋고 언제 피해야 하는지까지 살펴보겠습니다.

Auth0가 무엇인가요

Auth0는 사용자 로그인과 관련된 거의 모든 것을 대신 처리해 주는 서비스형 인증 플랫폼입니다. OAuth 2.0과 OpenID Connect를 기반으로 하고, 그 위에 소셜 로그인, 비밀번호 없는 로그인, MFA, 조직 관리 같은 기능을 얹어 놓았어요. 원래 독립 회사였다가 2021년 Okta에 인수되면서 지금은 Okta 제품군 중 개발자 친화적인 옵션으로 자리잡았습니다.

핵심 개념을 짚어볼게요. 테넌트(tenant) 는 서비스별로 만드는 Auth0의 독립된 작업 공간이고, 보통 개발용과 운영용을 분리해서 씁니다. 그 안에 애플리케이션(application) 을 등록하는데 이건 OAuth 용어로 클라이언트에 해당해요. 백엔드 리소스를 보호하려면 API 를 따로 등록합니다. 그래야 audience가 박힌 access token이 발급되거든요. 사용자 입장에서는 Universal Login 이라는 Auth0 호스팅 로그인 페이지로 리다이렉트되어 인증을 마치고 돌아오는 흐름입니다.

이 구조는 OAuth 2.0에서 이야기하는 Authorization Code 흐름 그대로입니다. Auth0가 인증 서버(Authorization Server) 역할을 맡아주니 남은 건 클라이언트와 리소스 서버뿐이에요.

SPA에 Auth0 붙이기

React 같은 SPA에 Auth0를 연동하는 가장 깔끔한 방법은 공식 SDK를 쓰는 것입니다. 먼저 패키지를 설치합니다.

설치
bun add @auth0/auth0-react

그다음 Auth0 대시보드에서 애플리케이션 타입을 “Single Page Application”으로 만들고, Allowed Callback URLsAllowed Logout URLs에 로컬 개발 주소(http://localhost:5173)를 등록합니다. 이 값이 맞지 않으면 로그인 후 리다이렉트가 막혀서 “Callback URL mismatch” 에러를 만나게 돼요.

앱 루트에서 Provider로 감싸줍니다.

main.tsx
import { Auth0Provider } from "@auth0/auth0-react";
import { createRoot } from "react-dom/client";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  <Auth0Provider
    domain={import.meta.env.VITE_AUTH0_DOMAIN}
    clientId={import.meta.env.VITE_AUTH0_CLIENT_ID}
    authorizationParams={{
      redirect_uri: window.location.origin,
      audience: import.meta.env.VITE_AUTH0_AUDIENCE,
    }}
  >
    <App />
  </Auth0Provider>,
);

domainclientId는 Auth0 대시보드 애플리케이션 화면에서 복사할 수 있고, audience는 보호하려는 API 식별자입니다. audience를 지정해야 JWT 형식의 access token이 나오고, 지정하지 않으면 Auth0 자체용 opaque 토큰이 내려와요. 백엔드에서 토큰을 검증하려면 반드시 audience가 필요합니다.

이제 컴포넌트에서 훅을 써서 로그인과 토큰을 다룰 수 있어요.

App.tsx
import { useAuth0 } from "@auth0/auth0-react";

export default function App() {
  const {
    isAuthenticated,
    isLoading,
    user,
    loginWithRedirect,
    logout,
    getAccessTokenSilently,
  } = useAuth0();

  if (isLoading) return <p>확인 중…</p>;

  if (!isAuthenticated) {
    return <button onClick={() => loginWithRedirect()}>로그인</button>;
  }

  const callApi = async () => {
    const token = await getAccessTokenSilently();
    const res = await fetch("/api/me", {
      headers: { Authorization: `Bearer ${token}` },
    });
    console.log(await res.json());
  };

  return (
    <div>
      <p>{user?.email}님, 안녕하세요</p>
      <button onClick={callApi}>API 호출</button>
      <button
        onClick={() =>
          logout({ logoutParams: { returnTo: window.location.origin } })
        }
      >
        로그아웃
      </button>
    </div>
  );
}

loginWithRedirect를 호출하면 브라우저가 Auth0 Universal Login으로 이동하고, 인증이 끝나면 redirect_uri로 되돌아옵니다. getAccessTokenSilently는 보이지 않는 iframe이나 refresh token으로 토큰을 조용히 갱신해 주기 때문에 API를 호출할 때마다 최신 토큰을 쓸 수 있어요.

백엔드에서 토큰 검증하기

access token을 받은 백엔드는 이 토큰이 정말 Auth0가 발급한 것이고, 호출된 API가 대상인 것이 맞으며, 아직 만료되지 않았는지 확인해야 합니다. Auth0는 JWKS 엔드포인트(https://YOUR_DOMAIN/.well-known/jwks.json)에서 공개 키를 제공하므로, 이를 이용해 서명을 검증하면 됩니다.

Node.js라면 jose 같은 라이브러리로 간단히 처리할 수 있어요.

verify.ts
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL(`https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`),
);

export async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: `https://${process.env.AUTH0_DOMAIN}/`,
    audience: process.env.AUTH0_AUDIENCE,
  });
  return payload;
}

여기서 issueraudience를 지정하는 게 중요합니다. 이 두 값이 맞지 않으면 검증을 통과시키지 않도록 해야 다른 테넌트나 다른 API의 토큰을 실수로 받아들이는 사고를 막을 수 있어요. JWT의 동작 원리를 알아두면 이 검증 과정이 훨씬 이해하기 쉽습니다.

규칙과 액션으로 동작 바꾸기

Auth0의 재미있는 점은 로그인 흐름 중간에 우리 코드를 끼워 넣을 수 있다는 것인데요. 예전에는 Rules라는 이름이었고 지금은 Actions 로 통합되었습니다. 로그인 직후 트리거에 JavaScript 함수를 등록하면 토큰에 커스텀 클레임을 넣거나, 특정 이메일 도메인만 허용하거나, 외부 서비스에 이벤트를 쏠 수 있어요.

Post-Login Action
exports.onExecutePostLogin = async (event, api) => {
  const namespace = "https://example.com";
  if (event.authorization) {
    api.idToken.setCustomClaim(`${namespace}/roles`, event.authorization.roles);
    api.accessToken.setCustomClaim(
      `${namespace}/roles`,
      event.authorization.roles,
    );
  }
};

커스텀 클레임을 넣을 때는 반드시 네임스페이스 URL을 앞에 붙여야 합니다. Auth0는 예약된 OIDC 클레임(name, email 등)에 임의 값을 넣는 걸 막기 때문에 네임스페이스 없이 roles 같은 키를 추가하면 토큰에 반영되지 않아요. 처음 쓸 때 가장 많이 만나는 함정입니다.

언제 쓰면 좋을까

Auth0가 잘 맞는 경우는 인증이 제품의 핵심 경쟁력이 아닐 때입니다. B2C SaaS에서 구글·깃허브·카카오 로그인을 빠르게 붙이고 싶거나, 초기 스타트업이 MFA와 비밀번호 재설정 같은 보일러플레이트에 시간을 쓰고 싶지 않을 때 유용해요. 무엇보다 보안 감사에 드는 인력과 시간을 아낄 수 있다는 게 큰 장점입니다.

반대로 고민해 볼 만한 지점도 있습니다. MAU가 늘어나면 비용이 꽤 빠르게 올라가요. 무료 구간을 넘어서는 순간부터 과금 곡선이 가팔라서, 사용자가 많은 B2C 서비스에서는 어느 시점에 자체 구현이나 Better Auth 같은 오픈소스로 이관하는 걸 진지하게 검토하게 됩니다. 또한 로그인 UI가 Auth0 도메인으로 리다이렉트되는 방식이 기본이라 브랜드 일관성을 위해 Universal Login을 커스터마이징하거나 자체 페이지로 옮기면 설정이 꽤 복잡해져요.

엔터프라이즈 B2B 쪽에서 SAML SSO나 SCIM 프로비저닝, 조직별 테넌트 분리가 필요하다면 Auth0의 Organizations 기능도 있지만 이 영역은 WorkOS 쪽 손이 덜 가는 편이에요. 상황에 맞게 골라 쓰는 게 좋겠죠.

마치며

Auth0는 로그인이라는 흔한 요구사항을 빠르게 해결해 주는 관리형 인증 서비스입니다. Universal Login과 SDK의 조합으로 며칠 걸릴 일을 몇 시간으로 줄여주고, Actions로 흐름을 유연하게 바꿀 수 있어요. 다만 비용 곡선과 브랜드 경험의 트레이드오프를 잊지 말아야 합니다.

인증을 직접 구현하는 경로가 궁금하다면 OAuth 2.0 쉽게 이해하기에서 프로토콜 자체를 살펴본 뒤, Better Auth처럼 타입스크립트 기반으로 코드를 다 쥐고 가는 방식과 비교해 보면 선택이 수월해집니다. 더 깊이 들어가고 싶다면 Auth0 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

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

Discord