Passkey Sample Application
Passkey를 이용한 사용자 인증에 대한 Sample Application을 소개합니다. (Spring Boot 3 + React + JWT)
이전 글에서 소개했던 Passkey Overview에서 Sample Application을 만들어서 소개해보겠다고 했는데, 시간이 많이 지났다. 사실 이미 만들어뒀었는데 올리는게 많이 늦었다. 분명히 Github에 기록을 만드는게 중요하다고 생각은 하는데 이게 Git init을 안하고 작업을 해버리니까 한 번에 데이터가 올라가는건 있다.
이번 Sample Application에서는 Spring Boot로 Backend를 구성하고, React를 사용하여 Frontend를 구성했다. 이렇게 거창하게 적혀있긴하지만 실제로는 별거 없다. 회원가입, 로그인, 패스키 추가가 주된 기능이다. 그리고 간단하게 S3 연동을 추가하여 프로필 사진에 대한 기능이 있다.
Tech Stacks
Backend
- JDK 17+
- Spring Boot 3.4.2
- Spring Data JPA
- Spring Security (WebAuthn4J 포함)
- MySQL 8
- JJWT (JWT)
- AWS SDK for Java (S3)
Frontend
- Node 22
- TypeScript 5.7.2
- React 19
- React Router
- Bootstrap 5.3.3
- Bootstrap Icons
인증 메커니즘
이 샘플 Application은 기본적으로 Username + Password 또는 Passkey를 이용하여 사용자를 인증한다.
상기 2가지 방식 중 하나로 사용자를 인증한 뒤에는 JWT 기반으로 로그인 된 유저를 인증한다. JWT 에는 Access Token과 Refresh Token이 있다.
Access Token은 FE에 전달되어 Session Storage에 저장되고, FE에서 BE에 요청할 때 Header의 Authorization에 Bearer 의 형태로 들어간다.
Refresh Token은 Cookie를 통해서 저장된다. Cookie를 통해 저장하면, FE에서 JS를 통해서는 이 값을 호출할 방법이 없다. 사용자의 입장에서는 Cookie를 확인할 수 있겠지만, JS에서는 불가능하기 때문에 Session Storage 보다는 상대적으로 안전하다.
상기 2개 Token을 Sign할 때는 각각 별도의 Private Key를 이용하여 사인한다. 즉, 단순히 Access Token의 Expire 시간을 길게한다고 Refresh Token이 되는 방식이 아니다.
다만, WebAuthn4J for Spring Security에서 Custom Handler를 적용할 방법을 아직 찾지 못하여 Passkey 인증이 성공한 후, 첫 Access Token을 가져올 때만 세션 인증을 수행한다.
환경변수
Backend
Backend에 설정해야할 환경변수는 아래와 같다.
- APP_DB_HOST: 데이터베이스 호스트 주소
- APP_DB_PORT: 데이터베이스 포트 번호
- APP_DB_NAME: 데이터베이스 이름
- APP_DB_USER: 데이터베이스 사용자 이름
- APP_DB_PASSWORD: 데이터베이스 비밀번호
- APP_JWT_ACCESS_PRIVATE_KEY_PATH: JWT Access Token 개인 키 파일 경로 (기본값: classpath:default_access_private_key.pem)
- APP_JWT_REFRESH_PRIVATE_KEY_PATH: JWT Refresh Token 개인 키 파일 경로 (기본값: classpath:default_refresh_private_key.pem)
- APP_JWT_ACCESS_TOKEN_EXPIRATION: JWT Access Token 만료 시간 (밀리초, 기본값: 900000)
- APP_JWT_REFRESH_TOKEN_EXPIRATION: JWT Refresh Token 만료 시간 (밀리초, 기본값: 86400000)
- APP_S3_ENDPOINT: S3 호환 스토리지 엔드포인트 URL
- APP_S3_ACCESS_KEY: S3 액세스 키
- APP_S3_SECRET_KEY: S3 시크릿 키
- APP_S3_REGION: S3 리전 (기본값: ap-northeast-2)
- APP_S3_BUCKET_NAME: S3 버킷 이름
- APP_S3_ENABLE_PATH_STYLE_ACCESS: S3 경로 스타일 액세스 활성화 여부 (기본값: true)
- APP_FILE_ALLOW_TO_UPLOAD: 업로드 허용 파일 확장자 목록 (기본값: .png,.jpg,.jpeg,.gif,.webp,.svg)
- APP_BASE_URL: 애플리케이션 기본 URL (기본값: http://localhost:8080)
- APP_STATIC_URL: 정적 파일 제공 URL (S3 프록시 등)
여기서, APP_JWT_ACCESS_PRIVATE_KEY_PATH의 경우, 절대 경로로 작성 해주어야한다. 다만, 이 키는 당연히 Github에 올라가 있으므로 개인이 구동할 때에는 별도의 키를 생성하여야한다. 아래 명령어로 생성할 수 있다.
openssl genrsa -out private-key.pem 2048
Frontend
Frontend에 설정해야할 환경변수는 없다.
스크린샷

BE와 FE를 실행하고 3000번 포트로 FE에 진입하면 위와 같은 화면이 나온다. 오른쪽 위의 Sign Up 버튼을 통해 계정을 만들어야한다.

Sign Up 페이지는 하기 정보를 요구한다.
- 이름
- 이메일 -> 로그인할 때 사용한다. 중복 확인이 필수이다.
- 비밀번호 -> 8자 이상
- 비밀번호 확인 -> 상기와 동일하게 입력해야함.
- Gravatar 사용 여부 -> 입력한 이메일을 기반으로 Gravatar에서 프로필 사진을 가져올지 여부

데이터를 넣게 되면 상기와 같다. 특히, 이메일 주소를 중복 확인한 경우 disable 되어 변경할 수 없다. 변경하기 위해서는 Modify 버튼을 이용하여 다시 정의해야한다.

계정을 만든 뒤에는 로그인을 할 수 있다. 처음에는 Email + Password 조합으로 로그인을 수행하면 된다.
로그인을 수행하게 되면, BE에 인증을 요구하고 Spring Security를 통해 인증이 완료되면 JWT Handler를 통해 Access Token과 Refresh Token을 발급해준다.

로그인이 정상적으로 완료되면, 다시 메인 화면으로 나오게 되는데, 상단 Header에 About 메뉴가 생겼고, 오른쪽에는 프로필 사진이 생긴 것을 볼 수 있다. 여기까지 통상적인 Username + Password 인증이다.
이제 Passkey 인증을 수행해보겠다.

프로필 사진을 누르면, 메뉴가 등장하고, 'Settings' 버튼을 눌러서 설정에 진입한다.

설정 화면에서는 여러 설정을 수행할 수 있지만, 'Manage Passkey'로 진입한다.

Passkey 설정 화면에 진입하면, Passkey를 추가하거나 삭제할 수 있다. Passkey를 구분할 이름을 입력하고 Add를 누르면 된다.

Add 버튼을 누르면 브라우저에서 Passkey 등록에 대한 화면이 등장하고 등록을 진행할 수 있다. 여기서 등장하는 이메일의 경우 계정의 이메일이다. 등록을 하게 되면, List에서 볼 수 있고, 로그아웃을 수행한 뒤에 로그인을 시도하면 작동하는 것을 확인할 수 있다.

리뷰
사실 이 프로젝트는 JWT 인증을 적용해보고 싶어서가 1번이었다. 그러면서 패스키까지 발전하게 된 형태이다.
직접 만들기 전에는 JWT가 무엇인지 계속 보아도 이해가 되지 않았다. 흔히들 JWT와 혼합하여 사용하는 OAuth 와 결합하여 구현해보아도 무엇인지 긴가민가한게 사실이었다. JWT와 OAuth는 각각 다른 인증에 쓰인다는 점을 알게 된 후에는 명확한 방향을 통헤 개발을 수행할 수 있었다.
정확한 개념 정립을 통해 패스키 구현도 결합할 수 있을 것 같아서 JWT를 통해 패스키를 구현해보았다.
Git Repos
- Backend: https://github.com/shin6949/passkey-sample-be
- Frontend: https://github.com/shin6949/passkey-sample-fe