안녕하세요, 인포팀에서 백엔드 개발을 맡고 있는 김도현입니다.
다들 서비스에 로그인하기 위해 아이디와 비밀번호를 사용해 본 경험이 있을 겁니다.
일반적으로 로그인을 할 때는 아이디와 비밀번호를 입력해 서버에 전송하면 DB에 저장되어 있는 정보를 비교하여 서비스에 가입되어 있는 사용자인지 확인합니다.
하지만 ‘패스키’를 사용해 얼굴 인증이나 지문 인증 등을 사용해 비밀번호를 입력하지 않고도 간편하게 서비스에 로그인 할 수 있다는 사실 알고 계셨나요?
Passkey?
패스키는 간단하게 말해 생체 인식 등의 방법으로 기기의 인증을 통과하면 그 기기가 갖고 있는 개인키를 비밀번호로써 사용하여 로그인 하는 방식을 의미합니다.
패스키는 크게 개인키와 공개키로 나뉘어집니다.
사용자의 기기에서는 패스키를 등록할 때 무작위로 개인키와 공개키를 만듭니다.
이때, 개인키는 tpm, secure enclave 등 내부 보안칩 등에 안전하게 저장하고 공개키는 서버로 전송해 DB에 저장합니다.
이때 개인키는 내가 인증된 사용자 임을 증명하기 위한 서명 작업에 사용되고 공개키는 서명 인증된 사용자가 서명했는지 확인하기 위해 사용됩니다.
패스키의 인증 방식
패스키의 인증 방식을 은행에 비유해 설명해보겠습니다.
패스키 등록
사용자가 은행에 정보를 등록을 하고 싶다고 요청합니다.
은행은 사용자에게 위조 요청 방지를 위한 인식표(challenge), 은행 이름(rp id), 본인임을 인증하기 위해 사용할 수 있는 인증 수단(pubKeyCredParams)에 대한 정보를 줍니다.
만약 지문 도장(개인키)으로 인증을 수행한다고 하면 사용자는 다른 은행과 겹치지 않는 손가락을 인증 수단으로 선택합니다.
그 후 은행에게 본인 지문임을 알 수 있는 정보(공개키)를 전달하고 은행은 정보를 저장합니다.
패스키 로그인
사용자가 본인임을 입증하기 위해 은행에 정보를 요청합니다.
은행은 지문을 찍을 인식표(challenge), 은행 이름(rp id)에 대한 정보를 사용자에게 전달합니다.
사용자는 은행 이름(rp id)을 보고 어떤 손가락(개인키)으로 인증을 수행할 지 선택하고 지문 도장을 인식표 위에 찍습니다. (개인키로 challenge에 서명)
인식표를 받은 은행은 본 은행이 발급한 인식표가 맞는지, 인식표에 찍힌 지문이 우리가 갖고 있는 정보(공개키)와 일치하는지 판단해 본 은행에 가입한 사용자가 맞는지 확인합니다.
실제로 지문과 같은 생체 정보를 서버에 전송하는 것은 아닙니다.
생체 인증은 개인키를 기기에서 가져오기 전에 거치는 인증 작업일 뿐입니다.
기존 로그인 방식보다 안전?
일반적인 로그인은 다음과 같은 문제점을 가질 수 있습니다
- 피싱 사이트로 로그인할 경우 아이디, 비밀번호 노출
- 백엔드 DB정보가 유출될 가능성
- 비밀번호의 재사용
하지만 패스키를 사용하면 위와 같은 문제점을 해결할 수 있습니다
피싱 사이트의 경우 패스키는 로그인하는 서비스와 1대1로 할당되기 때문에 피싱 사이트에 접속 시 기기에서 패스키로 로그인을 지원하지 않습니다.
만약 서비스 운영 중 DB 정보가 노출되더라도 공개키 정보 만으로는 개인키를 유추해낼 수 없기 때문에 비밀번호 역할을 하는 개인키를 알아낼 수 없습니다.
개인키는 각자의 기기에 저장되어 있기 때문에 혹시나 물리적 해킹을 통해 탈취될 가능성이 있는데 이러한 경우에도 패스키의 사용 횟수를 기기와 DB에 저장하기 때문에 비교했을 때 횟수가 다르다면 키가 탈취된 것으로 판단해 로그인을 막을 수 있습니다.
그럼 인포팀 계정에서는 어떻게 패스키를 사용하고 있을까요?
인포팀 계정에서의 패스키
인포팀 계정에서 패스키를 사용하는 과정은 크게 등록, 로그인 과정으로 나뉩니다.
패스키 등록
sequenceDiagram participant Client participant Server Client ->>+ Server: Requesting to register passkey note right of Server: POST /user/passkey Server ->>- Client: challenge, rp id, pubKeyCredParams, ... Client ->> Client: Recognize fingerprint or face Client ->> Client: Create private key and public key Client ->>+ Server: clientDataJSON, attestationObject, ... note right of Server: POST /user/passkey/verify Server ->> Server: Decode attestationObject and save public key Server ->>- Client: Created Response 201
먼저 등록의 경우 클라이언트에서 서버로
POST /user/passkey 를 요청하면 크게 다음과 같은 response를 줍니다.{ "challenge": "HPv7vydo...", "rp": { "id": "string", "name": "string" }, "user": { "id": "string", "name": "string", "displayName": "string" }, "pubKeyCredParams": [ "alg": "-7", "type": "public-key" ] }
여기에서 크게 중요한 것은 challenge, rp id가 있습니다.
challenge는 무작위 문자열로 서비스가 요청한 패스키인지, 재전송된 요청인지를 확인하는데 사용합니다.
rp id는 서비스의 주소로 피싱 사이트 등의 로그인을 막고 주소 별 1대1 할당된 패스키를 찾기 위해 사용됩니다.
pubKeyCredParams 는 어떤 보안 알고리즘을 서버가 검증할 수 있는지 알려주는 역할을 합니다.
클라이언트는 이 response를 바탕으로 사용자가 얼굴/지문 인식 등의 방식으로 기기에 대한 인증을 통해 개인키와 공개키를 생성하고 개인키는 보안칩에 저장, 공개키는 attestationObject에 담아 다음과 같은 request로
POST /user/passkey/verify 를 요청합니다.{ "name": "Passkey Name", "icon": "Passkey Icon", "registrationResponse": { "id": "CqSzhuX99amkiIsvM6jWkQ", "rawId": "CqSzhuX99amkiIsvM6jWkQ", "type": "public-key", "response": { "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG...", "attestationObject": "CqSzhuX99amkiIsvM6jWkQ...", "authenticatorData": "CqSzhuX99amkiIsvM6jWkQ...", "transports": "internal", "publicKeyAlgorithm": -7, "publicKey": "CqSzhuX99amkiIsvM6jWkQ..." } } }
해당 요청을 받은 후 서버에서는 clientDataJSON에 담긴 challenge와 origin을 보고 해당 서버에서 요청한 정보가 맞는지 확인하고 attestationObject를 디코딩하여 얻은 공개키를 DB에 저장합니다.
패스키로 로그인
sequenceDiagram participant Client participant Server Client ->>+ Server: Requesting to login using passkey note right of Server: POST /auth/passkey Server ->>- Client: key, challenge, rpId ... Client ->> Client: Recognize fingerprint or face Client ->> Client: Sign with a private key to challenge Client ->>+ Server: key, clientDataJSON, signature, userHandle... note right of Server: POST /auth/passkey/verify Server ->> Server: Verify signature using public key Server ->>- Client: access_token, refresh_token
등록한 패스키로 로그인하기 위해 클라이언트는 먼저 서버로
POST /auth/passkey 로 요청을 보내면 challenge를 발급해 저장하고 다음과 같은 response를 얻습니다.{ "key": "uuid", "challenge": "HPv7vydo...", "timeout": 60000, "rpId": "account.gistory.me", "userVerification": "preferred", }
해당 response를 얻으면 클라이언트는 rpId를 보고 현재 접속해 있는 창의 주소가 일치하는지, 일치한다면 얼굴/지문 인식 후 해당 rpId에 할당되어있는 패스키를 불러옵니다.
패스키를 불러온 후 클라이언트는 개인키를 사용해 challenge를 서명한 후
POST /auth/passkey/verify 로 다음과 같은 요청을 보냅니다.{ "key": "uuid", "authenticationResponse": { "id": "CqSzhuX99amkiIsvM6jWkQ", "rawId": "CqSzhuX99amkiIsvM6jWkQ", "type": "public-key", "response": { "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG...", "authenticatorData": "CqSzhuX99amkiIsvM6jWkQ...", "signature": "CqSzhuX99amkiIsvM6jWkQ...", "userHandle": "CqSzhuX99amkiIsvM6jWkQ..." } } }
요청을 받은 서버는 key를 통해 저장한 challenge를 불러와 clientDataJSON에 저장된 challenge와 같은지 확인하고 challenge로 서명한 값인 signature를 등록된 공개키로 검증합니다.
만약에 검증에 성공했다면 userHandle을 바탕으로 사용자 정보를 얻고 토큰을 반환합니다.
더 자세한 패스키 관련 로직은 인포팀 계정 FE, BE 레포지토리를 참고해보세요.
account-fe
gsainfoteam • Updated Mar 1, 2026
account-be
gsainfoteam • Updated Mar 3, 2026