2026년 모던 인증: JWT, 세션, OAuth, 그리고 Passkey
전체 인증 환경: 세션 vs JWT 선택, OAuth 2.0/OIDC 플로우, 리프레시 토큰 로테이션, Passkey(WebAuthn), 그리고 실제로 사용하는 Next.js 인증 패턴.
인증은 웹 개발에서 "동작하면 됐지"가 절대 충분하지 않은 유일한 영역이다. 날짜 선택기의 버그는 짜증나다. 인증 시스템의 버그는 데이터 유출이다.
인증을 처음부터 구현하고, 프로바이더 간 마이그레이션을 하고, 토큰 탈취 사고를 디버깅하고, "보안은 나중에 고치자"는 결정의 여파를 수습한 경험이 있다. 이 글은 처음 시작할 때 있었으면 좋았을 종합 가이드다. 이론만이 아니라 — 실제 트레이드오프, 실제 취약점, 프로덕션 압박 아래서도 견디는 패턴들이다.
세션, JWT, OAuth 2.0, Passkey, MFA, 인가의 전체 환경을 다룰 것이다. 끝까지 읽으면 각 메커니즘이 어떻게 동작하는지뿐만 아니라, 언제 사용해야 하는지와 왜 대안이 존재하는지를 이해하게 될 것이다.
세션 vs JWT: 실제 트레이드오프#
이것은 처음 마주할 결정이며, 인터넷에는 이에 대한 나쁜 조언이 넘쳐난다. 실제로 중요한 것이 무엇인지 정리해보겠다.
세션 기반 인증#
세션은 원래의 접근법이다. 서버가 세션 레코드를 만들어 어딘가(데이터베이스, 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;
}장점은 확실하다:
- 즉시 폐기 가능. 세션 레코드를 삭제하면 사용자가 로그아웃된다. 만료를 기다릴 필요가 없다. 의심스러운 활동을 감지했을 때 중요하다.
- 세션 가시성. 사용자에게 활성 세션을 보여줄 수 있다("Chrome, Windows 11, 서울에서 로그인") 그리고 개별 세션을 폐기할 수 있다.
- 작은 쿠키 크기. 세션 ID는 보통 64자다. 쿠키가 커지지 않는다.
- 서버 측 제어. 세션 데이터를 업데이트할 수 있고(사용자를 관리자로 승격, 권한 변경) 다음 요청에 반영된다.
단점도 확실하다:
- 모든 요청마다 데이터베이스 조회. 모든 인증된 요청에 세션 조회가 필요하다. Redis를 쓰면 밀리초 이하지만, 여전히 의존성이다.
- 수평 확장에 공유 스토리지가 필요. 여러 서버가 있으면 모두 같은 세션 저장소에 접근해야 한다. 스티키 세션은 취약한 우회법이다.
- CSRF가 우려된다. 쿠키는 자동으로 전송되므로 CSRF 보호가 필요하다. SameSite 쿠키가 이것을 대체로 해결하지만, 왜 그런지 이해해야 한다.
JWT 기반 인증#
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://akousa.net")
.setAudience("https://akousa.net")
.sign(secret);
}
async function verifyAccessToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://akousa.net",
audience: "https://akousa.net",
});
return payload;
} catch {
return null;
}
}장점:
- 서버 측 저장 불필요. 토큰이 자기 완결적이다. 서명을 검증하고 클레임을 읽으면 된다. 데이터베이스 조회가 없다.
- 서비스 간 동작. 마이크로서비스 아키텍처에서 공개 키만 있으면 어떤 서비스든 토큰을 검증할 수 있다. 공유 세션 저장소가 필요 없다.
- 무상태 확장. 세션 어피니티 걱정 없이 서버를 추가할 수 있다.
단점 — 사람들이 대충 넘기는 부분:
- JWT를 폐기할 수 없다. 한번 발급하면 만료될 때까지 유효하다. 사용자 계정이 침해당해도 강제 로그아웃할 수 없다. 블록리스트를 만들 수 있지만, 그러면 서버 측 상태를 다시 도입한 것이고 주요 장점을 잃은 것이다.
- 토큰 크기. 몇 개의 클레임이 있는 JWT도 보통 800바이트 이상이다. 역할, 권한, 메타데이터를 추가하면 모든 요청마다 킬로바이트를 보내게 된다.
- 페이로드가 읽힌다. 페이로드는 Base64로 인코딩된 것이지, 암호화된 것이 아니다. 누구나 디코딩할 수 있다. JWT에 민감한 데이터를 절대 넣지 마라.
- 시계 오차 문제. 서버들의 시계가 다르면(일어나는 일이다), 만료 확인이 신뢰할 수 없게 된다.
각각을 언제 사용하는가#
내 경험칙:
세션을 사용할 때: 모놀리식 애플리케이션이 있을 때, 즉시 폐기가 필요할 때, 계정 보안이 중요한 소비자 대면 제품을 만들 때, 또는 인증 요구사항이 자주 변할 수 있을 때.
JWT를 사용할 때: 서비스들이 독립적으로 신원을 검증해야 하는 마이크로서비스 아키텍처일 때, API 간 통신을 구축할 때, 또는 서드파티 인증 시스템을 구현할 때.
실제로는: 대부분의 애플리케이션은 세션을 사용해야 한다. "JWT가 더 확장 가능하다"는 주장은 세션 스토리지로 해결할 수 없는 확장 문제가 실제로 있을 때만 해당된다 — 그리고 Redis는 초당 수백만 세션 조회를 처리한다. JWT가 더 현대적으로 들린다는 이유로 선택했다가, 블록리스트와 리프레시 토큰 시스템을 만들어 세션보다 더 복잡해진 프로젝트를 너무 많이 봤다.
JWT 심화#
세션 기반 인증을 선택하더라도 OAuth, OIDC, 서드파티 통합을 통해 JWT를 접하게 된다. 내부 구조를 이해하는 것은 협상의 여지가 없다.
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" // 커스텀 클레임
}서명 — 토큰이 변조되지 않았음을 증명한다. 인코딩된 헤더와 페이로드를 비밀 키로 서명하여 생성한다.
RS256 vs HS256: 실제로 중요하다#
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"으로 토큰을 만든다- 공개 키를 HMAC 시크릿으로 사용해 서명한다
서버가 alg 헤더를 읽고 HS256 검증으로 전환하면, 공개 키(모두가 아는)가 공유 시크릿이 된다. 서명이 유효하다. 공격자가 토큰을 위조한 것이다.
다시 한번, 해결책은 동일하다: 토큰 헤더의 알고리즘을 절대 신뢰하지 마라. 항상 하드코딩하라.
리프레시 토큰 로테이션#
JWT를 사용한다면 리프레시 토큰 전략이 필요하다. 수명이 긴 액세스 토큰을 보내는 것은 문제를 자초하는 것이다 — 탈취되면 공격자가 전체 수명 동안 접근 권한을 갖는다.
패턴:
- 액세스 토큰: 수명이 짧다(15분). API 요청에 사용.
- 리프레시 토큰: 수명이 길다(30일). 새 액세스 토큰을 받기 위해서만 사용.
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 };
}패밀리 무효화가 중요한 이유#
이 시나리오를 생각해보라:
- 사용자가 로그인하고 리프레시 토큰 A를 받는다
- 공격자가 리프레시 토큰 A를 탈취한다
- 공격자가 A를 사용해 새 쌍을 받는다(액세스 토큰 + 리프레시 토큰 B)
- 사용자가 여전히 가지고 있는 A를 사용해 갱신을 시도한다
재사용 감지 없이는 사용자가 그냥 에러를 받는다. 공격자는 토큰 B로 계속 진행한다. 사용자는 다시 로그인하고, 계정이 침해당한 것을 절대 모른다.
재사용 감지와 패밀리 무효화가 있으면: 사용자가 이미 사용된 토큰 A를 사용하려 할 때, 시스템이 재사용을 감지하고, 패밀리의 모든 토큰(B 포함)을 무효화하고, 사용자와 공격자 모두에게 재인증을 강제한다. 사용자는 "다시 로그인하세요" 메시지를 받고 뭔가 잘못됐다는 것을 알아차릴 수 있다.
이것은 Auth0, Okta, Auth.js가 사용하는 접근법이다. 완벽하지는 않다 — 공격자가 합법적인 사용자보다 먼저 토큰을 사용하면, 합법적인 사용자가 재사용 경고를 트리거하는 쪽이 된다. 하지만 베어러 토큰으로 할 수 있는 최선이다.
OAuth 2.0 & OIDC#
OAuth 2.0과 OpenID Connect는 "Google/GitHub/Apple로 로그인"의 뒤에 있는 프로토콜이다. 라이브러리를 사용하더라도 이것들을 이해하는 것이 필수적인데, 문제가 생기면 — 그리고 생길 것이다 — 프로토콜 수준에서 무슨 일이 일어나는지 알아야 하기 때문이다.
핵심 구분#
OAuth 2.0은 인가 프로토콜이다. "이 애플리케이션이 이 사용자의 데이터에 접근할 수 있는가?"에 답한다. 결과는 특정 권한(스코프)을 부여하는 액세스 토큰이다.
**OpenID Connect (OIDC)**는 OAuth 2.0 위에 구축된 인증 계층이다. "이 사용자가 누구인가?"에 답한다. 결과는 사용자 신원 정보를 담은 ID 토큰(JWT)이다.
"Google로 로그인"할 때 OIDC를 사용하는 것이다. Google이 앱에 사용자가 누구인지 알려준다(인증). 캘린더나 드라이브에 접근하기 위해 OAuth 스코프를 요청할 수도 있다(인가).
PKCE를 사용한 인가 코드 플로우#
웹 애플리케이션에서 사용해야 하는 플로우다. 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 프로바이더가 이것들을 노출한다:
- 인가 엔드포인트 — 사용자를 로그인하고 권한을 부여하도록 리다이렉트하는 곳. 인가 코드를 반환한다.
- 토큰 엔드포인트 — 서버가 인가 코드를 액세스/리프레시/ID 토큰으로 교환하는 곳. 서버 간 호출이다.
- UserInfo 엔드포인트 — 액세스 토큰으로 추가 사용자 프로필 데이터를 가져올 수 있는 곳. OIDC에서는 이것의 대부분이 이미 ID 토큰에 있다.
State 파라미터#
state 파라미터는 OAuth 콜백에 대한 CSRF 공격을 방지한다. 이것 없이는:
- 공격자가 자기 컴퓨터에서 OAuth 플로우를 시작하고 인가 코드를 받는다
- 공격자가 URL을 조작한다:
https://yourapp.com/callback?code=ATTACKER_CODE - 공격자가 피해자를 클릭하게 속인다(이메일 링크, 숨겨진 이미지)
- 앱이 공격자의 코드를 교환하고 공격자의 Google 계정을 피해자의 세션에 연결한다
state가 있으면: 앱이 랜덤 값을 생성하고, 세션에 저장하고, 인가 URL에 포함한다. 콜백이 올 때 state가 일치하는지 확인한다. 공격자는 피해자의 세션에 접근할 수 없으므로 이것을 위조할 수 없다.
Next.js App Router에서의 Auth.js (NextAuth)#
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 (WebAuthn)#
Passkey는 수년간 가장 중요한 인증 개선이다. 피싱 내성이 있고, 리플레이 내성이 있으며, 비밀번호 관련 취약점의 전체 범주를 제거한다. 2026년에 새 프로젝트를 시작한다면 Passkey를 지원해야 한다.
Passkey의 동작 방식#
Passkey는 공개 키 암호화를 사용하며, 생체인식이나 디바이스 PIN으로 보호된다:
- 등록: 브라우저가 키 쌍을 생성한다. 개인 키는 디바이스에 남는다(보안 영역에, 생체인식으로 보호됨). 공개 키가 서버로 전송된다.
- 인증: 서버가 챌린지(랜덤 바이트)를 보낸다. 디바이스가 개인 키로 챌린지에 서명한다(생체인식 검증 후). 서버가 저장된 공개 키로 서명을 검증한다.
공유 시크릿이 네트워크를 통과하는 일은 없다. 피싱할 것도, 유출할 것도, 스터핑할 것도 없다.
Passkey가 피싱 내성인 이유#
Passkey가 생성될 때 오리진(예: https://example.com)에 바인딩된다. 브라우저는 생성된 정확한 오리진에서만 Passkey를 사용한다. 공격자가 https://exarnple.com에 유사한 사이트를 만들어도 Passkey는 제공되지 않는다. 이것은 사용자의 주의력이 아니라 브라우저에 의해 강제된다.
이것은 비밀번호와 근본적으로 다르다. 비밀번호에서는 사용자가 페이지가 올바르게 보이기 때문에 피싱 사이트에 자격 증명을 일상적으로 입력한다.
SimpleWebAuthn으로 구현#
SimpleWebAuthn은 내가 추천하는 라이브러리다. WebAuthn 프로토콜을 올바르게 처리하고 좋은 TypeScript 타입이 있다.
// 서버 측: 등록
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
GenerateRegistrationOptionsOpts,
VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
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 등록을 취소했습니다");
}
}
}Attestation vs Assertion#
접하게 될 두 가지 용어:
- Attestation (등록): 새 자격 증명을 생성하는 과정. 인증자가 자신의 신원과 기능을 "증명"한다. 대부분의 애플리케이션에서는 attestation을 검증할 필요가 없다 —
attestationType: "none"으로 설정하라. - Assertion (인증): 기존 자격 증명을 사용해 챌린지에 서명하는 과정. 인증자가 사용자가 주장하는 사람이 맞다고 "주장"한다.
MFA 구현#
Passkey가 있어도 TOTP를 통한 MFA가 필요한 시나리오를 만나게 된다 — 비밀번호와 함께 두 번째 요소로서의 Passkey, 또는 Passkey를 지원하지 않는 디바이스 사용자 지원.
TOTP (시간 기반 일회용 비밀번호)#
TOTP는 Google Authenticator, Authy, 1Password의 뒤에 있는 프로토콜이다. 동작 방식:
- 서버가 랜덤 시크릿을 생성한다(base32 인코딩)
- 사용자가 시크릿이 담긴 QR 코드를 스캔한다
- 서버와 인증 앱 모두 시크릿과 현재 시간으로 같은 6자리 코드를 계산한다
- 코드는 30초마다 변경된다
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 = "akousa.net"
): 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 복구는 대부분의 튜토리얼이 건너뛰고 대부분의 실제 애플리케이션이 망치는 부분이다. 내가 구현하는 것:
- 1순위: 인증 앱의 TOTP 코드
- 2순위: 10개의 백업 코드 중 하나
- 최후의 수단: 24시간 대기 기간과 사용자의 다른 인증된 채널에 대한 알림이 포함된 이메일 기반 복구
대기 기간이 핵심이다. 공격자가 사용자의 이메일을 침해한 경우 즉시 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 vs ABAC#
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 확인은 사용자 경험을 위한 것이다. 서버 확인은 보안을 위한 것이다. 절대 하나에만 의존하지 마라.
Next.js 미들웨어에서의 권한 확인#
미들웨어는 매칭되는 모든 요청 전에 실행된다. 대략적인 접근 제어에 좋은 장소다:
// "이 사용자가 이 섹션에 접근할 수 있는가?"
// 세분화된 확인("이 사용자가 이 게시물을 편집할 수 있는가?")은 라우트 핸들러에 속한다
// 미들웨어는 요청 본문이나 라우트 파라미터에 쉽게 접근할 수 없기 때문이다.
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;
}CSRF (크로스사이트 요청 위조)#
공격: 사용자가 사이트에 로그인되어 있다. 악성 페이지를 방문하면 그 페이지가 사이트에 요청을 보낸다. 쿠키가 자동으로 전송되므로 요청이 인증된다.
현대의 해결책: 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에서의 오픈 리다이렉트#
공격: 공격자가 인증 후 자기 사이트로 리다이렉트하는 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 "/";
}
}Referrer를 통한 토큰 유출#
토큰을 URL에 넣으면(넣지 마라), 사용자가 링크를 클릭할 때 Referer 헤더를 통해 유출된다. GitHub을 포함한 실제 유출 사고가 있었다.
규칙:
- 인증을 위해 URL 쿼리 파라미터에 토큰을 절대 넣지 마라
Referrer-Policy: strict-origin-when-cross-origin(또는 더 엄격하게)을 설정하라- URL에 토큰을 반드시 넣어야 하면(예: 이메일 인증 링크), 일회용이고 수명이 짧게 만들어라
// Next.js 미들웨어 또는 레이아웃에서
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");JWT 키 주입#
덜 알려진 공격: 일부 JWT 라이브러리가 검증자에게 공개 키를 어디서 찾을지 알려주는 jwk 또는 jku 헤더를 지원한다. 공격자가:
- 자기 키 쌍을 생성한다
- 자기 페이로드로 JWT를 만들고 자기 개인 키로 서명한다
jwk헤더를 자기 공개 키를 가리키도록 설정한다
라이브러리가 jwk 헤더에서 키를 무조건 가져다 사용하면, 서명이 검증된다. 해결책: 토큰이 자체 검증 키를 지정하게 절대 허용하지 마라. 항상 자체 설정의 키를 사용하라.
2026년 내 인증 스택#
수년간 인증 시스템을 구축한 후 오늘 실제로 사용하는 것이다.
대부분의 프로젝트: Auth.js + PostgreSQL + Passkey#
새 프로젝트의 기본 스택:
- Auth.js (v5) — 무거운 작업 담당: OAuth 프로바이더, 세션 관리, CSRF, 데이터베이스 어댑터
- PostgreSQL + Prisma 어댑터 — 세션 및 계정 저장
- Passkey — SimpleWebAuthn으로, 새 사용자의 기본 로그인 방법
- 이메일/비밀번호 — Passkey를 사용할 수 없는 사용자를 위한 폴백
- TOTP MFA — 비밀번호 기반 로그인의 두 번째 요소
세션 전략은 데이터베이스 기반(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,
},
});Clerk이나 Auth0를 대신 사용할 때#
매니지드 인증 프로바이더를 선택하는 경우:
- 프로젝트에 엔터프라이즈 SSO가 필요할 때 (SAML, SCIM). SAML을 올바르게 구현하는 것은 수개월 프로젝트다. Clerk은 기본 제공한다.
- 팀에 보안 전문가가 없을 때. 팀에서 PKCE를 설명할 수 있는 사람이 없으면 인증을 처음부터 만들면 안 된다.
- 출시 속도가 비용보다 중요할 때. Auth.js는 무료지만 제대로 설정하는 데 며칠이 걸린다. Clerk은 오후면 된다.
- 컴플라이언스 보장이 필요할 때 (SOC 2, HIPAA). 매니지드 프로바이더가 컴플라이언스 인증을 처리한다.
매니지드 프로바이더의 트레이드오프:
- 비용: Clerk은 월간 활성 사용자당 과금한다. 규모가 커지면 누적된다.
- 벤더 종속: Clerk이나 Auth0에서 이전하는 것은 고통스럽다. 사용자 테이블이 그들의 서버에 있다.
- 커스터마이징 제한: 인증 흐름이 특이하면 프로바이더의 의견과 싸우게 된다.
- 지연시간: 모든 인증 확인이 서드파티 API로 간다. 데이터베이스 세션이면 로컬 쿼리다.
피하는 것#
- 자체 암호화 구현. JWT에는
jose, Passkey에는@simplewebauthn/server, 비밀번호에는bcrypt또는argon2를 사용한다. 절대 직접 만들지 않는다. - SHA256으로 비밀번호 저장. bcrypt(비용 인수 12+) 또는 argon2id를 사용하라. SHA256은 너무 빠르다 — 공격자가 GPU로 초당 수십억 해시를 시도할 수 있다.
- 수명이 긴 액세스 토큰. 최대 15분. 더 긴 세션에는 리프레시 토큰 로테이션을 사용하라.
- 교차 서비스 검증에 대칭 시크릿. 여러 서비스가 토큰을 검증해야 하면 공개/개인 키 쌍의 RS256을 사용하라.
- 엔트로피가 불충분한 커스텀 세션 ID. 최소
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가 더 나은 선택이다.
보안 체크리스트#
인증 시스템을 배포하기 전에 확인하라:
- 비밀번호가 argon2id 또는 bcrypt(비용 12+)로 해시됨
- 로그인 후 세션이 재생성됨 (세션 고정 방지)
- 쿠키가
HttpOnly,Secure,SameSite=Lax또는Strict - JWT가 알고리즘을 명시적으로 지정 (
alg헤더를 절대 신뢰하지 마라) - 액세스 토큰이 15분 이하로 만료
- 재사용 감지가 포함된 리프레시 토큰 로테이션 구현
- OAuth state 파라미터가 검증됨 (CSRF 보호)
- 리다이렉트 URL이 허용 목록으로 유효성 검사됨
- 로그인, 회원가입, 비밀번호 재설정 엔드포인트에 레이트 리미팅 적용
- 실패한 로그인 시도가 IP와 함께 로그됨 (비밀번호는 안 됨)
- N회 실패 후 계정 잠금 (점진적 지연, 영구 잠금 아님)
- 비밀번호 재설정 토큰이 일회용이고 1시간 내 만료
- MFA 백업 코드가 비밀번호처럼 해시됨
- CORS가 알려진 오리진만 허용하도록 설정됨
-
Referrer-Policy헤더가 설정됨 - JWT 페이로드에 민감한 데이터 없음 (누구나 읽을 수 있다)
- WebAuthn 카운터가 검증되고 업데이트됨 (자격 증명 복제 방지)
이 목록이 완전한 것은 아니지만 프로덕션 시스템에서 가장 자주 본 취약점을 커버한다.
마치며#
인증은 환경이 계속 진화하지만 기본 원칙은 동일한 도메인 중 하나다: 신원을 검증하고, 최소한의 필요한 자격 증명을 발급하고, 모든 경계에서 권한을 확인하고, 침해를 가정하라.
2026년의 가장 큰 변화는 Passkey의 주류화다. 브라우저 지원이 보편적이고, 플랫폼 지원(iCloud 키체인, Google 비밀번호 관리자)이 UX를 매끄럽게 만들며, 보안 속성이 이전의 어떤 것보다 진정으로 우수하다. 새 애플리케이션을 만들고 있다면 Passkey를 기본 로그인 방법으로 만들고 비밀번호를 폴백으로 취급하라.
두 번째로 큰 변화는 자체 인증 구현이 정당화하기 어려워졌다는 것이다. Auth.js v5, Clerk, 유사한 솔루션들이 어려운 부분을 올바르게 처리한다. 커스텀으로 갈 유일한 이유는 요구사항이 기존 솔루션에 진정으로 맞지 않을 때이며 — 대부분의 개발자가 생각하는 것보다 그런 경우는 드물다.
무엇을 선택하든, 공격자처럼 인증을 테스트하라. 토큰 리플레이, 서명 위조, 접근해서는 안 되는 라우트 접근, 리다이렉트 URL 조작을 시도하라. 출시 전에 찾는 버그가 뉴스에 나오지 않는 버그다.