Contents
see List1. 패스키(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) 흐름
- 사용자가 패스키 등록 요청 → 서버가 챌린지(Challenge) 생성
- 브라우저
navigator.credentials.create()호출 → 인증기가 키 쌍 생성 - 공개키(Public Key) + 서명(Attestation) 서버 전송 →
UserCredentialRepository에 저장
인증(Authentication) 흐름
- 사용자가 로그인 요청 → 서버가 챌린지 생성
- 브라우저
navigator.credentials.get()호출 → 인증기가 개인키로 서명 - 서버가 저장된 공개키로 서명 검증 → 인증 성공
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:email10. 패스키 관리 화면 구현
@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) 모두에서 지원되며, 사용자 경험과 보안 모두를 크게 향상시키는 차세대 인증 표준입니다.