Auth.js로 NextJs 인증 구현하기

1. Auth.js란?
Auth.js는 표준 웹 API를 기반으로 하는 런타임 독립적인 라이브러리입니다. 다양한 최신 Javascript 프레임워크와 깊이 있게 통합되어, 간단하게 시작할 수 있고 확장이 용이하며 항상 프리이버시와 보안이 보장되는 인증 경험을 제공합니다!
Note: 이 글은
next-auth@5.0.0-beta
이상 버전과@auth/*
네임스페이스 아래의 모든 프레임워크를 다루고 있습니다.
- Next.js:
14.2.15
- Auth.js:
5.0.0-beta
위 버전으로 코드를 작성하였습니다.
2. 설치 및 환경설정
npm install next-auth@beta
npx auth secret
필수적으로 설정해야 하는 환경변수는 AUTH_SECRET
뿐입니다. 이는 라이브러리가 토큰과 이메일 인증 해시를 암호화하는데 사용하는 임의의 값입니다.
Auth_SECRET
은 Auth.js(NextAuth)에서 매우 중요한 보안 키로 사용됩니다.
- 세션암호화: JWT토큰을 암호화하고 서명하는데 사용
- 이메일 검증: 이메일 확인 링크의 토큰을 생성 할 때 사용
- CSRF 보호: CSRF 토큰을 생성하는데 사용
3. Credentials(자격 증명)
Auth.js에서는 자체 인증 방식이나 외부 인증을 적용할 때 Credentials Provider를 사용할 수 있습니다. 이 제공자는 사용자 이름(이메일)과 비밀번호와 같은 자격 정보를 로그인 폼으로 입력하게 해, 이를 설정된 인증 서비스로 전달하는 역할을 합니다.
Credentials Providers는 authorize
콜백을 통해 자격 정보(이메일, 패스워드)를 서버로 전달하여 인증을 처리합니다. 이 콜백 함수에서는 입력된 자격 정보가 올바른지 검증하고, 유효한 경우에는 사용자 정보를 반환합니다.
이 방식은 Google, Github과 같은 외부 제공자를 사용하는 것과 달리, 애플리케이션 자체에서 사용자 자격 정보를 직접 다루고자 할 때 유용합니다.
Note: 이번 포스팅에서는 Google, Github등 소셜로그인은 다루지 않습니다.
4. 회원가입 및 로그인 처리
4.1 회원가입
Next-Auth는 기본적으로 로그인(인증) 기능만 제공하고, 회원가입은 따로 자원하지 않습니다. 그래서 회원가입 같은 경우는 별도의 회원가입 API 엔드포인트를 사용하던지 , signIn
함수의 인자값으로 로그인, 회원가입을 구분한 후 처리해도 됩니다.
이 포스팅에서는 signIn 함수의 인자값으로 구분하여 회원가입을 하도록 하겠습니다.
// config/auth-config.ts export const authConfig: NextAuthConfig = { providers: [ Credentials({ async authorize( credentials: Partial<Record<string, unknown>>, ): Promise<User | null> { const creds = credentials as UserCredentials; try { if (creds?.isSignup && creds.name) { const { name, email, password } = creds; const { response, data } = await registerUser({ name, email, password, }); throw new AuthError( response.status, "User registered successfully but requires login.", ); } const { email, password } = creds; const data = await requestSignIn({ email, password }); return { id: data?.user.id, email: data?.user.email, name: data?.user?.name ?? "user", accessToken: data?.tokens.accessToken, }; } catch (err) { if (err instanceof AuthError) { throw new AuthError( err.statusCode || 400, err.message || "요청에 문제가 생겼습니다.", ); } throw new AuthError(400, "요청에 문제가 생겼습니다."); } }, }), ], }
import NextAuth from "next-auth"; import { authConfig } from "@/config/auth-config"; export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
사용자 이메일과 비밀번호로 설정할려면 Credentials Provider를 사용해야 합니다. 이 프로바이더는 로그인 폼에 입력된 사용자 이름과 비밀번호 등의 인증 정보를 authorize
콜백을 통해 인증 서비스로 전달하도록 설계되었습니다.
먼저 설정 파일에서 Credentials Provider를 초기화해야 합니다. 이 작업을 위해 프로바이더를 import 한 후 provider
배열에 추가해야 합니다.(구글,깃헙등 소셜로그인을 추가할려면 이 Provider 배열에 추가하면 됩니다.)
저 같은 경우는 회원가입이 정상적으로 성공 하였으면 throw new AuthError()
Error 객체를 확장한 AuthError 객체를 사용해서 메세지를 통해서 회원가입 성공여부를 체크했습니다. (깔끔한 방법은 아닌거 같은데 억지로 하다보니 이렇게 작성하였고, 만약 회원가입 후 바로 로그인을 처리할려면 바로 로그인 API를 호출해서 진행하면 됩니다.)
authorize
함수에 첫번째 매개변수 credentials에 들어오는 값은 로그인 시 사용되는 signIn 함수의 두번째 매개변수의 값이 여기로 전달됩니다.
// SignIn Page "use client"; import { useFormState } from "react-dom"; import { handleSignupAction } from "@/app/actions/signup-action"; interface Props { errors: any; message: string; success?: boolean; } const initialState: Props = { errors: {}, message: "", success: false, }; export default function Page() { const [state, formAction, isPending] = useFormState( handleSignupAction, initialState, ); const router = useRouter(); useEffect(() => { if (state && state.success) { alert("회원가입에 성공했습니다. 로그인 페이지로 이동합니다."); router.push("/signin"); } }, [state]); return ( <div className="min-h-screen flex items-center justify-center bg-gradient-to-r from-blue-100 to-purple-100"> <div className="bg-white p-8 rounded-xl shadow-2xl w-full max-w-md"> <div className="text-center mb-8"> <h1 className="text-3xl font-bold text-gray-800 mb-2">회원가입</h1> <p className="text-gray-600">새로운 계정을 만들어보세요</p> </div> <form action={formAction} className="space-y-6"> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> 이름 </label> <input name="name" type="text" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition" placeholder="홍길동" required /> {state.errors?.name && ( <p className="mt-1 text-sm text-red-500">{state.errors.name}</p> )} </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> 이메일 </label> <input name="email" type="email" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition" placeholder="example@email.com" required /> {state.errors?.email && ( <p className="mt-1 text-sm text-red-500">{state.errors.email}</p> )} </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> 비밀번호 </label> <input name="password" type="password" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition" placeholder="••••••••" required /> {state.errors?.password && ( <p className="mt-1 text-sm text-red-500"> {state.errors.password} </p> )} </div> <div> <label className="block text-sm font-medium text-gray-700 mb-1"> 비밀번호 확인 </label> <input name="confirmPassword" type="password" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition" placeholder="••••••••" required /> {state.errors?.confirmPassword && ( <p className="mt-1 text-sm text-red-500"> {state.errors.confirmPassword} </p> )} </div> <button type="submit" disabled={isPending} className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-200 transition duration-200 flex items-center justify-center" > {isPending ? ( <> <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" > <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" ></circle> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" ></path> </svg> 처리중... </> ) : ( "회원가입" )} </button> {state.message && !state.success && ( <p className="text-center text-sm text-red-500">{state.message}</p> )} {state.message && state.success && ( <p className="text-center text-sm text-green-600"> {state.message} </p> )} </form> <p className="mt-8 text-center text-sm text-gray-600"> 이미 계정이 있으신가요?{" "} <a href="/login" className="text-blue-600 hover:text-blue-700 font-medium" > 로그인하기 </a> </p> </div> </div> ); }
서버컴포넌트로 로그인 폼을 생성 한 후 서버액션으로 바로 로그인 함수를 생성해도 되지만 useFromState 훅을 이용해서 pending 상태를 처리 및 에러처리를 하였습니다.
이후 state의 success:true
값이라면 signIn
페이지로 이동합니다.
"use server" import { signIn } from "@/auth"; export async function registerUser({ name, email, password, }: SignupCredentials) { try { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/signup`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, name }), }); const data = await response.json(); if (!response.ok) { throw new AuthError(response.status, data.message, data); } return { data, response }; } catch (err) { throw new Error("알 수 없는 에러가 발생했습니다."); } } export async function handleSignupAction(prevState: any, formData: FormData) { try { const rowFormData = { username: formData.get("name")?.toString(), email: formData.get("email")?.toString(), password: formData.get("password")?.toString(), confirmPassword: formData.get("confirmPassword")?.toString(), }; const validatedFields = signupSchema.safeParse(rowFormData); if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors as any, message: "입력 정보를 확인해주세요.", }; } await signIn("credentials", { name: rowFormData.username, email: rowFormData.email, password: rowFormData.password, isSignup: true, redirect: false, }); return { success: true, message: "회원가입이 완료되었습니다.", }; } catch (err: any) { if ( err?.cause?.err?.message === "User registered successfully but requires login." && err?.cause?.err.statusCode === 201 ) { return { success: true, message: "회원가입이 완료되었습니다.", }; } return { errors: true, message: err?.cause?.err?.message || "요청에 실패했습니다.", }; } }
signIn
함수에 특정 provider를 전달하면(여기선 credentials 구글로그인이면 google 등), 해당 provider를 통해 바로 로그인 시도가 가능합니다. 커스텀 로그인 페이지를 설정하지 않은 경우에는 기본 로그인 페이지인 /[basePath]/signin
으로 이동합니다.
이후 섹션에서 config 값에 추가하는 방법을 알려드리겠습니다.
만약 로그인 후 /dashboard
와 같은 특정 페이지로 리디렉션하려면, signIn
옵션에 redirectTo
를 설정하여 원하는 URL로 사용자를 보내면 됩니다. 하지만 여기서는 useFormState를 이용해서 sccuess: true
인 경우 로그인 페이지로 이동하였습니다.
그리고 여기서는 auth-config.ts 에서 authorzie
함수에서 회원가입을 성공하면 Error throw 했기 때문에 위처럼 catch문에서 message, status 값으로 확인 후 처리를 했습니다.
4.2 로그인 하기
import { NextAuthConfig, User } from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { UserCredentials } from "@/types/auth"; import { registerUser } from "@/app/actions/signup-action"; import { AuthError } from "@/app/lib/auth-error"; import { requestSignIn } from "@/app/actions/signin-action"; import { AUTH_CONSTANTS } from "@/contants/auth"; import { handleTokenRefreshAction } from "@/app/actions/auth-action"; export const authConfig: NextAuthConfig = { providers: [ Credentials({ async authorize( credentials: Partial<Record<string, unknown>>, ): Promise<User | null> { const creds = credentials as UserCredentials; try { if (creds?.isSignup && creds.name) { const { name, email, password } = creds; const { response, data } = await registerUser({ name, email, password, }); throw new AuthError( response.status, "User registered successfully but requires login.", ); } const { email, password } = creds; const data = await requestSignIn({ email, password }); return { id: data?.user.id, email: data?.user.email, name: data?.user?.name ?? "user", accessToken: data?.tokens.accessToken, }; } catch (err) { if (err instanceof AuthError) { throw new AuthError( err.statusCode || 400, err.message || "요청에 문제가 생겼습니다.", ); } throw new AuthError(400, "요청에 문제가 생겼습니다."); } }, }), ], pages: { // 로그인 페이지 커스텀 경로 처리 signIn: "/signIn", }, session: { strategy: 'jwt', maxAge: AUTH_CONSTANTS.NEXT_SERVER_JWT_EXPIRY, }, callbacks: { async authorized({ auth }) { return !!auth; }, async jwt({ token, user }) { if (user) { return { ...token, accessToken: user.accessToken, accessTokenExpires: Date.now() + AUTH_CONSTANTS.ACCESS_TOKEN_EXPIRE_TIME, }; } if ( token.accessTokenExpires && Date.now() < token.accessTokenExpires - AUTH_CONSTANTS.REFRESH_TIME_BEFORE_EXPIRY ) { return token; } try { const result = await handleTokenRefreshAction(); return { ...token, accessToken: result.accessToken, accessTokenExpires: Date.now() + AUTH_CONSTANTS.ACCESS_TOKEN_EXPIRE_TIME, }; } catch (error) { return null; } }, async session({ session, token }) { if (session.user) { session.user.id = token.sub!; } session.accessToken = token.accessToken; session.accessTokenExpires = token.accessTokenExpires; return session; }, }, };
Note: 포스팅에 나오는 signIn 함수는 auth.ts 에서 export 한 signIn 함수입니다.(next-auth/react 🙅♀️)
auth-config의 값을 우선 위에 코드처럼 완성하였고, 하나하나씩 분해해서 보겠습니다. signIn 함수를 호출을 하면 authorize
함수가 호출이 된 후 callbacks 안에 있는 jwt
함수 실행 => session
함수가 실행이 됩니다.
authorize
=> jwt
=> session
이런 순서로 동작을 합니다.
- callbacks.jwt: 토큰의 생성과 수정을 담당하고 반환한 토큰값을 가지고 세션을 생성 후 쿠키에 저장합니다. jwt함수의 매개변수 user객체는 authorize 함수(provider)에서 반환한 객체가 포함이 되어 있습니다. user값은 최초 로그인시에만 값이 있습니다. next-auth는 기본적으로 토큰을 암호화해서 next서버의 토큰을 쿠키에 저장합니다. (jwt 만료기한은 기본값은 30일입니다. session 객체의 maxAge값으로 바꿀수 있습니다. )
- callbacks.session: 세션을 확인할때 마다 호출됩니다.
auth
를 호출해서 session값을 확인 할 수 있고 추가할 값을 추가하면 다른컴포넌트에서 사용할 수 있습니다. - callback.authorized : authorized() 함수는 미들웨어를 통해 사용자가 인증이 필요한 경우 호출됩니다. authorized 함수는 인증이 필요한 요청에서 실행되며, 특정 조건에 따라 NextResponse를 반환하여 기본 동작을 변경할 수 있습니다. params.auth: 인증된 사용자 또는 토큰 정보 (없을 경우 null). true를 반환시 요청이 승인이되고 false를 반환하면 접근이 차단 됩니다. 자세한내용은 미들웨어에서 살펴보겠습니다.
jwt 함수에서는 외부 백엔드 API access_token 만료기한을 설정을 하고 만약 만료기한이 30초이내로 남아 있다면 refresh 토큰으로 다시 accessToken을 발급받는 방식으로 했습니다. 이후 리프레쉬 토큰도 만료가 된다면 null을 리턴해서 로그인 페이지로 이동하게했습니다.(로그인 페이지로 이동은 미들웨어에서 처리)
//LoginForm export default function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); const [state, formAction, isPending] = useFormState( handleSignInAction, initialState, ); const [error, setError] = useState(""); const callbackUrl = useMemo(() => { return searchParams.get("callbackUrl") || "/dashboard"; }, [searchParams]); useEffect(() => { if (state && state.success) { const redirectionUrl = callbackUrl ?? "/dashboard"; router.replace(redirectionUrl); } }, [state]); return ( <form action={formAction}>...</form> ) }
// /app/actions/sign-in-action.ts "use server" export async function requestSignIn({ email, password, }: SignInCredentials): Promise<SignInResponse> { try { const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); if (!response.ok) { throw new AuthError(response.status, response.statusText, null); } const setCookieHeader = response.headers.get("set-cookie"); if (setCookieHeader) { const cookieOptions = parseCookieHeader(setCookieHeader); const refreshToken = cookieOptions["refresh_token"]; if (typeof refreshToken === "string") { setRefreshTokenCookie(refreshToken, { httpOnly: cookieOptions["httponly"] === true, sameSite: (cookieOptions["samesite"] as "lax" | "strict" | "none") || "lax", path: typeof cookieOptions["path"] === "string" ? cookieOptions["path"] : undefined, maxAge: typeof cookieOptions["max-age"] === "string" ? parseInt(cookieOptions["max-age"]) : undefined, }); } } const data = await response.json(); return data; } catch (err) { throw new Error("알 수 없는 에러가 발생했습니다."); } } export async function handleSignInAction(_: any, formData: FormData) { try { const rowFormData = { email: formData.get("email")?.toString(), password: formData.get("password")?.toString(), }; const validationFields = signInSchema.safeParse(rowFormData); if (!validationFields.success) { return { errors: validationFields.error.flatten().fieldErrors as any, message: "입력 정보를 확인해주세요.", }; } await signIn("credentials", { email: rowFormData.email, password: rowFormData.password, redirect: false, }); return { success: true, message: "로그인이 완료되었습니다.", }; } catch (err: any) { return { errors: true, message: err?.cause?.err?.message || "요청에 실패했습니다.", }; } }
5. 미들웨어로 인증 확인
미들웨어를 통해 페이지 접근을 제어하고 인증을 관리할 수 있고, 미들웨어는 요청이 완료되기 전에 실행되는 코드입니다. Next.js에서는 페이지가 렌더링되기 전에 미들웨어가 실행되며, 이를 통해:
- 인증 확인
- 리다이렉션
- 요청/응답 헤더 수정
- 라우트 보호
import { auth } from "@/auth"; export default auth((req) => { if (!req.auth) { const newUrl = new URL("/signIn", req.nextUrl.origin); return Response.redirect(newUrl); } }); export const config = { matcher: [ "/((?!api|_next/static|_next/image|favicon.ico|signIn|signup|posts).*)", ], };
matcher
설정- config 객체의 matcher는 미들웨어가 적용될 경로를 정의합니다. 이 코드에서는 api, _next/static, _next/image, favicon.ico, signIn, signup, posts 경로를 제외한 모든 경로에서 미들웨어가 실행됩니다.
- signIn, signup, posts와 같은 페이지는 인증 없이 접근 가능하며, 나머지 경로는 인증이 필요한 보호된 경로로 설정됩니다.
- 작동방식
- 요청이 들어오면 Next Auth는
authorized
콜백을 실행(auth-config.ts > callbacks > authorized) - 콜백의 결과가
req.auth
에 반영 authorized
콜백이false
를 반환하면req.auth
는 nulltrue
를 반환하면req.auth
에 세션정보가 포함됨.- null이면 로그인 페이지로 이동
- 요청이 들어오면 Next Auth는
6. 컴포넌트에서 Session 사용해보기
6.1 서버 컴포넌트
import { auth } from "@/auth"; export default async function Page() { const data = await auth(); return <div>Dashboard Page</div>; }
auth.ts에서 export 한 auth값을 불러와서 사용하면 됩니다.
6.2 클라이언트 컴포넌트
기존에는 next-auth/react 값을 사용하면 되지만, 사용하지 않으므로 커스텀훅으로 작성해서 사용해서 값을 불러올 수 있습니다.
// SessionContext.tsx "use client"; import { useContext, useEffect, useState, createContext } from "react"; import type { Session } from "next-auth"; import { usePathname } from "next/navigation"; import { getSession } from "@/app/actions/signin-action"; const SessionContext = createContext<Session | null>(null); export const SessionProvider = ({ children, }: { children: React.ReactNode; }) => { const pathname = usePathname(); const [session, setSession] = useState<Session | null>(null); useEffect(() => { getSession().then((res) => { setSession(res); }); }, [pathname]); return ( <SessionContext.Provider value={session}> {children} </SessionContext.Provider> ); }; export const useSession = () => { return useContext(SessionContext); }; //layout.tsx <SessionProvider> <Header /> {children} </SessionProvider>
// Profile Page "use client"; import { useSession } from "@/app/context/SessionContext"; export default function Page() { const session = useSession(); return <div>Profile Page</div>; }