SOFTMOA TECHNOLOGY

Spring Security 7 Passkey(패스키) 완전 가이드: WebAuthn 기반 비밀번호 없는 인증 구현

소프트모아가 정리한 기술 기록입니다.

Contents
작성일 2026. 04. 25.

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은 JdbcPublicKeyCredentialUserEntityRepository와 JdbcUserCredentialRepository를 기본 제공합니다. 스키마 생성 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) 모두에서 지원되며, 사용자 경험과 보안 모두를 크게 향상시키는 차세대 인증 표준입니다.

Share this

Spring

Have a questions?

견적 및 기술문의

mobile : 010-7931-4813

Contact Form