비밀번호를 아무리 복잡하게 만들어도, 피싱 사이트에 입력하는 순간 의미가 없어지지 않을까요?

비밀번호는 태생적으로 "공유되는 비밀"입니다. 서버와 사용자가 같은 값을 알고 있어야 하고, 매번 네트워크를 통해 전달됩니다. 아무리 해싱을 잘 해도, 사용자가 피싱 사이트에 비밀번호를 직접 입력하면 방어할 수 없습니다. Passkeys 는 이 근본적인 문제를 해결하려는 시도입니다.

비밀번호의 한계 — 왜 없애야 하는가

비밀번호 기반 인증이 갖는 구조적 문제를 정리해보면 이렇습니다.

  • Credential Stuffing: 다른 사이트에서 유출된 아이디/비밀번호 조합을 무작위로 대입하는 공격입니다. 사용자 대부분이 비밀번호를 재사용하기 때문에 생각보다 성공률이 높습니다.
  • ** 피싱(Phishing)**: 정교하게 만든 가짜 로그인 페이지로 비밀번호를 탈취합니다. MFA(문자 인증 등)도 실시간 중계 공격으로 우회 가능합니다.
  • ** 서버 유출 **: 아무리 BCrypt로 해싱해도 DB가 유출되면 약한 비밀번호는 크래킹됩니다.

비밀번호의 가장 큰 문제는 "알고 있는 것"이라는 점입니다. 알고 있는 것은 빼앗길 수 있고, 복제될 수 있고, 피싱될 수 있습니다. Passkeys는 "가지고 있는 것 + 본인인 것"으로 인증 방식 자체를 바꿉니다.

FIDO2와 WebAuthn — 핵심 개념

FIDO2는 FIDO Alliance 가 만든 비밀번호 없는 인증 표준이고, 두 가지 스펙으로 구성됩니다.

  • WebAuthn (Web Authentication): W3C 표준. 브라우저와 서버 사이의 API를 정의합니다.
  • CTAP (Client to Authenticator Protocol): 브라우저와 인증 장치(지문 센서, 보안 키) 사이의 프로토콜입니다.

개발자 입장에서 중요한 건 WebAuthn입니다. CTAP은 OS와 하드웨어가 알아서 처리합니다.

등장인물 정리

용어역할예시
Relying Party (RP)인증을 요청하는 서버Spring Security 애플리케이션
Authenticator키 생성/서명을 수행하는 장치Touch ID, Windows Hello, YubiKey
ClientRP와 Authenticator를 중개웹 브라우저
Credential공개키 + 메타데이터서버에 저장되는 등록 정보

공개키 암호화 기반 동작 원리

WebAuthn의 핵심은 의외로 단순합니다.

  1. 등록 시 디바이스가 ** 공개키/개인키 쌍 **을 생성합니다.
  2. ** 공개키 **만 서버에 저장합니다. 개인키는 디바이스를 절대 떠나지 않습니다.
  3. 인증 시 서버가 ** 챌린지(랜덤 값)** 를 보냅니다.
  4. 디바이스가 개인키로 챌린지를 ** 서명 **해서 돌려줍니다.
  5. 서버가 공개키로 서명을 ** 검증 **합니다.

서버에는 공개키만 있으므로, DB가 통째로 유출되어도 공격자는 인증할 수 없습니다. 이것이 비밀번호와의 근본적인 차이입니다.

Registration Flow (Attestation)

사용자가 Passkey를 처음 등록하는 과정입니다.

PLAINTEXT
[사용자] → [브라우저] → [서버: 챌린지 + RP 정보 생성]

[브라우저] → navigator.credentials.create() 호출

[Authenticator] → 생체인증/PIN 확인 → 키 쌍 생성

[브라우저] → [서버: 공개키 + attestation 데이터 전송]

[서버] → 검증 후 공개키 저장

핵심은 navigator.credentials.create() API입니다. 브라우저가 Authenticator에게 키 생성을 위임하고, 생성된 공개키와 attestation(출처 증명)을 서버로 보냅니다.

Authentication Flow (Assertion)

등록된 Passkey로 로그인하는 과정입니다.

PLAINTEXT
[사용자] → [브라우저] → [서버: 챌린지 생성 + 허용된 credential ID 목록]

[브라우저] → navigator.credentials.get() 호출

[Authenticator] → 생체인증/PIN 확인 → 챌린지를 개인키로 서명

[브라우저] → [서버: 서명된 assertion 데이터 전송]

[서버] → 공개키로 서명 검증 → 인증 성공

서버가 보내는 챌린지는 매번 달라지는 랜덤 값이기 때문에, 이전 인증 데이터를 캡처해서 재사용하는 리플레이 공격도 방어됩니다.

Passkeys란 — WebAuthn의 진화

Passkeys는 WebAuthn 위에 ** 동기화(Synced Credentials)** 를 추가한 개념입니다.

기존 WebAuthn의 문제는 디바이스 분실 시 키도 함께 잃어버린다는 점이었습니다. Passkeys는 이 문제를 해결합니다.

  • Platform Authenticator: Touch ID, Face ID, Windows Hello 등 디바이스 내장 인증
  • Synced Passkeys: iCloud Keychain, Google Password Manager를 통해 여러 기기에서 동기화
  • Device-bound Passkeys: 보안 키(YubiKey 등)처럼 특정 하드웨어에 묶인 키

공부하다 보니 이 부분에서 많이 헷갈렸는데, Passkeys는 새로운 프로토콜이 아니라 WebAuthn의 사용성을 개선한 확장이라고 이해하면 됩니다.

Passkeys vs 기존 MFA 비교

항목비밀번호 + SMS OTP비밀번호 + TOTPPasskeys
피싱 방어취약 (실시간 중계 가능)취약 (실시간 중계 가능)강함 (origin 바인딩)
서버 유출 영향비밀번호 크래킹 가능비밀번호 크래킹 가능공개키만 유출, 무해
사용자 경험코드 입력 필요앱 열어서 코드 확인생체인증 한 번
디바이스 분실비밀번호로 복구백업 코드 필요클라우드 동기화로 복구
Credential Stuffing취약부분 방어불가능

Passkeys가 피싱에 강한 이유는 origin 바인딩 때문입니다. 브라우저가 현재 도메인 정보를 자동으로 포함하므로, 가짜 도메인에서는 Credential이 아예 동작하지 않습니다.

Spring Security 7.0의 WebAuthn 지원

Spring Security 7.0부터 webAuthn() DSL로 Passkeys를 지원합니다. 설정 방법을 살펴보겠습니다.

SecurityFilterChain 설정

JAVA
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .formLogin(withDefaults())  // 폴백용 폼 로그인
            .webAuthn(webAuthn -> webAuthn
                .rpName("My Application")       // Relying Party 이름 (사용자에게 표시)
                .rpId("example.com")            // Relying Party ID (도메인)
                .allowedOrigins("https://example.com")  // 허용 origin
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/webauthn/**").permitAll()
                .anyRequest().authenticated()
            );

        return http.build();
    }
}

rpId는 도메인과 일치해야 합니다. example.com으로 설정하면 sub.example.com에서도 사용 가능하지만, 다른 도메인에서는 동작하지 않습니다.

PublicKeyCredentialUserEntityRepository

WebAuthn에서 사용자의 Credential을 관리하는 핵심 인터페이스입니다.

JAVA
// 사용자 엔티티 정보를 관리하는 리포지토리
public interface PublicKeyCredentialUserEntityRepository {

    // 사용자 이름으로 UserEntity 조회
    PublicKeyCredentialUserEntity findByUsername(String username);

    // UserEntity 저장
    void save(PublicKeyCredentialUserEntity userEntity);

    // UserEntity 삭제
    void delete(byte[] id);
}

Spring Security는 기본적으로 인메모리 구현체를 제공합니다. 프로덕션에서는 DB 기반 구현체를 만들어야 합니다.

JAVA
@Component
public class JpaCredentialUserEntityRepository
        implements PublicKeyCredentialUserEntityRepository {

    private final UserCredentialJpaRepository jpaRepository;

    public JpaCredentialUserEntityRepository(UserCredentialJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public PublicKeyCredentialUserEntity findByUsername(String username) {
        // DB에서 사용자의 credential 엔티티 조회
        return jpaRepository.findByUsername(username)
                .map(this::toPublicKeyEntity)
                .orElse(null);
    }

    @Override
    public void save(PublicKeyCredentialUserEntity userEntity) {
        // 신규 credential 저장 또는 기존 credential 업데이트
        UserCredentialEntity entity = toJpaEntity(userEntity);
        jpaRepository.save(entity);
    }

    @Override
    public void delete(byte[] id) {
        // credential 삭제 (사용자가 Passkey를 해제할 때)
        jpaRepository.deleteByCredentialId(id);
    }

    // ... 변환 메서드 생략
}

UserCredentialRepository

개별 Credential(공개키) 정보를 저장하는 리포지토리도 필요합니다.

JAVA
// 한 사용자가 여러 Credential을 가질 수 있으므로 1:N 관계
@Bean
public UserCredentialRepository userCredentialRepository(DataSource dataSource) {
    // JDBC 기반 구현체 — 테이블을 자동 생성하지 않으므로 스키마 관리 필요
    return new JdbcUserCredentialRepository(
        new JdbcTemplate(dataSource)
    );
}

클라이언트 사이드 JavaScript 연동

Spring Security가 자동으로 등록/인증 엔드포인트를 제공하지만, 프론트엔드에서 WebAuthn API를 호출해야 합니다.

Passkey 등록

JAVASCRIPT
async function registerPasskey() {
    // 1. 서버에서 등록 옵션 요청
    const optionsResponse = await fetch('/webauthn/register/options', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' }
    });
    const options = await optionsResponse.json();

    // 2. ArrayBuffer로 변환 (서버에서 Base64로 인코딩된 값)
    options.challenge = base64ToArrayBuffer(options.challenge);
    options.user.id = base64ToArrayBuffer(options.user.id);

    // 3. 브라우저의 WebAuthn API 호출 → 생체인증 프롬프트 표시
    const credential = await navigator.credentials.create({
        publicKey: options
    });

    // 4. 생성된 credential을 서버에 전송
    const registrationResponse = await fetch('/webauthn/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            id: credential.id,
            rawId: arrayBufferToBase64(credential.rawId),
            response: {
                // attestation 데이터 — 공개키가 포함되어 있음
                attestationObject: arrayBufferToBase64(
                    credential.response.attestationObject
                ),
                clientDataJSON: arrayBufferToBase64(
                    credential.response.clientDataJSON
                )
            },
            type: credential.type
        })
    });
}

Passkey 로그인

JAVASCRIPT
async function loginWithPasskey() {
    // 1. 서버에서 인증 옵션 요청
    const optionsResponse = await fetch('/webauthn/authenticate/options', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' }
    });
    const options = await optionsResponse.json();

    // 2. 챌린지와 허용된 credential ID를 ArrayBuffer로 변환
    options.challenge = base64ToArrayBuffer(options.challenge);
    options.allowCredentials = options.allowCredentials.map(cred => ({
        ...cred,
        id: base64ToArrayBuffer(cred.id)
    }));

    // 3. 브라우저의 WebAuthn API 호출 → 생체인증 프롬프트 표시
    const assertion = await navigator.credentials.get({
        publicKey: options
    });

    // 4. 서명된 assertion을 서버에 전송
    const authResponse = await fetch('/webauthn/authenticate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            id: assertion.id,
            rawId: arrayBufferToBase64(assertion.rawId),
            response: {
                // 개인키로 서명된 데이터
                authenticatorData: arrayBufferToBase64(
                    assertion.response.authenticatorData
                ),
                clientDataJSON: arrayBufferToBase64(
                    assertion.response.clientDataJSON
                ),
                signature: arrayBufferToBase64(
                    assertion.response.signature
                )
            },
            type: assertion.type
        })
    });
}

navigator.credentials.create()navigator.credentials.get()은 사용자 제스처(클릭 등)가 선행되어야 호출할 수 있습니다. 페이지 로드 시 자동으로 호출하면 브라우저가 차단합니다.

Base64 유틸리티

WebAuthn API는 ArrayBuffer를 사용하지만, JSON 전송 시에는 Base64 인코딩이 필요합니다.

JAVASCRIPT
// ArrayBuffer → Base64URL 변환
function arrayBufferToBase64(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = '';
    bytes.forEach(b => binary += String.fromCharCode(b));
    return btoa(binary)
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
}

// Base64URL → ArrayBuffer 변환
function base64ToArrayBuffer(base64) {
    const padded = base64.replace(/-/g, '+').replace(/_/g, '/');
    const binary = atob(padded);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) {
        bytes[i] = binary.charCodeAt(i);
    }
    return bytes.buffer;
}

디바이스 호환성과 폴백 전략

2026년 기준으로 Passkeys 지원 현황입니다.

  • iOS/macOS: Safari, Chrome 지원. iCloud Keychain으로 동기화
  • Android: Chrome, Samsung Internet 지원. Google Password Manager 동기화
  • Windows: Edge, Chrome 지원. Windows Hello 또는 휴대폰 QR 코드
  • Linux: Chrome 지원. 외부 보안 키 또는 휴대폰 QR 코드

아직 모든 사용자가 Passkeys를 사용할 수 있는 건 아닙니다. 폴백 전략이 필수입니다.

JAVA
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // 1순위: Passkey 인증
        .webAuthn(webAuthn -> webAuthn
            .rpName("My Application")
            .rpId("example.com")
            .allowedOrigins("https://example.com")
        )
        // 2순위: 폴백으로 폼 로그인 유지
        .formLogin(form -> form
            .loginPage("/login")
            .permitAll()
        )
        // 3순위: OAuth2 소셜 로그인도 병행 가능
        .oauth2Login(oauth -> oauth
            .loginPage("/login")
        );

    return http.build();
}

실무에서는 Passkey를 "추가 인증 수단"으로 먼저 제공하고, 점진적으로 비밀번호 의존도를 줄여가는 접근이 현실적입니다. Google, Apple, GitHub 모두 이 전략을 사용하고 있습니다.

보안 고려사항

Passkeys를 도입할 때 알아두면 좋은 포인트들입니다.

  • **Attestation 검증 **: 등록 시 Authenticator의 신뢰성을 검증할 수 있습니다. 엔터프라이즈 환경에서는 특정 제조사의 보안 키만 허용하는 것도 가능합니다.
  • User Verification: Authenticator가 사용자를 확인했는지(생체인증, PIN 등) 검증합니다. userVerification: "required" 옵션으로 강제할 수 있습니다.
  • Credential Backup: Synced Passkeys는 클라우드에 백업되므로 클라우드 계정 보안이 중요해집니다. backupEligible, backupState 플래그로 확인 가능합니다.
JAVA
// Attestation 검증 레벨 설정
.webAuthn(webAuthn -> webAuthn
    .rpName("Enterprise App")
    .rpId("enterprise.example.com")
    .allowedOrigins("https://enterprise.example.com")
    // attestation 모드: none, indirect, direct, enterprise
    // 대부분 "none"으로 충분하지만, 높은 보안 요구 시 "direct" 사용
)

전체 흐름 요약

PLAINTEXT
[등록]
사용자 → "Passkey 등록" 클릭
       → 서버가 챌린지 + RP 정보 생성
       → 브라우저가 navigator.credentials.create() 호출
       → 디바이스가 생체인증 후 키 쌍 생성
       → 공개키를 서버에 저장

[인증]
사용자 → "Passkey로 로그인" 클릭
       → 서버가 챌린지 생성
       → 브라우저가 navigator.credentials.get() 호출
       → 디바이스가 생체인증 후 챌린지를 개인키로 서명
       → 서버가 공개키로 서명 검증 → 인증 완료

비밀번호는 "사용자가 기억하는 것", Passkeys는 "디바이스가 증명하는 것"입니다. 개인키가 네트워크를 타지 않기 때문에 중간자 공격, 피싱, credential stuffing이 원천적으로 불가능합니다. Spring Security 7.0의 .webAuthn() DSL 덕분에 설정도 간결해졌으니, 새 프로젝트라면 도입을 적극적으로 고려해볼 만합니다.

댓글 로딩 중...