비밀번호 없는 세상: Next.js와 SimpleWebAuthn으로 패스키(Passkey) 완벽 구현하기

곽태욱·2026년 1월 29일

비밀번호는 오랫동안 우리를 괴롭혀왔습니다. 짧으면 불안하고, 길면 기억하기 힘들죠. 특수문자와 숫자를 섞으라는 규칙은 사용자 경험을 해치는 주범이었습니다. 하지만 이제 패스키(Passkey)의 등장으로 우리는 비밀번호 없는 세상으로 나아갈 수 있게 되었습니다.

이번 글에서는 Next.js(App Router) 환경에서 @simplewebauthn 라이브러리를 사용하여 패스키 등록부터 로그인까지 완벽하게 구현하는 방법을 공유합니다. 특히, 실제 서비스 수준에서 고려해야 할 DB 설계, 보안(User Enumeration 방지), 그리고 UX 처리까지 다뤄보겠습니다.

1. 패스키(WebAuthn)란?

패스키는 FIDO2/WebAuthn 표준을 기반으로 한 비밀번호 없는 로그인 방식입니다.
사용자는 지문, 얼굴 인식(FaceID), PIN 등을 사용하여 인증하고, 서버에는 비밀번호 대신 공개키(Public Key)만 저장됩니다. 개인키(Private Key)는 사용자의 기기(Secure Enclave 등)에 안전하게 보관되어 유출될 위험이 없습니다.

2. 데이터베이스 설계

패스키 정보를 저장하기 위한 테이블을 설계해야 합니다. drizzle-orm을 사용한 PostgreSQL 스키마 예시입니다.

import { bigint, index, integer, pgTable, smallint, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core'
import { userTable } from './user'

export const credentialTable = pgTable(
  'credential',
  {
    // 패스키의 고유 식별자 (Base64URL encoded)
    credentialId: varchar({ length: 256 }).notNull(),
    
    // 공개키 (검증용)
    publicKey: text('public_key').notNull(),
    
    // 리플레이 공격 방지용 카운터
    counter: integer().notNull().default(0),
    
    // 인증 수단 (usb, nfc, internal 등)
    transports: text().array(),
    
    // 기기 타입 (platform: 내장 인증, cross-platform: 보안키)
    deviceType: smallint('device_type').notNull(),
    
    userId: bigint('user_id', { mode: 'number' })
      .references(() => userTable.id, { onDelete: 'cascade' })
      .notNull(),
      
    lastUsedAt: timestamp('last_used_at', { precision: 3, withTimezone: true }),
  },
  (table) => [
    unique('idx_credential_credential_id').on(table.credentialId),
  ],
)

핵심 포인트:

  • credentialId: WebAuthn이 생성한 ID를 저장합니다.
  • publicKey: 서명을 검증할 공개키를 저장합니다.
  • counter: 인증할 때마다 증가하는 값으로, 복제된 인증 장치 사용을 탐지하는 데 쓰입니다.

3. 패스키 등록하기

패스키 등록은 서버에서 옵션 생성 -> 클라이언트에서 서명 -> 서버에서 검증의 3단계로 이루어집니다.

3-1. 서버: 등록 옵션 생성

먼저 서버에서 챌린지(Challenge)를 포함한 등록 옵션을 생성하여 클라이언트에 전달합니다.

// ... imports

export async function getRegistrationOptions() {
  const userId = await validateUserIdFromCookie()
  // ... 사용자 조회 로직

  const options = await generateRegistrationOptions({
    rpName: '서비스 이름',
    rpID: 'example.com', // 현재 도메인
    userName: user.loginId,
    userID: new Uint8Array(Buffer.from(user.userId.toString())),
    userDisplayName: user.nickname,
    attestationType: 'direct',
    // 사용자의 생체 인증 등을 강제
    authenticatorSelection: {
      userVerification: 'required',
      residentKey: 'required',
    },
  })

  // 챌린지를 Redis 등에 잠시 저장 (검증 시 필요)
  await storeChallenge(user.userId, ChallengeType.REGISTRATION, options.challenge)

  return ok(options)
}

3-2. 클라이언트: 브라우저 API 호출

클라이언트는 서버에서 받은 옵션으로 브라우저의 WebAuthn API를 호출합니다.

import { startRegistration } from '@simplewebauthn/browser'

// ...

async function handleRegisterPasskey() {
  try {
    // 1. 서버에서 옵션 가져오기
    const optionsResult = await getRegistrationOptions()
    if (!optionsResult.ok) throw new Error(optionsResult.error)

    // 2. 브라우저 패스키 등록 창 띄우기
    const registrationResponse = await startRegistration({ 
      optionsJSON: optionsResult.data 
    })

    // 3. 서버로 서명된 데이터 전송하여 최종 검증
    dispatchAction(registrationResponse)
  } catch (error) {
    if (error.name === 'NotAllowedError') {
      toast.info('패스키 등록이 취소됐어요')
    } else if (error.name === 'NotSupportedError') {
      toast.warning('이 브라우저는 패스키를 지원하지 않아요')
    }
    // ... 에러 핸들링
  }
}

3-3. 서버: 등록 검증 및 저장

마지막으로 서버는 클라이언트가 보낸 응답을 검증하고 DB에 저장합니다.

export async function verifyRegistration(body: RegistrationResponseJSON) {
  // ... 
  
  // 저장해둔 챌린지 가져오기
  const challenge = await getAndDeleteChallenge(userId, ChallengeType.REGISTRATION)

  const { verified, registrationInfo } = await verifyRegistrationResponse({
    response: body,
    expectedChallenge: challenge,
    expectedOrigin: 'https://example.com',
    expectedRPID: 'example.com',
  })

  if (verified && registrationInfo) {
    const { credential } = registrationInfo
    
    // DB에 패스키 정보 저장
    await db.insert(credentialTable).values({
      credentialId: credential.id,
      publicKey: Buffer.from(credential.publicKey).toString('base64'),
      counter: credential.counter,
      // ...
    })
  }
}

4. 패스키로 로그인하기

로그인 과정도 등록과 유사하지만, 사용자 아이디 탐색 공격(User Enumeration Attack)을 방지하는 것이 매우 중요합니다.

4-1. 보안: 가짜 자격 증명 생성

공격자가 특정 ID가 패스키를 사용하는지 확인하지 못하도록, 존재하지 않는 ID에 대해서도 가짜 인증 옵션을 반환해야 합니다.

// 로그인 아이디로 사용자를 찾음
const userWithCredentials = await db.select(/* ... */).where(eq(userTable.loginId, loginId))

// 사용자가 있으면 실제 등록된 패스키 ID 목록 사용
// 사용자가 없으면 가짜 패스키 ID 목록 생성!
const allowCredentials = userId
  ? userWithCredentials.map(c => ({ id: c.credentialId, ... }))
  : generateFakeCredentials(loginId)

const options = await generateAuthenticationOptions({
  allowCredentials,
  // ...
})

generateFakeCredentials 함수는 입력받은 loginId를 시드(seed)로 사용하여 항상 동일한 가짜 ID들을 생성해냅니다. 이렇게 하면 공격자는 타이밍 공격이나 응답 값으로 ID 존재 여부를 유추할 수 없습니다.

4-2. 서버: 로그인 검증

검증이 성공하면 세션을 발급하고, 패스키의 counter를 업데이트합니다. 카운터가 이전보다 작거나 같으면 복제된 패스키를 이용한 공격일 수 있으므로 차단해야 합니다(라이브러리가 자동으로 체크해줍니다).

const { verified, authenticationInfo } = await verifyAuthenticationResponse({
  // ...
  credential: {
    id: credential.credentialId,
    publicKey: new Uint8Array(Buffer.from(credential.publicKey, 'base64')),
    counter: credential.counter,
  },
})

if (verified) {
  // 카운터 업데이트 및 로그인 처리
  await db.update(credentialTable)
    .set({ counter: authenticationInfo.newCounter, lastUsedAt: new Date() })
    .where(/* ... */)
    
  // 세션 쿠키 발급
  // ...
}

5. 마치며

이렇게 Next.js와 SimpleWebAuthn을 사용하여 보안과 사용성을 모두 잡은 패스키 인증을 구현해보았습니다.

구현 시 놓치기 쉬운 포인트:
1. 브라우저 호환성 체크: 모든 브라우저가 패스키를 지원하지는 않으므로 우아한 폴백(Fallback)이나 안내가 필요합니다.
2. 도메인 일치: 로컬 개발(localhost)과 프로덕션 도메인이 다르므로 rpIDexpectedOrigin을 환경변수로 관리해야 합니다.
3. User Enumeration 방지: 보안을 위해 반드시 가짜 자격 증명 로직을 넣으세요.

패스키는 이제 선택이 아닌 필수가 되어가고 있습니다. 여러분의 서비스에도 패스키를 도입하여 비밀번호 없는 세상을 만들어보세요!

profile
이유와 방법을 알려주는 메모장 겸 블로그 (Frontend, AI, 경제, 책)

0개의 댓글