패스키 등록을 붙이는데 등록 버튼을 누르면 500 이 났습니다. SerializationException 이었습니다.
WebAuthn 등록 옵션을 세션에 넣은 게 원인이었는데, 고치는 과정에서 한 번 더 막혔습니다. 세션이 안 돼서 JSON 으로 바꿨더니 이번엔 저장은 되는데 다시 읽을 수가 없었습니다.

Spring Security 7 / Jackson 2 / JDK 25 기준이다.
WebAuthn 등록 옵션은 요청 두 번 사이에 살아 있어야 한다
패스키 등록은 한 번에 끝나지 않는다.
① POST /webauthn/register/options
서버가 난수 challenge 를 만들어 브라우저에 준다
② navigator.credentials.create(options)
인증기(지문 등)가 키쌍을 만들고 challenge 에 서명한다
③ POST /webauthn/register
서버가 ①에서 만든 challenge 와 대조해 검증한다
①과 ③ 사이에 challenge 를 서버 어딘가 들고 있어야 한다. 요청이 다르니 메모리 변수로는 안 되고, 세션이 제일 먼저 떠오른다.
PublicKeyCredentialCreationOptions 를 세션에 넣으면 될 것 같다. 안 된다.
테스트 1 — 세션에 담아본다
Spring Session 을 Redis 로 쓰면 기본 저장 방식이 JDK 직렬화다. 그래서 세션에 담긴 객체는 Serializable 이어야 한다.
WebAuthn 옵션을 하나 만들어 그대로 직렬화해봤다.
대상: org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions
Serializable 구현 여부: false
=== 1. JDK 직렬화 (세션에 그냥 담을 때) ===
실패 java.io.NotSerializableException
org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions
클래스 선언을 보면 그냥 이렇게 생겼다.
public final class PublicKeyCredentialCreationOptions {
Serializable 을 구현하지 않는다. 세션에 넣는 순간 저장 단계에서 터진다.
로컬에서 세션을 메모리로 쓰면 이 문제가 안 보인다는 게 고약하다. 톰캣 기본 세션은 객체를 그냥 들고 있어서 직렬화를 안 하기 때문이다. Redis 를 붙이는 순간에 처음 터진다.
테스트 2 — JSON 으로 바꾼다
그러면 JSON 으로 만들어 Redis 에 문자열로 넣으면 되겠다 싶었다. Spring Security 가 WebAuthn 용 Jackson 모듈을 제공한다.
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new WebauthnJackson2Module());
직렬화는 잘 된다.
=== 2. Jackson 직렬화 ===
OK {"rp":{"name":"demo","id":"localhost"},"user":{"name":"hoony","id":"sB0JzQJ0SbcO_XqqC1KW05JeQrlRgASteGXCXcmV0s4","displayName":"hoony"},"cha …
여기까지 보고 끝난 줄 알았다. 같은 모듈로 다시 읽으니 이렇게 됐다.
=== 3. Jackson 역직렬화 (같은 모듈로 다시 읽기) ===
실패 com.fasterxml.jackson.databind.exc.InvalidDefinitionException
Cannot construct instance of `org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions`
(no Creators, like default constructor, exist): cannot deserialize from Object value
(no delegate- or property-based Creator)
WebauthnJackson2Module 안을 보면 이유가 나온다. 등록하는 게 전부 mixin 인데, 그 mixin 들이 직렬화 쪽만 정의한다. 기본 생성자도 @JsonCreator 도 없으니 Jackson 이 객체를 만들 방법이 없다.
즉 이 모듈은 브라우저로 내보내는 용도다. 옵션을 JSON 으로 응답에 실어 보내는 게 목적이지, 저장했다 다시 읽는 걸 상정하지 않았다.
테스트 3 — 그래서 빌더로 재구성한다
읽는 쪽을 직접 만들었다. JSON 을 트리로 읽고 빌더로 다시 조립한다.
PublicKeyCredentialCreationOptions.builder()
.rp(PublicKeyCredentialRpEntity.builder().id(...).name(...).build())
.user(ImmutablePublicKeyCredentialUserEntity.builder()
.id(Bytes.fromBase64(...))
.name(...)
.displayName(...)
.build())
.challenge(Bytes.fromBase64(...))
.pubKeyCredParams(...)
.timeout(Duration.ofMillis(...))
.build();
값 타입이 대부분 valueOf 로 복원되는데, UserVerificationRequirement 처럼 getValue() 로만 문자열을 뱉는 것도 있어서 그건 따로 매핑해야 했다.
저장은 Redis 에 직접 넣는다. 세션에 얹지 않고 별도 키로 둔다.
webauthn:creation:{sessionId} TTL 5분
webauthn:request:{sessionId} TTL 5분
TTL 을 짧게 두는 건 challenge 의 성질 때문이다. 재사용을 막으려고 만든 난수라 오래 살아 있을 이유가 없다. 등록을 하다 만 사용자의 값도 알아서 사라진다.
정리
WebAuthn 등록 옵션을 담을 자리를 고르면서 배운 건 이거다. 세션은 “아무거나 넣는 곳”이 아니다. 저장소를 Redis 로 바꾸는 순간 넣는 객체가 JDK 직렬화를 통과해야 한다는 조건이 붙는다. 프레임워크가 만들어주는 객체라고 다 통과하지 않는다.
그리고 Jackson 모듈이 있다고 왕복이 되는 것도 아니다. 직렬화 전용 모듈이 따로 있다. 이번 건은 저장은 성공하고 읽기에서 실패해서, 문제가 한 박자 늦게 나왔다.
라이브러리가 제공하는 직렬화 지원을 쓸 때는 왕복을 한 번 돌려보는 게 빠르다. 저장만 해보고 넘어가면 나중에 읽는 자리에서 만난다.
같은 인증 서버에서 겪은 다른 직렬화 문제도 적어뒀다.
인가 정보에 실린 Authentication 이 복원 단계에서 거부되던 이야기는 Jackson 직렬화 허용 목록 에 있다.