NextAuth PrismaAdapter + 암호화된 이메일: Blind Index 커스텀 Adapter

OAuth 추가 후 getUserByEmail이 null을 반환하는 조용한 실패를 추적해, Prisma Extension과 Blind Index를 활용한 커스텀 Adapter로 해결한 사례입니다.

NextAuth PrismaAdapter + 암호화된 이메일: Blind Index 커스텀 Adapter

1. 문제 상황

이미 운영 중인 B2B SaaS에 카카오·Google OAuth 로그인을 추가하던 중이었습니다. 지금까지는 자체 Credentials(이메일·비밀번호) 로그인만 있었고, 사용자의 이메일·이름·전화번호는 AES-256-GCM으로 암호화해 저장하고 있었습니다. 암호화된 필드를 검색하기 위해 emailHash, nameHash 같은 Blind Index 컬럼을 별도로 두고, unique 제약도 emailHash에 걸어둔 상태였습니다.

OAuth 프로바이더를 붙이는 건 NextAuth 기준으로 그리 어렵지 않은 작업입니다. providers 배열에 Kakao({...}), Google({...})을 넣고 .env에 클라이언트 시크릿을 채우면 끝날 것 같았습니다. 그런데 실제로 로그인 버튼을 눌러보니 이상한 증상이 나타났습니다.

증상

# OAuth 로그인 시도
1. 카카오 동의 화면 → OK
2. 콜백 URL로 리다이렉트 → OK
3. NextAuth 내부에서 에러 없이 종료
4. 하지만 새 세션이 생성되지 않음 → 다시 로그인 페이지로 돌아감

에러 로그에는 아무것도 찍히지 않았습니다. try/catch도, Sentry도 잡아내지 못했습니다. 세션이 만들어지지 않는데 예외는 없는, 그야말로 조용한 실패였습니다.

영향 범위

  • OAuth 로그인 100% 실패 — 카카오, Google 모두 동일
  • Credentials 로그인은 정상 — 기존 이메일·비밀번호 로그인은 영향 없음
  • 기존 사용자·신규 사용자 모두 영향 — 신규 가입뿐 아니라 이미 있는 사용자가 소셜 계정 연동을 시도해도 동일

실마리

NextAuth 디버그 모드(AUTH_DEBUG=true)를 켜보니 아래 로그가 흘러나왔습니다.

[auth][debug]: adapter_getUserByEmail
{ email: '[email protected]', result: null }

getUserByEmailnull을 반환하고 있었습니다. 기존 사용자가 DB에 멀쩡히 존재하는데도요. 여기서 범인이 특정됐습니다.


2. 원인 분석

2.1 @auth/prisma-adaptergetUserByEmail 구현

@auth/prisma-adapter 패키지 내부를 뜯어보면 getUserByEmail은 아주 단순합니다.

// @auth/prisma-adapter 내부 구현 (단순화)
getUserByEmail: (email) => {
  return p.user.findUnique({ where: { email } });
},

User.email 필드에 대해 findUnique를 호출합니다. 이게 정상적으로 동작하려면 두 가지 조건이 만족되어야 합니다.

  1. User 테이블에 email 컬럼이 있고 unique 제약이 걸려 있을 것
  2. DB에 저장된 email 값이 사용자가 입력한 이메일과 일치할 것

제 프로젝트는 1번은 만족했지만 2번이 무너져 있었습니다.

2.2 필드 레벨 암호화와 Blind Index

개인정보보호법·GDPR 때문에 민감 데이터는 DB에 암호화해서 저장하고 있었습니다. AES-256-GCM이 핵심이고, Prisma Client Extension으로 CRUD 시점에 자동 암호화·복호화가 걸립니다.

// lib/core/encryption.ts — 핵심 부분
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const ENCRYPTED_PREFIX = 'ENC:';

export function encrypt(plaintext: string): string {
  if (!plaintext) return plaintext;

  const key = getEncryptionKey();
  const iv = crypto.randomBytes(IV_LENGTH);
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv);

  let encrypted = cipher.update(plaintext, 'utf8');
  encrypted = Buffer.concat([encrypted, cipher.final()]);

  const authTag = cipher.getAuthTag();
  const combined = Buffer.concat([iv, authTag, encrypted]);

  return ENCRYPTED_PREFIX + combined.toString('base64');
}

핵심은 각 암호화마다 IV(Initialization Vector)가 랜덤이라는 점입니다. 같은 이메일 [email protected]을 두 번 암호화해도 매번 다른 ciphertext가 나옵니다. 이게 보안상 매우 중요한 성질이지만, 동시에 검색을 불가능하게 만드는 원흉이기도 합니다.

그럼 검색은 어떻게 할까요? Blind Index를 씁니다. HMAC-SHA256 기반의 deterministic 해시입니다.

// lib/core/encryption.ts — Blind Index 부분
export function blindIndex(value: string): string {
  if (!value) return '';

  const key = getEncryptionKey();
  const hmac = crypto.createHmac('sha256', key);
  hmac.update(value.toLowerCase().trim());

  return hmac.digest('hex');
}

/**
 * 이메일용 Blind Index — 소문자·trim 정규화
 */
export function emailBlindIndex(email: string): string {
  if (!email) return '';
  return blindIndex(email.toLowerCase().trim());
}

blindIndex는 같은 입력에 대해 항상 같은 해시를 반환합니다. 원본 복원은 불가능하지만(HMAC이므로), 해시 값을 DB에 저장해두고 WHERE emailHash = ?로 검색할 수 있습니다.

2.3 Prisma 스키마 구조

이 조합이 Prisma 스키마에 어떻게 반영되어 있는지 보면 충돌의 원인이 명확해집니다.

// prisma/schema.prisma
model User {
  id        String  @id @default(cuid())
  email     String                // 암호화됨 ('ENC:...' 형태로 저장)
  emailHash String  @unique       // Blind Index (검색·unique 제약용)
  name      String?               // 암호화됨
  password  String?               // bcrypt 해시 (암호화 대상 아님)
  role      Role    @default(VIEWER)
  companyId String?
  // ... 생략
}

email은 unique가 아니고 emailHash만 unique입니다. 이유는 둘 다 unique로 걸면 같은 이메일에 대해 매번 다른 ciphertext가 생성되므로 충돌 검사가 무의미하기 때문입니다. emailHash가 유일성의 source of truth 역할을 합니다.

2.4 Prisma Extension의 자동 암·복호화

Prisma Client Extension이 CRUD 시점에 끼어들어 암·복호화를 처리합니다.

// lib/core/db/index.ts — Extension 일부 (단순화)
const encryptedFields = {
  User: ['email', 'name', 'phone'],
  Employee: ['name', 'phone', 'residentNumber'],
  // ...
};

export const prisma = new PrismaClient().$extends({
  query: {
    $allModels: {
      async create({ model, args, query }) {
        const fields = encryptedFields[model] ?? [];
        for (const field of fields) {
          if (args.data[field]) {
            args.data[field] = encrypt(args.data[field]);
          }
        }
        // email이면 emailHash도 자동 생성
        if (fields.includes('email') && args.data.email) {
          args.data.emailHash = emailBlindIndex(originalEmail);
        }
        return query(args);
      },

      async findUnique({ args, query }) {
        const result = await query(args);
        // 조회 결과의 암호화 필드 자동 복호화
        return decryptResult(result);
      },

      // ... update, findMany 등
    },
  },
});

핵심 포인트: Extension은 create, update, findUnique 등의 쿼리 인수를 가로채 암호화를 걸고, 결과를 가로채 복호화를 겁니다. 하지만 WHERE email = "[email protected]" 같은 평문 기준 검색은 처리하지 않습니다.

왜일까요? where 절에 평문을 넣어도 DB에 저장된 값은 ENC:base64... 형태이므로 매칭이 안 됩니다. Extension이 where.emailwhere.emailHash로 바꿔치기 할 수도 있겠지만, 그건 "매직" 수준으로 코드를 숨기는 것이고 유지보수가 어렵습니다. 그래서 프로젝트 규칙은 **"암호화 필드는 검색하지 말고, 해시 필드로만 검색하라"**입니다.

2.5 충돌 시나리오 정리

이제 퍼즐이 다 맞춰졌습니다.

1. OAuth 콜백 → NextAuth가 getUserByEmail(email) 호출
2. PrismaAdapter 내부: prisma.user.findUnique({ where: { email } })
3. 평문 이메일을 where에 넣음
4. DB의 email 컬럼 값은 'ENC:xxx'로 저장되어 있음
5. 매칭 실패 → null 반환
6. NextAuth: "기존 사용자가 없으니 createUser 호출"
7. 하지만 emailHash는 이미 유니크하게 존재 → unique constraint 에러
8. NextAuth가 내부적으로 에러를 삼키고 세션 생성을 포기

조용히 실패한 이유는 7번입니다. createUser에서 예외가 발생하지만 OAuth 플로우 중간이라 NextAuth가 signIn 콜백을 false로 처리하며 사용자에게는 그냥 "로그인 실패"로 보이게 됩니다.


3. 해결 방법

3.1 옵션 비교

이 시점에서 가능한 해결 옵션은 세 가지였습니다.

옵션 A: @auth/prisma-adapter를 쓰지 않고 Adapter를 처음부터 구현

  • NextAuth Adapter는 createUser, getUser, getUserByEmail, getUserByAccount, updateUser, linkAccount, unlinkAccount, createSession, getSessionAndUser, updateSession, deleteSession, createVerificationToken, useVerificationToken... 15개 이상의 메서드를 구현해야 합니다.
  • 비용이 너무 큽니다. 포기.

옵션 B: PrismaAdapter 원본을 fork해서 고친 버전을 쓰기

  • 업스트림 업데이트를 따라가기 어렵습니다.
  • 고쳐야 할 곳이 getUserByEmail 한 군데뿐인데 과한 대응입니다.

옵션 C: PrismaAdapter를 spread로 가져와 getUserByEmail만 오버라이드

  • 가장 작은 변경.
  • createUser, linkAccount 등은 Prisma Extension이 이미 자동 암호화·emailHash 생성을 처리하므로 건드릴 필요 없음.
  • 유일하게 평문 기준 검색을 하는 getUserByEmail만 고치면 됨.

C로 결정했습니다.

3.2 Custom Adapter 구현

// lib/core/auth/adapter.ts
/**
 * Custom PrismaAdapter — 암호화 호환
 *
 * PrismaAdapter의 getUserByEmail이 findUnique({ where: { email } })를 사용하지만,
 * User.email은 암호화되어 있고 unique 제약은 emailHash에만 걸려 있어 기본 동작 불가.
 * emailHash(Blind Index) 기반 조회로 오버라이드.
 *
 * Prisma 확장(db/index.ts)이 create/read 시 자동 암·복호화하므로
 * createUser, linkAccount 등은 기본 동작 그대로 사용.
 */

import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/core/db";
import { emailBlindIndex } from "@/lib/core/encryption";
import type { Adapter } from "next-auth/adapters";

export function createCustomAdapter(): Adapter {
  const base = PrismaAdapter(prisma) as Adapter;

  return {
    ...base,

    getUserByEmail: async (email: string) => {
      if (!email) return null;
      const hash = emailBlindIndex(email.toLowerCase()); // ← 핵심 변경점
      const user = await prisma.user.findUnique({
        where: { emailHash: hash }, // ← email이 아닌 emailHash로 조회
      });
      return user as ReturnType<
        NonNullable<Adapter["getUserByEmail"]>
      > extends Promise<infer T>
        ? T
        : never;
    },
  };
}

코드는 32줄입니다. 의도를 설명하는 주석이 절반을 차지합니다. 실제 로직은 다음 네 단계입니다.

  1. @auth/prisma-adapter의 기본 Adapter를 가져옴
  2. spread로 모든 메서드를 그대로 복사
  3. getUserByEmail만 오버라이드: emailBlindIndex로 해시를 만들고 emailHash 필드로 검색
  4. 나머지는 기본 Adapter + Prisma Extension의 협업에 맡김

타입 단언이 좀 지저분한 건 Adapter 타입이 generic하고 리턴 타입이 conditional이기 때문입니다. any로 뚫는 것보다는 명시적으로 리턴 타입을 추론하도록 남겼습니다.

3.3 NextAuth 설정에 연결

기존 auth/index.ts에서 adapter: PrismaAdapter(prisma) 부분만 교체하면 됩니다.

// lib/core/auth/index.ts (Before)
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/core/db";

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(prisma), // ← 기본 adapter
  session: { strategy: "jwt" },
  providers: [
    // ... Credentials만 있던 시절
  ],
});
// lib/core/auth/index.ts (After)
import Kakao from "next-auth/providers/kakao";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
import { createCustomAdapter } from "./adapter"; // ← 커스텀 adapter import

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: createCustomAdapter(), // ← 교체
  session: { strategy: "jwt" },
  pages: { signIn: "/login" },
  providers: [
    Kakao({
      clientId: process.env.KAKAO_CLIENT_ID,
      clientSecret: process.env.KAKAO_CLIENT_SECRET,
      allowDangerousEmailAccountLinking: true, // ← 같은 이메일 = 같은 계정
    }),
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      allowDangerousEmailAccountLinking: true,
    }),
    Credentials({
      /* ... 기존 Credentials 설정 유지 ... */
    }),
  ],
  // ... callbacks
});

allowDangerousEmailAccountLinking: true는 같은 이메일로 가입한 카카오와 Google을 같은 사용자로 연결합니다. NextAuth가 "dangerous"라고 경고하는 이유는 악의적으로 이메일을 탈취한 공격자가 다른 프로바이더 계정도 가로챌 수 있기 때문입니다. 저희 프로젝트는 카카오·Google 모두 이메일 인증이 된 ID 프로바이더이므로 허용할 만합니다.

3.4 Credentials 로그인에도 동일 패턴 적용

getUserByEmail은 NextAuth 내부 플로우(OAuth 계정 연결, 비밀번호 재설정 등)에서 쓰입니다. 그러나 Credentials 프로바이더의 authorize 함수는 여러분이 직접 작성하는 코드이므로, 여기서도 emailBlindIndex를 써야 합니다.

Credentials({
  name: "credentials",
  credentials: {
    email: { label: "Email", type: "email" },
    password: { label: "Password", type: "password" },
  },
  async authorize(credentials) {
    const email = credentials?.email as string | undefined;

    if (!email || !credentials?.password) {
      logger.warn("[Auth] Login attempt with missing credentials");
      return null;
    }

    // ← 핵심: emailHash로 검색 (Blind Index)
    const emailHash = emailBlindIndex(email);
    const user = await prisma.user.findUnique({
      where: { emailHash },
      include: { company: true },
    });

    if (!user) {
      // 보안: 사용자 존재 여부를 노출하지 않되, 로그에는 기록
      logger.warn("[Auth] Login attempt for non-existent email", { emailHash });
      return null;
    }

    if (!user.password) {
      // OAuth 전용 계정으로 credentials 로그인 시도
      logger.warn("[Auth] Credentials login for OAuth-only account", {
        userId: user.id,
      });
      return null;
    }

    const isPasswordValid = await bcrypt.compare(
      credentials.password as string,
      user.password
    );

    if (!isPasswordValid) {
      logger.warn("[Auth] Invalid password attempt", { userId: user.id });
      return null;
    }

    return {
      id: user.id,
      email: user.email, // Prisma Extension이 이미 복호화된 상태
      name: user.name,
      role: user.role,
      companyId: user.companyId,
      // ...
    };
  },
}),

한 가지 재미있는 부분은 !user.password 체크입니다. OAuth로만 가입한 사용자는 password 필드가 null입니다. 이 사용자가 실수로(혹은 의도적으로) Credentials로 로그인을 시도하면 bcrypt.compare(null, ...)이 예외를 던지거나, 더 나쁘게는 빈 문자열 비교가 통과해버릴 수 있습니다. null 체크로 명시적으로 거부해 두면 안전합니다.

3.5 OAuth 콜백에서 이메일 없는 계정 거부

카카오는 사용자가 이메일 제공 동의를 거부할 수 있습니다. 이 경우 profile.emailundefined로 들어옵니다. 그대로 진행하면 getUserByEmail이 빈 이메일로 호출되어 null을 반환하고, 빈 이메일로 createUser가 호출되면 emailHash''(빈 문자열 해시)가 되어 충돌이 발생합니다.

callbacks: {
  async signIn({ account, profile }) {
    // OAuth: 이메일 없는 사용자 거부
    if (account?.type === 'oidc' || account?.type === 'oauth') {
      if (!profile?.email) {
        logger.warn('[Auth] OAuth sign-in rejected: no email', {
          provider: account.provider,
        });
        return false;
      }
    }
    return true;
  },
  // ... 나머지 콜백
},

signIn 콜백에서 false를 반환하면 NextAuth가 로그인 플로우를 중단합니다. 사용자에게는 /login?error=AccessDenied로 리다이렉트됩니다.


4. 확장: 온보딩 미들웨어

커스텀 Adapter 하나로 OAuth 로그인은 동작하지만, 또 다른 문제가 남았습니다. 기존 프로젝트는 User가 반드시 Company에 속해 있다는 가정으로 돌아가고 있었습니다. Credentials 가입 플로우는 회사 정보 수집 단계가 있었지만, OAuth 가입은 카카오·Google이 이메일만 주고 끝납니다. 즉, companyId = null 상태의 User가 생길 수 있는 겁니다.

companyId가 없는 사용자가 /dashboard에 접근하면 어떤 쿼리든 where: { companyId: null }이 되어서 데이터를 볼 수 없고, 잘 안 짠 코드는 null에 의해 크래시합니다. 해결책은 "로그인은 됐지만 아직 회사 정보가 없는 사용자"를 미들웨어에서 온보딩 페이지로 보내는 겁니다.

// src/middleware.ts
export default auth((req) => {
  const hasValidSession = !!(req.auth?.user?.id && req.auth?.user?.role);
  const isLoggedIn = hasValidSession;
  const userRole = req.auth?.user?.role;
  const isPlatformAdmin = userRole === "PLATFORM_ADMIN";
  const { pathname } = req.nextUrl;

  // 로그인 필요한 경로
  const protectedPaths = [
    "/dashboard",
    "/schedules",
    "/reports",
    "/members",
    "/settings",
    "/platform",
  ];
  const isProtectedPath = protectedPaths.some((p) => pathname.startsWith(p));

  // ... (비로그인 → /login 리다이렉트 부분 생략)

  // ↓ 온보딩 흐름
  const needsOnboarding =
    isLoggedIn && !req.auth?.user?.companyId && !isPlatformAdmin;
  const isOnboardPath = pathname === "/onboard";

  if (isLoggedIn && needsOnboarding) {
    if (isProtectedPath && !isOnboardPath) {
      return NextResponse.redirect(new URL("/onboard", req.nextUrl));
    }
  }

  // 온보딩 완료 사용자가 /onboard 접근 시 대시보드로
  if (isLoggedIn && !needsOnboarding && isOnboardPath) {
    return NextResponse.redirect(
      new URL(getDefaultRedirectPath(userRole), req.nextUrl)
    );
  }

  return NextResponse.next();
});

세 가지 경우를 다룹니다.

  1. 비로그인/login으로 (기존 로직)
  2. 로그인했지만 회사 없음 (companyId === null) → /onboard
  3. 이미 온보딩 완료한 사용자가 /onboard에 다시 접근 → 대시보드로

PLATFORM_ADMIN은 회사에 소속되지 않는 역할이라 예외 처리했습니다.


5. 핵심 개념 정리

개념 설명 관련 코드
AES-256-GCM 인증된 대칭 암호화. IV가 매번 랜덤이라 같은 평문도 매번 다른 ciphertext 생성 encrypt()
Blind Index HMAC-SHA256 기반 deterministic 해시. 검색용 blindIndex(), emailBlindIndex()
Prisma Client Extension $extends({ query: {...} })로 CRUD 쿼리에 가로채기 로직 주입 lib/core/db/index.ts
NextAuth Adapter DB 연동 인터페이스. @auth/prisma-adapter가 기본 구현 제공 createCustomAdapter()
getUserByEmail OAuth 콜백에서 기존 사용자 찾기 위해 호출됨. 평문 email로 findUnique Adapter 메서드
allowDangerousEmailAccountLinking 같은 이메일의 여러 OAuth 프로바이더를 같은 User로 연결 Provider 옵션

왜 32줄로 끝났는가?

  • createUser를 건드리지 않아도 되는 이유: Prisma Extension이 args.data.email을 자동 암호화하고, 동시에 args.data.emailHash를 자동 생성해서 Prisma에 넘깁니다. 결과적으로 Prisma create 호출은 암호화된 email + emailHash가 모두 채워진 상태로 실행됩니다.
  • linkAccount, updateUser, deleteUser 등도 마찬가지. 이들은 email을 검색 키로 쓰지 않고 idproviderAccountId로 조회하므로 Blind Index 문제와 무관합니다.
  • 유일하게 평문 email 기준 검색을 하는 메서드가 getUserByEmail 하나였기 때문에 이 한 메서드만 오버라이드하면 충분했습니다.

6. 베스트 프랙티스

필드 레벨 암호화 + 서드파티 Adapter를 쓸 때 체크리스트

  • [ ] Adapter 소스 코드를 직접 읽기. node_modules/@auth/prisma-adapter/src/index.ts를 열어보고 email, name 같은 평문 기준 검색이 있는 메서드를 식별합니다.
  • [ ] unique 제약은 해시 필드에만. 암호화 필드에 unique를 걸면 IV 랜덤성 때문에 의미가 없고, DB가 매번 다른 값을 unique로 처리해 검증이 무용지물이 됩니다.
  • [ ] Blind Index는 프로젝트 전역에서 일관된 해시 함수를 써야 합니다. 대소문자·공백 정규화 규칙을 정해두고(예: toLowerCase().trim()), 모든 코드에서 같은 함수를 거치도록 강제합니다.
  • [ ] Extension이 자동으로 emailHash를 계산하도록 연결. 수동으로 매번 blindIndex 호출하면 빠뜨리기 쉽습니다.
  • [ ] OAuth 콜백에서 이메일 null 체크. 카카오·애플처럼 이메일을 선택적으로 제공하는 프로바이더가 있으면 signIn 콜백에서 거부합니다.
  • [ ] Credentials 프로바이더의 authorize에서도 동일하게 해시로 검색. NextAuth가 알아서 해주지 않습니다.

보안 관점

  • Blind Index의 한계: deterministic 해시이므로 emailHash만 봐도 "같은 이메일인지"는 알 수 있습니다. 공격자가 해시 rainbow table을 만들 수 있을까? HMAC 키가 ENCRYPTION_KEY와 동일하므로 키가 새지 않으면 불가능합니다. 키가 샌 순간 암호화 데이터 전체가 위험해지므로 키 관리가 전부입니다.
  • 로그에는 해시만. logger.warn("login failed", { emailHash })처럼 해시는 남기되 평문 이메일은 절대 로그에 남기지 않습니다. 해시는 같은 이메일에 대한 반복 시도를 추적하는 데 충분합니다.
  • !user.password 체크. OAuth 전용 사용자가 Credentials 로그인을 시도할 때 반드시 거부해야 합니다. 안 그러면 bcrypt.compare(입력, null)가 false를 반환하든 예외를 던지든, 분기가 애매해집니다.

7. FAQ

Q1. @auth/prisma-adapter를 완전히 버리고 직접 구현하는 게 더 깔끔하지 않나요?

A. Adapter 메서드가 15개 이상이고, 각 메서드가 NextAuth 내부 플로우와 결합되어 있습니다. 한 메서드를 잘못 구현하면 session·verification token·account linking 어디서든 미묘한 버그가 발생합니다. 공식 Adapter를 상속해서 필요한 부분만 오버라이드하는 게 테스트 부담도 적고 업스트림 업데이트 수용도 쉽습니다.

Q2. getUserByEmail 말고 다른 Adapter 메서드도 문제가 될 수 있나요?

A. 실제로 평문 필드를 where로 쓰는 메서드는 getUserByEmail 하나뿐이었습니다. 나머지는 모두 id, providerAccountId, sessionToken 등 암호화 대상이 아닌 필드로 조회합니다. 다만 프로젝트 초기에 @auth/prisma-adapter의 모든 메서드를 훑어보고 암호화 필드를 검색 키로 쓰는지 확인하는 게 안전합니다. 패키지가 업데이트되면서 새로운 메서드가 생길 수도 있으니까요.

Q3. 기존 사용자의 password 필드를 암호화하지 않는 이유는 뭔가요?

A. bcrypt 해시 자체가 이미 단방향 해시이고, 같은 비밀번호에 대해 매번 다른 해시를 만들어냅니다(salt). 여기에 대칭 암호화를 추가로 거는 건 복구성을 떨어뜨리기만 하고 보안 이득은 없습니다. "해시의 해시"는 원본 복원은 어렵게 만들지만, 비밀번호 검증 로직(bcrypt.compare)을 쓸 수 없게 만들어버려서 운영상 골치만 아파집니다.

Q4. allowDangerousEmailAccountLinking: true가 정말 "dangerous"한가요?

A. NextAuth 문서가 경고하는 시나리오는 이렇습니다. 공격자가 피해자의 이메일을 (어떤 경로로든) 입수해 공격자 소유의 OAuth 계정을 그 이메일로 만들고, 피해자 프로젝트에 해당 이메일로 로그인해 피해자 계정을 탈취하는 경우입니다. 카카오·Google은 가입 시 이메일 인증을 요구하므로 공격자가 피해자 이메일을 등록하는 것 자체가 어렵습니다. 반면 이메일 검증 없이 아무 주소나 받는 소셜 프로바이더(예: 일부 이메일 기반 매직링크 서비스)를 쓴다면 이 옵션을 켜면 안 됩니다.

Q5. Prisma Extension을 안 쓰고 매번 수동으로 encrypt/decrypt를 호출하면 안 되나요?

A. 가능은 하지만 매우 위험합니다. 코드 100군데 중 한 곳이라도 암호화를 빠뜨리면 평문이 DB에 들어가고, 이후 복호화 시점에 "ENC: 접두사가 없으면 그대로 반환"하는 방어 코드 때문에 조용히 평문이 돌아다닙니다. Extension으로 중앙집중화하면 "암호화 대상 필드 목록" 한 곳만 관리하면 되고, 실수할 여지가 대폭 줄어듭니다.


8. 참고 자료


9. 다음 단계

OAuth가 동작하기 시작한 다음에 마주친 두 번째 숙제는 JWT 콜백 분기였습니다. Credentials 사용자와 OAuth 사용자는 authorize·adapter로 들어오는 루트가 다르고, token에 담아야 할 데이터도 조금씩 다릅니다. 다음 글에서 NextAuth v5의 jwt 콜백을 OAuth/Credentials 두 경로로 깔끔하게 분기하는 방법을 다루겠습니다.

시리즈 목차:

  1. NextAuth PrismaAdapter + 암호화된 이메일: Blind Index 커스텀 Adapter ← 현재 글
  2. NextAuth v5 JWT 콜백: OAuth와 Credentials 사용자 분기 처리
  3. 통합 온보딩 페이지: OAuth와 이메일 가입을 하나의 플로우로