1. 패스키(Passkey)란 무엇인가?

패스키(Passkey)는 FIDO2/WebAuthn 표준을 기반으로 한 비밀번호 없는(Passwordless) 인증 자격증명입니다. 기존 비밀번호 방식의 피싱, 크리덴셜 스터핑, 재사용 등 보안 취약점을 근본적으로 해결합니다. 스마트폰, 노트북, 보안 키 등 하드웨어 인증기(Authenticator)에 저장되며, 생체인식(지문, 얼굴인식) 또는 PIN으로 잠금 해제합니다.

Spring Security는 6.3 버전부터 Passkey 지원을 실험적으로 도입했으며, Spring Security 7.0(Spring Boot 4.0과 함께 출시)에서 안정화된 정식 API로 제공합니다. 이 가이드에서는 Spring Security 7 환경에서 WebAuthn 패스키 인증을 처음부터 끝까지 구현하는 방법을 다룹니다.

2. Spring Security 7 패스키 아키텍처

등록(Registration) 흐름

  1. 사용자가 패스키 등록 요청 → 서버가 챌린지(Challenge) 생성
  2. 브라우저 navigator.credentials.create() 호출 → 인증기가 키 쌍 생성
  3. 공개키(Public Key) + 서명(Attestation) 서버 전송 → UserCredentialRepository에 저장

인증(Authentication) 흐름

  1. 사용자가 로그인 요청 → 서버가 챌린지 생성
  2. 브라우저 navigator.credentials.get() 호출 → 인증기가 개인키로 서명
  3. 서버가 저장된 공개키로 서명 검증 → 인증 성공

3. 의존성 설정

Spring Boot 4.0 프로젝트 기준 pom.xml에 다음 의존성을 추가합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-webauthn</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>

Gradle 프로젝트라면 다음과 같이 추가합니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.security:spring-security-webauthn'
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
}

4. SecurityFilterChain 기본 설정

Spring Security 7에서는 authorizeRequests()가 완전히 제거되었습니다. 반드시 authorizeHttpRequests()를 사용해야 합니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .formLogin(withDefaults())
            .webAuthn(webAuthn -> webAuthn
                .rpName("My Spring App")
                .rpId("example.com")
                .allowedOrigins("https://example.com")
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/register", "/webauthn/**").permitAll()
                .anyRequest().authenticated()
            );
        return http.build();
    }
}

위 설정으로 Spring Security 7이 자동으로 등록하는 엔드포인트:

  • GET /webauthn/register/options — 등록 옵션(챌린지) 발급
  • POST /webauthn/register — 패스키 등록 처리
  • POST /login/webauthn — 패스키 인증 처리
  • DELETE /webauthn/register/{credentialId} — 등록된 패스키 삭제

5. 자격증명 저장소(Repository) 구성

5-1. 인메모리 저장소 (개발/테스트용)

@Configuration
public class WebAuthnConfig {

    @Bean
    public PublicKeyCredentialUserEntityRepository userEntityRepository() {
        return new InMemoryPublicKeyCredentialUserEntityRepository();
    }

    @Bean
    public UserCredentialRepository userCredentialRepository() {
        return new InMemoryUserCredentialRepository();
    }
}

5-2. JDBC 저장소 (프로덕션 권장)

Spring Security 7은 JdbcPublicKeyCredentialUserEntityRepositoryJdbcUserCredentialRepository를 기본 제공합니다. 스키마 생성 SQL:

CREATE TABLE webauthn_credentials (
    credential_id         TEXT PRIMARY KEY,
    credential_type       TEXT NOT NULL,
    user_entity_user_id   TEXT NOT NULL,
    public_key_cose       BYTEA NOT NULL,
    sign_count            BIGINT NOT NULL,
    uvInitialized         BOOLEAN NOT NULL,
    transports            TEXT[],
    backup_eligible       BOOLEAN NOT NULL,
    backup_state          BOOLEAN NOT NULL,
    attestation_object    BYTEA,
    attestation_client_data_json BYTEA,
    created               TIMESTAMP WITH TIME ZONE NOT NULL,
    last_used             TIMESTAMP WITH TIME ZONE,
    label                 TEXT NOT NULL
);

CREATE TABLE webauthn_user_entities (
    id           TEXT PRIMARY KEY,
    name         TEXT NOT NULL,
    display_name TEXT NOT NULL
);
@Configuration
public class WebAuthnJdbcConfig {

    @Bean
    public PublicKeyCredentialUserEntityRepository userEntityRepository(JdbcOperations jdbc) {
        return new JdbcPublicKeyCredentialUserEntityRepository(jdbc);
    }

    @Bean
    public UserCredentialRepository userCredentialRepository(JdbcOperations jdbc) {
        return new JdbcUserCredentialRepository(jdbc);
    }
}

6. UserDetailsService 연동

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByUsername(username)
            .map(user -> User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRoles().toArray(String[]::new))
                .build())
            .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username));
    }
}

7. 프론트엔드 JavaScript 구현

Spring Security 7은 WebAuthn 관련 JavaScript 유틸리티를 /assets/passkeys/header.js 경로에서 자동 제공합니다.

패스키 등록 (register.html)

<!DOCTYPE html>
<html>
<head>
    <title>패스키 등록</title>
    <script type="module" src="/assets/passkeys/header.js"></script>
</head>
<body>
    <h1>패스키 등록</h1>
    <form id="registerForm">
        <label>패스키 이름: <input type="text" id="label" placeholder="내 MacBook"></label>
        <button type="submit">패스키 등록하기</button>
    </form>
    <script type="module">
        import { registerPasskey } from '/assets/passkeys/header.js';
        document.getElementById('registerForm').addEventListener('submit', async (e) => {
            e.preventDefault();
            const label = document.getElementById('label').value;
            try {
                await registerPasskey(label);
                alert('패스키 등록 완료!');
                window.location.href = '/dashboard';
            } catch (err) {
                console.error('패스키 등록 실패:', err);
            }
        });
    </script>
</body>
</html>

패스키 로그인 (login.html)

<!DOCTYPE html>
<html>
<head>
    <title>로그인</title>
    <script type="module" src="/assets/passkeys/header.js"></script>
</head>
<body>
    <form action="/login" method="post">
        <input type="text" name="username" placeholder="아이디">
        <input type="password" name="password" placeholder="비밀번호">
        <button type="submit">로그인</button>
    </form>
    <hr>
    <button id="passkeyLogin">패스키로 로그인</button>
    <script type="module">
        import { authenticate } from '/assets/passkeys/header.js';
        document.getElementById('passkeyLogin').addEventListener('click', async () => {
            try {
                await authenticate();
            } catch (err) {
                console.error('패스키 인증 실패:', err);
                alert('인증에 실패했습니다. 다시 시도해주세요.');
            }
        });
    </script>
</body>
</html>

8. Spring Security 7 CSRF 변경사항 대응

Spring Security 7에서는 REST API 엔드포인트에도 CSRF 보호가 기본 활성화됩니다. REST API와 웹 UI를 분리하여 설정합니다.

@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
    http
        .securityMatcher("/api/**")
        .csrf(csrf -> csrf.disable())
        .sessionManagement(session ->
            session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
    return http.build();
}

@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(withDefaults())
        .formLogin(withDefaults())
        .webAuthn(webAuthn -> webAuthn
            .rpName("My App")
            .rpId("example.com")
            .allowedOrigins("https://example.com"))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/login", "/webauthn/**").permitAll()
            .anyRequest().authenticated());
    return http.build();
}

9. OAuth2 + 패스키 조합 구성

패스키 인증과 소셜 로그인(OAuth2)을 동시에 지원하는 설정입니다.

@Bean
public SecurityFilterChain combinedFilterChain(HttpSecurity http) throws Exception {
    http
        .formLogin(form -> form
            .loginPage("/login")
            .defaultSuccessUrl("/dashboard"))
        .oauth2Login(oauth2 -> oauth2
            .loginPage("/login")
            .defaultSuccessUrl("/dashboard"))
        .webAuthn(webAuthn -> webAuthn
            .rpName("Enterprise App")
            .rpId("mycompany.com")
            .allowedOrigins("https://mycompany.com", "https://app.mycompany.com"))
        .logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessUrl("/login?logout")
            .deleteCookies("JSESSIONID"))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/login", "/oauth2/**", "/webauthn/**").permitAll()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated());
    return http.build();
}

application.yml OAuth2 설정:

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: user:email

10. 패스키 관리 화면 구현

@Controller
@RequestMapping("/passkeys")
public class PasskeyManagementController {

    private final UserCredentialRepository credentialRepository;
    private final PublicKeyCredentialUserEntityRepository userEntityRepository;

    public PasskeyManagementController(
            UserCredentialRepository credentialRepository,
            PublicKeyCredentialUserEntityRepository userEntityRepository) {
        this.credentialRepository = credentialRepository;
        this.userEntityRepository = userEntityRepository;
    }

    @GetMapping
    public String listPasskeys(@AuthenticationPrincipal UserDetails user, Model model) {
        var userEntity = userEntityRepository.findByUsername(user.getUsername());
        if (userEntity != null) {
            var credentials = credentialRepository.findByUserId(userEntity.getId());
            model.addAttribute("credentials", credentials);
        }
        model.addAttribute("username", user.getUsername());
        return "passkeys/list";
    }

    @DeleteMapping("/{credentialId}")
    @ResponseBody
    public ResponseEntity<Void> deletePasskey(
            @PathVariable String credentialId,
            @AuthenticationPrincipal UserDetails user) {
        var userEntity = userEntityRepository.findByUsername(user.getUsername());
        if (userEntity == null) return ResponseEntity.notFound().build();

        var credential = credentialRepository.findByCredentialId(
            Bytes.fromBase64(credentialId));
        if (credential == null || !credential.getUserEntityUserId().equals(userEntity.getId())) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
        credentialRepository.delete(credential.getCredentialId());
        return ResponseEntity.noContent().build();
    }
}

11. Spring Security 7 마이그레이션 핵심 체크리스트

변경 항목6.x (이전)7.0 (현재)
요청 권한 설정authorizeRequests() (제거됨)authorizeHttpRequests()
CSRF 기본 정책폼 기반만 활성화API 포함 전체 활성화
SecurityFilterChain 순서묵시적@Order 명시 권장
deprecated 메서드경고완전 제거
PasswordEncoder encode()nullable 반환 가능non-null 계약 강제
패스키 지원실험적(6.3+)정식 안정 API

12. 로컬 개발 환경 설정

패스키 개발 시 localhost 환경에서는 rpId를 localhost로, allowedOrigins를 http://localhost:8080으로 설정해야 합니다. HTTPS 없이도 localhost에서는 WebAuthn이 동작합니다.

# application-local.yml
spring:
  security:
    webauthn:
      rp-id: localhost
      rp-name: Dev App
      allowed-origins: http://localhost:8080
@Bean
@Profile("local")
public SecurityFilterChain localFilterChain(HttpSecurity http) throws Exception {
    http.webAuthn(webAuthn -> webAuthn
        .rpName("Dev App")
        .rpId("localhost")
        .allowedOrigins("http://localhost:8080")
    );
    return http.build();
}

@Bean
@Profile("prod")
public SecurityFilterChain prodFilterChain(HttpSecurity http) throws Exception {
    http.webAuthn(webAuthn -> webAuthn
        .rpName("Production App")
        .rpId("example.com")
        .allowedOrigins("https://example.com")
    );
    return http.build();
}

정리

Spring Security 7은 WebAuthn 패스키를 정식 지원하여 비밀번호 없는 인증을 쉽게 구현할 수 있습니다.

  • spring-security-webauthn 의존성 추가 후 .webAuthn() DSL 한 줄로 패스키 엔드포인트 자동 등록
  • 인메모리 저장소로 빠른 프로토타입, JDBC 저장소로 프로덕션 적용
  • authorizeRequests() 완전 제거 — authorizeHttpRequests()로 전환 필수
  • CSRF가 API 엔드포인트에도 기본 활성화 — REST API는 명시적으로 비활성화 필요
  • OAuth2 소셜 로그인과 패스키를 병용하는 하이브리드 인증 전략 권장
  • localhost 개발 환경에서는 HTTPS 없이도 테스트 가능

패스키 기반 인증은 2026년 현재 주요 브라우저(Chrome, Safari, Firefox, Edge) 모두에서 지원되며, 사용자 경험과 보안 모두를 크게 향상시키는 차세대 인증 표준입니다.