전체 인증 환경: 세션 vs JWT 선택, OAuth 2.0/OIDC 플로우, 리프레시 토큰 로테이션, Passkey(WebAuthn), 그리고 실제로 사용하는 Next.js 인증 패턴.
인증은 웹 개발에서 "동작하면 됐지"가 절대 충분하지 않은 유일한 영역이다. 날짜 선택기의 버그는 짜증나다. 인증 시스템의 버그는 데이터 유출이다.
인증을 처음부터 구현하고, 프로바이더 간 마이그레이션을 하고, 토큰 탈취 사고를 디버깅하고, "보안은 나중에 고치자"는 결정의 여파를 수습한 경험이 있다. 이 글은 처음 시작할 때 있었으면 좋았을 종합 가이드다. 이론만이 아니라 — 실제 트레이드오프, 실제 취약점, 프로덕션 압박 아래서도 견디는 패턴들이다.
세션, JWT, OAuth 2.0, Passkey, MFA, 인가의 전체 환경을 다룰 것이다. 끝까지 읽으면 각 메커니즘이 어떻게 동작하는지뿐만 아니라, 언제 사용해야 하는지와 왜 대안이 존재하는지를 이해하게 될 것이다.
이것은 처음 마주할 결정이며, 인터넷에는 이에 대한 나쁜 조언이 넘쳐난다. 실제로 중요한 것이 무엇인지 정리해보겠다.
세션은 원래의 접근법이다. 서버가 세션 레코드를 만들어 어딘가(데이터베이스, Redis, 메모리)에 저장하고, 클라이언트에게 쿠키로 불투명한 세션 ID를 준다.
// 간략화된 세션 생성
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
interface Session {
userId: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string;
userAgent: string;
}
async function createSession(userId: string, request: Request): Promise<string> {
const sessionId = randomBytes(32).toString("hex");
const session: Session = {
userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24시간
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// 데이터베이스 또는 Redis에 저장
await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
const cookieStore = await cookies();
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 86400,
path: "/",
});
return sessionId;
}장점은 확실하다:
단점도 확실하다:
JWT는 모델을 뒤집는다. 서버에 세션 상태를 저장하는 대신, 서명된 토큰에 인코딩해서 클라이언트가 보유하게 한다.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
async function createAccessToken(userId: string, role: string): Promise<string> {
return new SignJWT({ sub: userId, role })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m")
.setIssuer("https://example.com")
.setAudience("https://example.com")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://example.com",
audience: "https://example.com",
});
return payload;
} catch {
return null;
}
}장점:
단점 — 사람들이 대충 넘기는 부분:
내 경험칙:
세션을 사용할 때: 모놀리식 애플리케이션이 있을 때, 즉시 폐기가 필요할 때, 계정 보안이 중요한 소비자 대면 제품을 만들 때, 또는 인증 요구사항이 자주 변할 수 있을 때.
JWT를 사용할 때: 서비스들이 독립적으로 신원을 검증해야 하는 마이크로서비스 아키텍처일 때, API 간 통신을 구축할 때, 또는 서드파티 인증 시스템을 구현할 때.
실제로는: 대부분의 애플리케이션은 세션을 사용해야 한다. "JWT가 더 확장 가능하다"는 주장은 세션 스토리지로 해결할 수 없는 확장 문제가 실제로 있을 때만 해당된다 — 그리고 Redis는 초당 수백만 세션 조회를 처리한다. JWT가 더 현대적으로 들린다는 이유로 선택했다가, 블록리스트와 리프레시 토큰 시스템을 만들어 세션보다 더 복잡해진 프로젝트를 너무 많이 봤다.
세션 기반 인증을 선택하더라도 OAuth, OIDC, 서드파티 통합을 통해 JWT를 접하게 된다. 내부 구조를 이해하는 것은 협상의 여지가 없다.
JWT는 점으로 구분된 세 부분이 있다: header.payload.signature
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...
헤더 — 알고리즘과 토큰 유형을 선언한다:
{
"alg": "RS256",
"typ": "JWT"
}페이로드 — 클레임을 담고 있다. 표준 클레임은 짧은 이름을 사용한다:
{
"sub": "user_123", // Subject (누구에 대한 것인가)
"iss": "https://auth.example.com", // Issuer (누가 생성했는가)
"aud": "https://api.example.com", // Audience (누가 수락해야 하는가)
"iat": 1709312000, // Issued At (Unix 타임스탬프)
"exp": 1709312900, // Expiration (Unix 타임스탬프)
"role": "admin" // 커스텀 클레임
}서명 — 토큰이 변조되지 않았음을 증명한다. 인코딩된 헤더와 페이로드를 비밀 키로 서명하여 생성한다.
HS256 (HMAC-SHA256) — 대칭형. 같은 시크릿이 서명과 검증을 한다. 간단하지만 토큰을 검증해야 하는 모든 서비스가 시크릿을 가져야 한다. 하나라도 침해당하면 공격자가 토큰을 위조할 수 있다.
RS256 (RSA-SHA256) — 비대칭형. 개인 키가 서명하고, 공개 키가 검증한다. 인증 서버만 개인 키가 필요하다. 어떤 서비스든 공개 키로 검증할 수 있다. 검증 서비스가 침해당해도 공격자는 토큰을 읽을 수만 있고 위조할 수는 없다.
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
// RS256 — 여러 서비스가 토큰을 검증할 때 사용
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
async function signWithRS256(payload: Record<string, unknown>) {
const privateKey = await importPKCS8(privateKeyPem, "RS256");
return new SignJWT(payload)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
async function verifyWithRS256(token: string) {
const publicKey = await importSPKI(publicKeyPem, "RS256");
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"], // 핵심: 항상 알고리즘을 제한하라
});
return payload;
}규칙: 토큰이 서비스 경계를 넘을 때마다 RS256을 사용하라. 같은 서비스가 서명과 검증을 모두 할 때만 HS256을 사용하라.
alg: none 공격가장 유명한 JWT 취약점이며 당황스러울 만큼 단순하다. 일부 JWT 라이브러리가 이전에는:
alg 필드를 읽고alg: "none"이면 서명 검증을 완전히 건너뛰었다공격자가 유효한 JWT를 가져와 페이로드를 변경하고(예: "role": "admin" 설정), alg를 "none"으로 설정하고, 서명을 제거해서 보내면 된다. 서버가 그것을 수락했다.
// 취약 — 절대 이렇게 하지 마라
function verifyJwt(token: string) {
const [headerB64, payloadB64, signature] = token.split(".");
const header = JSON.parse(atob(headerB64));
if (header.alg === "none") {
// "서명이 필요 없다" — 치명적
return JSON.parse(atob(payloadB64));
}
// ... 서명 검증
}해결책은 간단하다: 항상 예상 알고리즘을 명시적으로 지정하라. 토큰이 스스로를 어떻게 검증해야 하는지 알려주게 하지 마라.
// 안전 — 알고리즘이 하드코딩되어 있고, 토큰에서 읽지 않는다
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"], // RS256만 수락 — 헤더를 무시
});jose 같은 현대 라이브러리는 기본적으로 이것을 올바르게 처리하지만, 심층 방어로서 algorithms 옵션을 명시적으로 전달해야 한다.
위의 것과 관련이 있다: 서버가 RS256을 수락하도록 설정되어 있으면, 공격자가:
alg: "HS256"으로 토큰을 만든다서버가 alg 헤더를 읽고 HS256 검증으로 전환하면, 공개 키(모두가 아는)가 공유 시크릿이 된다. 서명이 유효하다. 공격자가 토큰을 위조한 것이다.
다시 한번, 해결책은 동일하다: 토큰 헤더의 알고리즘을 절대 신뢰하지 마라. 항상 하드코딩하라.
JWT를 사용한다면 리프레시 토큰 전략이 필요하다. 수명이 긴 액세스 토큰을 보내는 것은 문제를 자초하는 것이다 — 탈취되면 공격자가 전체 수명 동안 접근 권한을 갖는다.
패턴:
import { randomBytes } from "crypto";
interface RefreshTokenRecord {
tokenHash: string;
userId: string;
familyId: string; // 관련 토큰을 그룹화
used: boolean;
expiresAt: Date;
createdAt: Date;
}
async function issueTokenPair(userId: string) {
const familyId = randomBytes(16).toString("hex");
const accessToken = await createAccessToken(userId);
const refreshToken = randomBytes(64).toString("hex");
const refreshTokenHash = await hashToken(refreshToken);
// 리프레시 토큰 레코드 저장
await db.refreshToken.create({
data: {
tokenHash: refreshTokenHash,
userId,
familyId,
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
return { accessToken, refreshToken };
}클라이언트가 리프레시 토큰을 사용해 새 액세스 토큰을 받을 때마다, 새로운 리프레시 토큰을 발급하고 이전 것을 무효화한다:
async function rotateTokens(incomingRefreshToken: string) {
const tokenHash = await hashToken(incomingRefreshToken);
const record = await db.refreshToken.findUnique({
where: { tokenHash },
});
if (!record) {
// 토큰이 존재하지 않음 — 탈취 가능성
return null;
}
if (record.expiresAt < new Date()) {
// 토큰 만료
await db.refreshToken.delete({ where: { tokenHash } });
return null;
}
if (record.used) {
// 이 토큰은 이미 사용되었다.
// 누군가 리플레이하고 있다 — 합법적인 사용자이거나
// 공격자이거나. 어느 쪽이든 전체 패밀리를 죽인다.
await db.refreshToken.deleteMany({
where: { familyId: record.familyId },
});
console.error(
`사용자 ${record.userId}, 패밀리 ${record.familyId}에서 리프레시 토큰 재사용 감지. 패밀리의 모든 토큰 무효화.`,
);
return null;
}
// 현재 토큰을 사용됨으로 표시 (삭제하지 마라 — 재사용 감지에 필요)
await db.refreshToken.update({
where: { tokenHash },
data: { used: true },
});
// 같은 패밀리 ID로 새 쌍 발급
const newRefreshToken = randomBytes(64).toString("hex");
const newRefreshTokenHash = await hashToken(newRefreshToken);
await db.refreshToken.create({
data: {
tokenHash: newRefreshTokenHash,
userId: record.userId,
familyId: record.familyId, // 같은 패밀리
used: false,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
},
});
const newAccessToken = await createAccessToken(record.userId);
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}이 시나리오를 생각해보라:
재사용 감지 없이는 사용자가 그냥 에러를 받는다. 공격자는 토큰 B로 계속 진행한다. 사용자는 다시 로그인하고, 계정이 침해당한 것을 절대 모른다.
재사용 감지와 패밀리 무효화가 있으면: 사용자가 이미 사용된 토큰 A를 사용하려 할 때, 시스템이 재사용을 감지하고, 패밀리의 모든 토큰(B 포함)을 무효화하고, 사용자와 공격자 모두에게 재인증을 강제한다. 사용자는 "다시 로그인하세요" 메시지를 받고 뭔가 잘못됐다는 것을 알아차릴 수 있다.
이것은 Auth0, Okta, Auth.js가 사용하는 접근법이다. 완벽하지는 않다 — 공격자가 합법적인 사용자보다 먼저 토큰을 사용하면, 합법적인 사용자가 재사용 경고를 트리거하는 쪽이 된다. 하지만 베어러 토큰으로 할 수 있는 최선이다.
OAuth 2.0과 OpenID Connect는 "Google/GitHub/Apple로 로그인"의 뒤에 있는 프로토콜이다. 라이브러리를 사용하더라도 이것들을 이해하는 것이 필수적인데, 문제가 생기면 — 그리고 생길 것이다 — 프로토콜 수준에서 무슨 일이 일어나는지 알아야 하기 때문이다.
OAuth 2.0은 인가 프로토콜이다. "이 애플리케이션이 이 사용자의 데이터에 접근할 수 있는가?"에 답한다. 결과는 특정 권한(스코프)을 부여하는 액세스 토큰이다.
**OpenID Connect (OIDC)**는 OAuth 2.0 위에 구축된 인증 계층이다. "이 사용자가 누구인가?"에 답한다. 결과는 사용자 신원 정보를 담은 ID 토큰(JWT)이다.
"Google로 로그인"할 때 OIDC를 사용하는 것이다. Google이 앱에 사용자가 누구인지 알려준다(인증). 캘린더나 드라이브에 접근하기 위해 OAuth 스코프를 요청할 수도 있다(인가).
웹 애플리케이션에서 사용해야 하는 플로우다. PKCE(Proof Key for Code Exchange)는 원래 모바일 앱을 위해 설계되었지만, 이제는 서버 사이드 애플리케이션을 포함한 모든 클라이언트에 권장된다.
import { randomBytes, createHash } from "crypto";
// 1단계: PKCE 값을 생성하고 사용자를 리다이렉트
function initiateOAuthFlow() {
// 코드 검증자: 43-128자의 랜덤 문자열
const codeVerifier = randomBytes(32).toString("base64url").slice(0, 43);
// 코드 챌린지: 검증자의 SHA256 해시, base64url 인코딩
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
// 상태: CSRF 보호를 위한 랜덤 값
const state = randomBytes(16).toString("hex");
// 리다이렉트하기 전에 둘 다 세션에 저장 (서버 측!)
// 절대 code_verifier를 쿠키나 URL 파라미터에 넣지 마라
session.codeVerifier = codeVerifier;
session.oauthState = state;
const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("scope", "openid email profile");
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
return authUrl.toString();
}// 2단계: 콜백 처리
async function handleOAuthCallback(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
// 프로바이더의 에러 확인
if (error) {
throw new Error(`OAuth 에러: ${error}`);
}
// 상태 일치 확인 (CSRF 보호)
if (state !== session.oauthState) {
throw new Error("상태 불일치 — CSRF 공격 가능성");
}
// 인가 코드를 토큰으로 교환
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: "https://example.com/api/auth/callback/google",
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
code_verifier: session.codeVerifier, // PKCE: 이 플로우를 시작한 것이 우리임을 증명
}),
});
const tokens = await tokenResponse.json();
// tokens.access_token — Google API 호출용
// tokens.id_token — 사용자 신원이 담긴 JWT (OIDC)
// tokens.refresh_token — 새 액세스 토큰 발급용
// 3단계: ID 토큰을 검증하고 사용자 정보 추출
const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
return {
googleId: idTokenPayload.sub,
email: idTokenPayload.email,
name: idTokenPayload.name,
picture: idTokenPayload.picture,
};
}모든 OAuth/OIDC 프로바이더가 이것들을 노출한다:
state 파라미터는 OAuth 콜백에 대한 CSRF 공격을 방지한다. 이것 없이는:
https://yourapp.com/callback?code=ATTACKER_CODEstate가 있으면: 앱이 랜덤 값을 생성하고, 세션에 저장하고, 인가 URL에 포함한다. 콜백이 올 때 state가 일치하는지 확인한다. 공격자는 피해자의 세션에 접근할 수 없으므로 이것을 위조할 수 없다.
Auth.js는 대부분의 Next.js 프로젝트에서 먼저 손이 가는 것이다. OAuth 댄스, 세션 관리, 데이터베이스 영속화, CSRF 보호를 처리한다. 프로덕션 준비된 설정은 이렇다.
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
// 더 나은 보안을 위해 데이터베이스 세션 사용 (JWT가 아님)
session: {
strategy: "database",
maxAge: 30 * 24 * 60 * 60, // 30일
updateAge: 24 * 60 * 60, // 24시간마다 세션 연장
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// 특정 스코프 요청
authorization: {
params: {
scope: "openid email profile",
prompt: "consent",
access_type: "offline", // 리프레시 토큰 받기
},
},
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// 이메일/비밀번호 로그인 (주의해서 사용)
Credentials({
credentials: {
email: { label: "이메일", type: "email" },
password: { label: "비밀번호", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash) {
return null;
}
const isValid = await verifyPassword(credentials.password as string, user.passwordHash);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
// 로그인 허용 여부 제어
async signIn({ user, account }) {
// 차단된 사용자의 로그인 거부
if (user.id) {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { banned: true },
});
if (dbUser?.banned) return false;
}
return true;
},
// 세션에 커스텀 필드 추가
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
// 데이터베이스에서 역할 가져오기
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { role: true },
});
session.user.role = dbUser?.role ?? "user";
}
return session;
},
},
pages: {
signIn: "/login",
error: "/auth/error",
verifyRequest: "/auth/verify",
},
});// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isAuthPage = req.nextUrl.pathname.startsWith("/login") || req.nextUrl.pathname.startsWith("/register");
const isProtectedRoute =
req.nextUrl.pathname.startsWith("/dashboard") ||
req.nextUrl.pathname.startsWith("/settings") ||
req.nextUrl.pathname.startsWith("/admin");
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
// 로그인된 사용자를 인증 페이지에서 리다이렉트
if (isLoggedIn && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
// 인증되지 않은 사용자를 로그인으로 리다이렉트
if (!isLoggedIn && isProtectedRoute) {
const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
return NextResponse.redirect(new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl));
}
// 관리자 역할 확인
if (isAdminRoute && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/admin/:path*", "/login", "/register"],
};// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<div>
<h1>환영합니다, {session.user.name}</h1>
<p>역할: {session.user.role}</p>
</div>
);
}"use client";
import { useSession } from "next-auth/react";
export function UserMenu() {
const { data: session, status } = useSession();
if (status === "loading") {
return <div>로딩 중...</div>;
}
if (status === "unauthenticated") {
return <a href="/login">로그인</a>;
}
return (
<div>
<img
src={session?.user?.image ?? "/default-avatar.png"}
alt={session?.user?.name ?? "사용자"}
/>
<span>{session?.user?.name}</span>
</div>
);
}Passkey는 수년간 가장 중요한 인증 개선이다. 피싱 내성이 있고, 리플레이 내성이 있으며, 비밀번호 관련 취약점의 전체 범주를 제거한다. 2026년에 새 프로젝트를 시작한다면 Passkey를 지원해야 한다.
Passkey는 공개 키 암호화를 사용하며, 생체인식이나 디바이스 PIN으로 보호된다:
공유 시크릿이 네트워크를 통과하는 일은 없다. 피싱할 것도, 유출할 것도, 스터핑할 것도 없다.
Passkey가 생성될 때 오리진(예: https://example.com)에 바인딩된다. 브라우저는 생성된 정확한 오리진에서만 Passkey를 사용한다. 공격자가 https://exarnple.com에 유사한 사이트를 만들어도 Passkey는 제공되지 않는다. 이것은 사용자의 주의력이 아니라 브라우저에 의해 강제된다.
이것은 비밀번호와 근본적으로 다르다. 비밀번호에서는 사용자가 페이지가 올바르게 보이기 때문에 피싱 사이트에 자격 증명을 일상적으로 입력한다.
SimpleWebAuthn은 내가 추천하는 라이브러리다. WebAuthn 프로토콜을 올바르게 처리하고 좋은 TypeScript 타입이 있다.
// 서버 측: 등록
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
import type { GenerateRegistrationOptionsOpts, VerifiedRegistrationResponse } from "@simplewebauthn/server";
const rpName = "example.com";
const rpID = "example.com";
const origin = "https://example.com";
async function startRegistration(userId: string, userEmail: string) {
// 사용자의 기존 Passkey를 가져와 제외
const existingCredentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
const options: GenerateRegistrationOptionsOpts = {
rpName,
rpID,
userID: new TextEncoder().encode(userId),
userName: userEmail,
attestationType: "none", // 대부분의 앱에 증명서는 필요 없다
excludeCredentials: existingCredentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
};
const registrationOptions = await generateRegistrationOptions(options);
// 챌린지를 임시로 저장 — 검증에 필요
await redis.set(
`webauthn:challenge:${userId}`,
registrationOptions.challenge,
"EX",
300, // 5분 만료
);
return registrationOptions;
}
async function finishRegistration(userId: string, response: unknown) {
const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
if (!expectedChallenge) {
throw new Error("챌린지가 만료되었거나 찾을 수 없음");
}
let verification: VerifiedRegistrationResponse;
try {
verification = await verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
} catch (error) {
throw new Error(`등록 검증 실패: ${error}`);
}
if (!verification.verified || !verification.registrationInfo) {
throw new Error("등록 검증 실패");
}
const { credential } = verification.registrationInfo;
// 자격 증명을 데이터베이스에 저장
await db.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: credential.transports ?? [],
},
});
// 정리
await redis.del(`webauthn:challenge:${userId}`);
return { verified: true };
}// 서버 측: 인증
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
async function startAuthentication(userId?: string) {
let allowCredentials;
// 사용자를 알고 있으면(예: 이메일을 입력한 경우), 그들의 Passkey로 제한
if (userId) {
const credentials = await db.credential.findMany({
where: { userId },
select: { credentialId: true, transports: true },
});
allowCredentials = credentials.map((cred) => ({
id: cred.credentialId,
transports: cred.transports,
}));
}
const options = await generateAuthenticationOptions({
rpID,
allowCredentials,
userVerification: "preferred",
});
// 검증을 위해 챌린지 저장
const challengeKey = userId ? `webauthn:auth:${userId}` : `webauthn:auth:${options.challenge}`;
await redis.set(challengeKey, options.challenge, "EX", 300);
return options;
}
async function finishAuthentication(response: any, expectedChallenge: string, userId: string) {
const credential = await db.credential.findUnique({
where: { credentialId: response.id },
});
if (!credential) {
throw new Error("자격 증명을 찾을 수 없음");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
},
});
if (!verification.verified) {
throw new Error("인증 검증 실패");
}
// 중요: 리플레이 공격 방지를 위해 카운터 업데이트
await db.credential.update({
where: { credentialId: response.id },
data: {
counter: verification.authenticationInfo.newCounter,
},
});
return { verified: true, userId: credential.userId };
}// 클라이언트 측: 등록
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
async function registerPasskey() {
// 서버에서 옵션 가져오기
const optionsResponse = await fetch("/api/auth/webauthn/register", {
method: "POST",
});
const options = await optionsResponse.json();
try {
// 브라우저의 Passkey UI 트리거 (생체인식 프롬프트)
const credential = await webAuthnRegister(options);
// 검증을 위해 서버에 자격 증명 전송
const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
const result = await verifyResponse.json();
if (result.verified) {
console.log("Passkey가 성공적으로 등록되었습니다!");
}
} catch (error) {
if ((error as Error).name === "NotAllowedError") {
console.log("사용자가 Passkey 등록을 취소했습니다");
}
}
}접하게 될 두 가지 용어:
attestationType: "none"으로 설정하라.Passkey가 있어도 TOTP를 통한 MFA가 필요한 시나리오를 만나게 된다 — 비밀번호와 함께 두 번째 요소로서의 Passkey, 또는 Passkey를 지원하지 않는 디바이스 사용자 지원.
TOTP는 Google Authenticator, Authy, 1Password의 뒤에 있는 프로토콜이다. 동작 방식:
import { createHmac, randomBytes } from "crypto";
// 사용자를 위한 TOTP 시크릿 생성
function generateTOTPSecret(): string {
const buffer = randomBytes(20);
return base32Encode(buffer);
}
function base32Encode(buffer: Buffer): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let result = "";
let bits = 0;
let value = 0;
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
result += alphabet[(value >>> (bits - 5)) & 0x1f];
bits -= 5;
}
}
if (bits > 0) {
result += alphabet[(value << (5 - bits)) & 0x1f];
}
return result;
}
// QR 코드를 위한 TOTP URI 생성
function generateTOTPUri(secret: string, userEmail: string, issuer: string = "example.com"): string {
const encodedIssuer = encodeURIComponent(issuer);
const encodedEmail = encodeURIComponent(userEmail);
return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}// TOTP 코드 검증
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
const secretBuffer = base32Decode(secret);
const now = Math.floor(Date.now() / 1000);
// 현재 시간 단계와 인접한 단계 확인 (시계 오차 허용)
for (let i = -window; i <= window; i++) {
const timeStep = Math.floor(now / 30) + i;
const expectedCode = generateTOTPCode(secretBuffer, timeStep);
// 타이밍 공격 방지를 위한 상수 시간 비교
if (timingSafeEqual(code, expectedCode)) {
return true;
}
}
return false;
}
function generateTOTPCode(secret: Buffer, timeStep: number): string {
// 시간 단계를 8바이트 빅엔디안 버퍼로 변환
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(timeStep));
// HMAC-SHA1
const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
// 동적 자르기
const offset = hmac[hmac.length - 1] & 0x0f;
const code =
((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff);
return (code % 1_000_000).toString().padStart(6, "0");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
return createHmac("sha256", "key").update(bufA).digest().equals(createHmac("sha256", "key").update(bufB).digest());
}사용자는 폰을 잃어버린다. 항상 MFA 설정 시 백업 코드를 생성하라:
import { randomBytes, createHash } from "crypto";
function generateBackupCodes(count: number = 10): string[] {
return Array.from(
{ length: count },
() => randomBytes(4).toString("hex").toUpperCase(), // 8자리 hex 코드
);
}
async function storeBackupCodes(userId: string, codes: string[]) {
// 저장 전에 코드를 해시 — 비밀번호처럼 취급
const hashedCodes = codes.map((code) => createHash("sha256").update(code).digest("hex"));
await db.backupCode.createMany({
data: hashedCodes.map((hash) => ({
userId,
codeHash: hash,
used: false,
})),
});
// 사용자가 저장하도록 평문 코드를 한 번만 반환
// 이후에는 해시만 가지고 있다
return codes;
}
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
const codeHash = createHash("sha256").update(code.toUpperCase().replace(/\s/g, "")).digest("hex");
const backupCode = await db.backupCode.findFirst({
where: {
userId,
codeHash,
used: false,
},
});
if (!backupCode) return false;
// 사용됨으로 표시 — 각 백업 코드는 정확히 한 번만 동작
await db.backupCode.update({
where: { id: backupCode.id },
data: { used: true, usedAt: new Date() },
});
return true;
}MFA 복구는 대부분의 튜토리얼이 건너뛰고 대부분의 실제 애플리케이션이 망치는 부분이다. 내가 구현하는 것:
대기 기간이 핵심이다. 공격자가 사용자의 이메일을 침해한 경우 즉시 MFA를 비활성화하게 하고 싶지 않다. 24시간 지연은 합법적인 사용자가 이메일을 알아차리고 개입할 시간을 준다.
async function initiateAccountRecovery(email: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// 계정 존재 여부를 노출하지 마라
return { message: "해당 이메일이 존재하면 복구 안내를 보냈습니다." };
}
const recoveryToken = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
await db.recoveryRequest.create({
data: {
userId: user.id,
tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24시간
status: "pending",
},
});
// 복구 링크가 포함된 이메일 전송
await sendEmail(email, {
subject: "계정 복구 요청",
body: `
계정의 MFA를 비활성화하는 요청이 있었습니다.
본인이라면 24시간 후 아래 링크를 클릭하세요: ...
본인이 아니라면 즉시 비밀번호를 변경하세요.
`,
});
return { message: "해당 이메일이 존재하면 복구 안내를 보냈습니다." };
}인증은 누구인지 알려준다. 인가는 무엇을 할 수 있는지 알려준다. 이것을 잘못하면 뉴스에 나오게 된다.
RBAC (역할 기반 접근 제어): 사용자에게 역할이 있고, 역할에 권한이 있다. 단순하고, 이해하기 쉽고, 대부분의 애플리케이션에 동작한다.
// RBAC — 직관적인 역할 확인
type Role = "user" | "editor" | "admin" | "super_admin";
const ROLE_PERMISSIONS: Record<Role, string[]> = {
user: ["read:own_profile", "update:own_profile", "read:posts"],
editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
admin: [
"read:own_profile",
"update:own_profile",
"read:posts",
"create:posts",
"update:posts",
"delete:posts",
"read:users",
"update:users",
],
super_admin: ["*"], // 와일드카드에 주의
};
function hasPermission(role: Role, permission: string): boolean {
const permissions = ROLE_PERMISSIONS[role];
return permissions.includes("*") || permissions.includes(permission);
}
// API 라우트에서 사용
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasPermission(session.user.role as Role, "delete:posts")) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
await db.post.delete({ where: { id } });
return Response.json({ success: true });
}ABAC (속성 기반 접근 제어): 권한이 사용자, 리소스, 컨텍스트의 속성에 따라 결정된다. 더 유연하지만 더 복잡하다.
// ABAC — RBAC가 부족할 때
interface PolicyContext {
user: {
id: string;
role: string;
department: string;
clearanceLevel: number;
};
resource: {
type: string;
ownerId: string;
classification: string;
department: string;
};
action: string;
environment: {
ipAddress: string;
time: Date;
mfaVerified: boolean;
};
}
function evaluatePolicy(context: PolicyContext): boolean {
const { user, resource, action, environment } = context;
// 사용자는 항상 자기 리소스를 읽을 수 있다
if (action === "read" && resource.ownerId === user.id) {
return true;
}
// 관리자는 자기 부서의 모든 리소스를 읽을 수 있다
if (action === "read" && user.role === "admin" && user.department === resource.department) {
return true;
}
// 기밀 리소스는 MFA와 최소 보안등급이 필요
if (resource.classification === "confidential") {
if (!environment.mfaVerified) return false;
if (user.clearanceLevel < 3) return false;
}
// 파괴적 작업은 업무 시간 외 차단
if (action === "delete") {
const hour = environment.time.getHours();
if (hour < 9 || hour > 17) return false;
}
return false; // 기본 거부
}가장 중요한 인가 원칙: UI 수준에서만이 아니라 모든 신뢰 경계에서 권한을 확인하라.
// 나쁜 예 — 컴포넌트에서만 확인
function DeleteButton({ post }: { post: Post }) {
const { data: session } = useSession();
// 버튼을 숨기지만 삭제를 방지하지는 않는다
if (session?.user?.role !== "admin") return null;
return <button onClick={() => deletePost(post.id)}>삭제</button>;
}
// 역시 나쁜 예 — 서버 액션에서 확인하지만 API 라우트에서는 안 함
async function deletePostAction(postId: string) {
const session = await auth();
if (session?.user?.role !== "admin") throw new Error("Forbidden");
await db.post.delete({ where: { id: postId } });
}
// 공격자가 여전히 POST /api/posts/123을 직접 호출할 수 있다
// 좋은 예 — 모든 경계에서 확인
// 1. UI에서 버튼 숨기기 (UX, 보안이 아님)
// 2. 서버 액션에서 확인 (심층 방어)
// 3. API 라우트에서 확인 (실제 보안 경계)
// 4. 선택적으로 미들웨어에서 확인 (라우트 수준 보호)UI 확인은 사용자 경험을 위한 것이다. 서버 확인은 보안을 위한 것이다. 절대 하나에만 의존하지 마라.
미들웨어는 매칭되는 모든 요청 전에 실행된다. 대략적인 접근 제어에 좋은 장소다:
// "이 사용자가 이 섹션에 접근할 수 있는가?"
// 세분화된 확인("이 사용자가 이 게시물을 편집할 수 있는가?")은 라우트 핸들러에 속한다
// 미들웨어는 요청 본문이나 라우트 파라미터에 쉽게 접근할 수 없기 때문이다.
export default auth((req) => {
const path = req.nextUrl.pathname;
const role = req.auth?.user?.role;
// 라우트 수준 접근 제어
const routeAccess: Record<string, Role[]> = {
"/admin": ["admin", "super_admin"],
"/editor": ["editor", "admin", "super_admin"],
"/dashboard": ["user", "editor", "admin", "super_admin"],
};
for (const [route, allowedRoles] of Object.entries(routeAccess)) {
if (path.startsWith(route)) {
if (!role || !allowedRoles.includes(role as Role)) {
return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
}
}
}
return NextResponse.next();
});실제 코드베이스에서 가장 자주 보는 공격들이다. 이해하는 것이 필수적이다.
공격: 공격자가 사이트에서 유효한 세션을 만든 다음, 피해자를 그 세션 ID를 사용하도록 속인다(예: URL 파라미터를 통해 또는 서브도메인을 통한 쿠키 설정). 피해자가 로그인하면 공격자의 세션이 인증된 사용자를 갖게 된다.
해결책: 인증 성공 후 항상 세션 ID를 재생성하라. 인증 전 세션 ID가 인증 후 세션으로 이어지게 하지 마라.
async function login(credentials: { email: string; password: string }, request: Request) {
const user = await verifyCredentials(credentials);
if (!user) throw new Error("잘못된 자격 증명");
// 핵심: 이전 세션을 삭제하고 새 것을 만든다
const oldSessionId = getSessionIdFromCookie(request);
if (oldSessionId) {
await redis.del(`session:${oldSessionId}`);
}
// 새 ID로 완전히 새로운 세션 생성
const newSessionId = await createSession(user.id, request);
return newSessionId;
}공격: 사용자가 사이트에 로그인되어 있다. 악성 페이지를 방문하면 그 페이지가 사이트에 요청을 보낸다. 쿠키가 자동으로 전송되므로 요청이 인증된다.
현대의 해결책: SameSite 쿠키. SameSite: Lax를 설정하면(현재 대부분의 브라우저에서 기본값) 크로스 오리진 POST 요청에서 쿠키가 전송되지 않아 대부분의 CSRF 시나리오를 커버한다.
// SameSite=Lax는 대부분의 CSRF 시나리오를 커버:
// - 크로스 오리진 POST, PUT, DELETE에서 쿠키 차단
// - 크로스 오리진 GET에서 쿠키 허용 (최상위 탐색)
// GET 요청은 부작용이 없어야 하므로 괜찮다
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // 이것이 CSRF 보호
maxAge: 86400,
path: "/",
});JSON을 수락하는 API의 경우 추가 보호를 무료로 얻는다: Content-Type: application/json 헤더는 HTML 폼으로 설정할 수 없고, CORS가 다른 오리진의 JavaScript가 커스텀 헤더로 요청을 보내는 것을 방지한다.
더 강력한 보장이 필요하면(예: 폼 제출을 수락하는 경우), 더블 서밋 쿠키 패턴이나 동기화 토큰을 사용하라. Auth.js가 이것을 자동으로 처리한다.
공격: 공격자가 인증 후 자기 사이트로 리다이렉트하는 OAuth 콜백 URL을 조작한다: https://yourapp.com/callback?redirect_to=https://evil.com/steal-token
콜백 핸들러가 redirect_to 파라미터를 무조건 리다이렉트하면, 사용자는 잠재적으로 URL에 토큰이 있는 상태로 공격자의 사이트에 도착한다.
// 취약
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// ... 사용자 인증 ...
return Response.redirect(redirectTo); // https://evil.com일 수 있다!
}
// 안전
async function handleCallback(request: Request) {
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirect_to") ?? "/";
// 리다이렉트 URL 유효성 검사
const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
// ... 사용자 인증 ...
return Response.redirect(safeRedirect);
}
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
try {
const url = new URL(redirect, baseUrl);
const base = new URL(baseUrl);
// 같은 오리진으로의 리다이렉트만 허용
if (url.origin !== base.origin) {
return "/";
}
// 경로 리다이렉트만 허용 (javascript:이나 data: URI 불가)
if (!url.pathname.startsWith("/")) {
return "/";
}
return url.pathname + url.search;
} catch {
return "/";
}
}토큰을 URL에 넣으면(넣지 마라), 사용자가 링크를 클릭할 때 Referer 헤더를 통해 유출된다. GitHub을 포함한 실제 유출 사고가 있었다.
규칙:
Referrer-Policy: strict-origin-when-cross-origin (또는 더 엄격하게)을 설정하라// Next.js 미들웨어 또는 레이아웃에서
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");덜 알려진 공격: 일부 JWT 라이브러리가 검증자에게 공개 키를 어디서 찾을지 알려주는 jwk 또는 jku 헤더를 지원한다. 공격자가:
jwk 헤더를 자기 공개 키를 가리키도록 설정한다라이브러리가 jwk 헤더에서 키를 무조건 가져다 사용하면, 서명이 검증된다. 해결책: 토큰이 자체 검증 키를 지정하게 절대 허용하지 마라. 항상 자체 설정의 키를 사용하라.
수년간 인증 시스템을 구축한 후 오늘 실제로 사용하는 것이다.
새 프로젝트의 기본 스택:
세션 전략은 데이터베이스 기반(JWT가 아님)이며, 즉시 폐기와 간단한 세션 관리를 제공한다.
// 새 프로젝트의 전형적인 auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [
Google,
GitHub,
Passkey({
// Auth.js v5에는 내장 Passkey 지원이 있다
// 내부적으로 SimpleWebAuthn을 사용한다
}),
],
experimental: {
enableWebAuthn: true,
},
});매니지드 인증 프로바이더를 선택하는 경우:
매니지드 프로바이더의 트레이드오프:
jose, Passkey에는 @simplewebauthn/server, 비밀번호에는 bcrypt 또는 argon2를 사용한다. 절대 직접 만들지 않는다.crypto.randomBytes(32)를 사용하라. UUID v4는 수용 가능하지만 원시 랜덤 바이트보다 엔트로피가 적다.언급했으니까 — 2026년에 비밀번호를 올바르게 해싱하는 방법:
import { hash, verify } from "@node-rs/argon2";
// Argon2id가 권장 알고리즘
// 웹 애플리케이션에 합리적인 기본값
async function hashPassword(password: string): Promise<string> {
return hash(password, {
memoryCost: 65536, // 64 MB
timeCost: 3, // 3회 반복
parallelism: 4, // 4 스레드
});
}
async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
try {
return await verify(hashedPassword, password);
} catch {
return false;
}
}왜 bcrypt 대신 argon2id인가? Argon2id는 메모리 하드이며, 공격에 CPU 파워뿐만 아니라 대량의 RAM도 필요하다는 뜻이다. 이것은 GPU와 ASIC 공격을 상당히 비싸게 만든다. Bcrypt는 여전히 괜찮다 — 깨진 것이 아니다 — 하지만 새 프로젝트에는 argon2id가 더 나은 선택이다.
인증 시스템을 배포하기 전에 확인하라:
HttpOnly, Secure, SameSite=Lax 또는 Strictalg 헤더를 절대 신뢰하지 마라)Referrer-Policy 헤더가 설정됨이 목록이 완전한 것은 아니지만 프로덕션 시스템에서 가장 자주 본 취약점을 커버한다.
인증은 환경이 계속 진화하지만 기본 원칙은 동일한 도메인 중 하나다: 신원을 검증하고, 최소한의 필요한 자격 증명을 발급하고, 모든 경계에서 권한을 확인하고, 침해를 가정하라.
2026년의 가장 큰 변화는 Passkey의 주류화다. 브라우저 지원이 보편적이고, 플랫폼 지원(iCloud 키체인, Google 비밀번호 관리자)이 UX를 매끄럽게 만들며, 보안 속성이 이전의 어떤 것보다 진정으로 우수하다. 새 애플리케이션을 만들고 있다면 Passkey를 기본 로그인 방법으로 만들고 비밀번호를 폴백으로 취급하라.
두 번째로 큰 변화는 자체 인증 구현이 정당화하기 어려워졌다는 것이다. Auth.js v5, Clerk, 유사한 솔루션들이 어려운 부분을 올바르게 처리한다. 커스텀으로 갈 유일한 이유는 요구사항이 기존 솔루션에 진정으로 맞지 않을 때이며 — 대부분의 개발자가 생각하는 것보다 그런 경우는 드물다.
무엇을 선택하든, 공격자처럼 인증을 테스트하라. 토큰 리플레이, 서명 위조, 접근해서는 안 되는 라우트 접근, 리다이렉트 URL 조작을 시도하라. 출시 전에 찾는 버그가 뉴스에 나오지 않는 버그다.