Passkey로 비밀번호 대체: 작동 원리와 통합 방법

1. 개요

비밀번호는 세 가지 주요 문제가 있습니다: 추측하기 쉬움, 여러 곳에서 재사용됨, 가짜 사이트에 입력되기 쉬움. Passkey는 공개 키 암호화를 통해 이 세 가지 문제를 모두 해결합니다. 서버는 더 이상 사용자의 어떤 비밀도 보관하지 않습니다.

이 글에서는 passkey가 무엇인지, 그리고 등록 및 로그인 흐름이 어떻게 작동하는지 설명합니다. 그 후 Spring Security를 사용하여 Spring Boot 3.x 애플리케이션에서 passkey를 활성화하는 방법을 보여줍니다. 마지막에는 흔한 오류와 passkey를 아직 사용하지 않아야 하는 경우를 다룹니다.

2. Passkey란 무엇인가?

Passkey는 WebAuthn credential, 즉 FIDO2 표준을 따르는 credential입니다. 본질적으로, 그것은 한 쌍의 키입니다:

  • Private key는 사용자의 기기(휴대폰, 노트북, 보안 USB 키) 또는 비밀번호 관리자에 있습니다. 이 키는 절대 그곳을 떠나지 않습니다.
  • Public key는 등록 시 서버로 전송됩니다. 서버는 이 키를 계정과 함께 저장합니다.

각 키 쌍은 RP ID를 통해 Relying Party (RP), 즉 당신의 웹사이트 또는 애플리케이션에 연결됩니다. RP ID는 일반적으로 도메인 이름이며, 예를 들어 shop.example.com입니다.

주요 passkey의 두 가지 유형이 있습니다:

유형 특징
Synced passkey 운영 체제 또는 비밀번호 관리자의 클라우드 계정을 통해 동기화됩니다. 기기를 잃어도 복구할 수 있습니다.
Device-bound passkey 하나의 기기에만 존재하며, 예를 들어 하드웨어 보안 키입니다. 더 안전하지만 기기를 잃으면 키도 잃습니다.

사용자는 기기의 지문, 얼굴 또는 PIN으로 passkey를 잠금 해제합니다. 생체 인식 데이터는 로컬 잠금 해제에만 사용됩니다. 서버로 전송되지 않습니다.

3. Passkey의 작동 방식

3.1. 등록 (registration)

  1. 서버가 무작위 challenge를 생성합니다. 서버는 RP 및 사용자 정보와 함께 challenge를 브라우저로 전송합니다.
  2. 브라우저가 navigator.credentials.create()를 호출합니다. Authenticator는 해당 RP ID에 대한 새로운 키 쌍을 생성합니다.
  3. 브라우저는 public key, credential ID 및 서명된 데이터를 서버로 전송합니다.
  4. 서버는 challenge와 origin을 확인한 후 public key를 저장합니다.

3.2. 로그인 (authentication)

  1. 서버가 새로운 challenge를 생성합니다.
  2. 브라우저가 navigator.credentials.get()을 호출합니다. Authenticator는 private key로 challenge에 서명합니다.
  3. 서버는 저장된 public key를 사용하여 서명을 확인합니다.

핵심은 ’private key로 서명, public key로 확인’입니다. 아래의 순수 Java 코드는 이 단계를 정확히 모의합니다:

import java.security.*;

public class PasskeyConcept {

    public static void main(String[] args) throws Exception {
        // 등록 시 기기가 키 쌍을 생성합니다 (EC P-256, WebAuthn에서 흔한 ES256에 해당)
        KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
        generator.initialize(256);
        KeyPair deviceKeys = generator.generateKeyPair();
        PublicKey storedOnServer = deviceKeys.getPublic();

        // 서버가 로그인 시 무작위 challenge를 생성합니다
        byte[] challenge = randomBytes();

        // 기기가 private key로 challenge에 서명합니다
        Signature signer = Signature.getInstance("SHA256withECDSA");
        signer.initSign(deviceKeys.getPrivate());
        signer.update(challenge);
        byte[] signature = signer.sign();

        // 서버가 저장된 public key로 확인합니다
        System.out.println("Challenge 맞음: " + verify(storedOnServer, challenge, signature));
        System.out.println("Challenge 다름: " + verify(storedOnServer, randomBytes(), signature));
    }

    static boolean verify(PublicKey key, byte[] data, byte[] signature) throws Exception {
        Signature verifier = Signature.getInstance("SHA256withECDSA");
        verifier.initVerify(key);
        verifier.update(data);
        return verifier.verify(signature);
    }

    static byte[] randomBytes() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        return bytes;
    }
}

결과:

Challenge 맞음: true
Challenge 다름: false

이 결과로부터 두 가지를 알 수 있습니다:

  • 서버는 storedOnServer, 즉 public key만 보관합니다. 공격자가 데이터베이스를 획득해도 로그인하는 데 사용할 수 없습니다.
  • 서명은 그 때의 challenge하고만 맞습니다. 기존 서명을 캡처하여 다시 전송하면 (replay attack), 공격자도 확인 단계를 통과할 수 없습니다.

실제 WebAuthn에서 서명된 데이터는 페이지의 origin도 포함합니다. 브라우저가 자동으로 origin을 입력하며, 웹사이트가 위조할 수 없습니다. 따라서 사용자가 실수로 examp1e.com에 방문하면, 브라우저는 example.com의 passkey를 사용하지 않습니다. 피싱은 프로토콜 수준에서 차단되며, 사용자의 주의에 의존하지 않습니다.

4. Passkey가 주목받는 이유

  • 설계 단계부터 피싱 방지. 비밀번호와 OTP는 모두 가짜 사이트에 입력되도록 속을 수 있습니다. Passkey는 그렇지 않습니다.
  • 서버에 비밀 없음. 데이터베이스 누출이 로그인 정보를 노출하지 않습니다.
  • 더 나은 사용자 경험. 사용자는 지문을 터치하기만 하면 되며, 비밀번호를 기억하거나 SMS를 기다릴 필요가 없습니다.
  • 생태계가 준비됨. 주요 운영 체제, 브라우저 및 비밀번호 관리자는 모두 passkey 동기화를 지원합니다. 덕분에 기기 손실은 더 이상 예전처럼 큰 장애물이 아닙니다.
  • Framework 지원. 암호 확인 부분을 직접 작성할 필요가 없으므로 실수할 가능성이 줄어듭니다.

5. Spring Security와 Passkey 통합

Spring Security 6.4 (Spring Boot 3.4 이상 포함)부터 프레임워크에는 webAuthn() DSL이 있습니다. 내부적으로 확인은 WebAuthn4J 라이브러리를 사용합니다.

5.1. Dependency

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>com.webauthn4j</groupId>
        <artifactId>webauthn4j-core</artifactId>
        <version>${webauthn4j.version}</version>
    </dependency>
</dependencies>

webauthn4j.version 속성을 사용 중인 Spring Security와 호환되는 버전으로 선언합니다. Spring Security 문서에는 테스트된 버전이 명시되어 있습니다.

5.2. SecurityFilterChain 구성

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .webAuthn(webAuthn -> webAuthn
                .rpName("Nón Lá Shop")
                .rpId("localhost")
                .allowedOrigins("http://localhost:8080")
            );
        return http.build();
    }

    @Bean
    UserDetailsService userDetailsService() {
        UserDetails customer = User.withUsername("minh")
                .password("{noop}matkhau-tam")
                .roles("CUSTOMER")
                .build();
        return new InMemoryUserDetailsManager(customer);
    }
}

각 부분의 의미:

  • rpName: 운영 체제의 passkey 대화 상자에 표시되는 이름입니다.
  • rpId: passkey에 연결된 도메인 이름입니다. 이 값은 scheme이나 port가 없습니다. 프로덕션에서는 shop.example.com이 될 것입니다.
  • allowedOrigins: 허용된 origin입니다. Origin은 scheme과 port를 모두 정확히 일치시켜야 합니다.
  • formLogin: 계속 유지합니다. 사용자는 passkey를 등록하기 위해 먼저 비밀번호로 로그인해야 합니다. Passkey는 기존 계정에 추가되는 credential입니다.

애플리케이션을 실행할 때 Spring Security는 자동으로 다음 엔드포인트를 생성합니다:

GET  /webauthn/register              passkey 관리/등록 페이지 (기본값)
POST /webauthn/register/options      등록 challenge 생성
POST /webauthn/register              새로운 public key 저장
POST /webauthn/authenticate/options  로그인 challenge 생성
POST /login/webauthn                 서명 확인 및 로그인

기본 로그인 페이지에는 “Sign in with a passkey” 버튼이 추가됩니다. 테스트 흐름은 다음 네 단계로 구성됩니다:

  1. minh / matkhau-tam으로 로그인합니다.
  2. /webauthn/register를 열고 passkey를 만듭니다.
  3. 로그아웃합니다.
  4. passkey로 다시 로그인합니다.

5.3. Credential 저장소

기본적으로 Spring Security는 credential을 메모리에 저장합니다. 애플리케이션을 다시 시작하면 모든 passkey가 손실됩니다. 프로덕션에서는 두 interface를 구현하는 bean을 제공해야 합니다:

  • UserCredentialRepository: public key, credential ID 및 signature count를 저장합니다.
  • PublicKeyCredentialUserEntityRepository: 당신의 user를 WebAuthn user handle에 매핑합니다.

최신 Spring Security 버전에는 이 두 interface의 JDBC 구현이 있습니다. JPA를 사용하여 직접 작성할 수도 있습니다. 이 경우 각 credential은 별도의 레코드여야 합니다. 사용자는 일반적으로 여러 기기에서 여러 passkey를 가지기 때문입니다.

6. 브라우저 측

기본 로그인 페이지를 사용하면 Spring Security가 JavaScript를 처리해줍니다. 사용자 정의 인터페이스의 경우 로그인 흐름은 다음과 같습니다:

async function loginWithPasskey() {
  const csrf = document.querySelector('meta[name="_csrf"]').content;

  // 1. 서버로부터 challenge를 요청합니다
  const optionsRes = await fetch('/webauthn/authenticate/options', {
    method: 'POST',
    headers: { 'X-CSRF-TOKEN': csrf }
  });
  const options = PublicKeyCredential.parseRequestOptionsFromJSON(await optionsRes.json());

  // 2. 브라우저가 대화 상자를 표시하고, authenticator가 challenge에 서명합니다
  const credential = await navigator.credentials.get({ publicKey: options });

  // 3. 서명을 서버로 전송하여 확인합니다
  const res = await fetch('/login/webauthn', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf },
    body: JSON.stringify(credential.toJSON())
  });
  if (res.ok) window.location.href = '/';
}

코드의 각 단계:

  • 단계 1은 challenge를 가져옵니다. POST이므로 요청에 CSRF token이 포함되어야 합니다.
  • parseRequestOptionsFromJSON은 base64url 필드를 ArrayBuffer로 변환합니다. 구형 브라우저는 이 함수를 지원하지 않을 수 있습니다. 이 경우 helper 라이브러리를 사용하거나 직접 디코드해야 합니다.
  • 단계 3은 결과를 서버로 전송합니다. JSON 형식은 서버가 예상하는 형식과 일치해야 합니다. Spring Security의 기본 로그인 페이지의 스크립트는 비교하기 위한 최고의 참고 자료입니다.

7. 테스트

unit test에서 실제 authenticator를 간단히 모의할 수 없습니다. 그러나 서버의 중요한 부분은 여전히 테스트할 수 있습니다: challenge 생성 엔드포인트가 작동하고 각 호출이 다른 challenge를 반환합니다.

@SpringBootTest
@AutoConfigureMockMvc
class PasskeyEndpointTest {

    @Autowired
    MockMvcTester mvc;

    @Test
    void authenticationOptions_containRpIdAndFreshChallenge() {
        String first = requestChallenge();
        String second = requestChallenge();

        assertThat(first).isNotBlank();
        assertThat(second).isNotEqualTo(first);
    }

    @Test
    void authenticationOptions_useConfiguredRpId() {
        assertThat(mvc.post().uri("/webauthn/authenticate/options").with(csrf()))
                .hasStatusOk()
                .bodyJson()
                .extractingPath("$.rpId")
                .isEqualTo("localhost");
    }

    private String requestChallenge() {
        MvcTestResult result = mvc.post()
                .uri("/webauthn/authenticate/options")
                .with(csrf())
                .exchange();
        assertThat(result).hasStatusOk();
        return JsonPath.read(result.getResponse().getContentAsString(), "$.challenge");
    }
}

MockMvcTester (Spring Framework 6.2+)는 AssertJ 스타일의 assertion을 작성할 수 있게 해줍니다. csrf()는 SecurityMockMvcRequestPostProcessors에서 가져옵니다. 첫 번째 테스트는 challenge가 재사용되지 않음을 확인하며, 이것이 바로 replay 방지 메커니즘입니다. 두 번째 테스트는 rpId가 올바르게 구성되었음을 보장합니다. 배포 시 가장 흔한 구성 오류입니다.

end-to-end 테스트를 위해 브라우저의 virtual authenticator를 사용합니다. Chrome DevTools에는 WebAuthn 탭이 있으며, WebDriver/Playwright도 이 기능을 지원합니다.

8. 흔한 오류와 사용하지 않아야 할 경우

8.1. 흔한 오류

  • 잘못된 rpId 또는 allowedOrigins. rpId에 https://를 작성하거나 origin의 port를 빼먹으면 브라우저 또는 서버가 거부합니다. 이 두 값을 환경별로 application.yaml에서 분리하는 것이 좋습니다.
  • HTTPS를 사용하지 않음. WebAuthn은 secure context에서만 실행됩니다. localhost는 유일한 예외입니다.
  • 도메인 변경. Passkey는 RP ID에 연결됩니다. 기본 도메인을 변경하면 모든 기존 passkey를 사용할 수 없습니다.
  • 프로덕션에서 메모리 내 저장. 다시 시작하면 사용자의 passkey가 손실됩니다.
  • 한 개의 passkey만 등록 허용. 사용자는 여러 기기에 대해 여러 passkey가 필요합니다. 각 passkey를 나열하고 삭제할 수 있는 페이지를 제공합니다.

주의: Passkey는 계정 복구 흐름도 강할 때만 강합니다. “passkey를 잊은 경우” 이메일로 링크를 보내기만 하면 공격자는 passkey 대신 이메일을 대상으로 할 것입니다.

8.2. 사용하지 않아야 할 경우 (또는 단독으로 사용하지 않아야 할 경우)

  • 구형 기기 또는 공유 기기의 사용자, 예를 들어 카운터 컴퓨터 또는 키오스크. 이 경우 백업 방법을 유지해야 합니다.
  • 브라우저나 앱 기반이 아닌 시스템, 예를 들어 서비스 간 API, CLI 또는 백그라운드 작업. 이러한 경우 OAuth2 client credentials, mTLS 또는 API key를 사용해야 합니다.
  • 준수 요구 사항이 하드웨어 키만 수락. Synced passkey는 요구 사항을 충족하지 못할 수 있습니다. device-bound passkey와 attestation 검증이 필요합니다.
  • 팀이 아직 계정 복구 지원 프로세스가 없는 경우. 먼저 passkey를 추가 옵션으로 배포한 다음 나중에 비밀번호를 제거하는 것이 좋습니다.

9. 결론

Passkey는 공유 비밀 (비밀번호)을 도메인에 연결된 공개 키 쌍으로 대체합니다. 따라서 passkey는 하나의 메커니즘만으로 피싱, 자격 증명 채우기 및 데이터베이스 누출 위험을 제거합니다. Spring Security를 사용하면 SecurityFilterChain에 webAuthn()을 추가하고, rpId와 allowedOrigins을 올바르게 구성한 다음, 메모리 내 credential 저장소를 지속성 저장소로 교체하면 됩니다.

가장 안전한 배포 방법은 단계적으로 진행하는 것입니다: 사용자가 기존 계정에 passkey를 추가하게 하고, 복구 흐름을 엄격하게 설계한 후, 비밀번호 로그인을 비활성화하는 것을 고려합니다.

passkeywebauthnspring-security보안tech-trends