사용자 신원 재확인 (Verification)
Source:
guide/verification.md· Live: https://docs.1pass.dev/guide/verification LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L41-L49: ## 언제 쓰나
- L50-L55: ## 준비
- L56-L76: ## 전체 흐름
- L77-L148: ## 1. 요청 생성
- L128-L148: ### purpose 와 context 슬롯
- L149-L163: ## 2. 폴링
- L164-L185: ## 3. 소비 (1승인 = 1실행)
- L186-L193: ## 4. 취소
- L194-L276: ## 영수증 검증
- L198-L221: ### Node — SDK 로
- L222-L255: ### 직접 검증할 때
- L256-L276: ### 클레임
- L277-L298: ## 에러
- L299-L311: ## 한도와 프롬프트 피로 방어
- L312-L318: ## 알아둘 것
- L319-L325: ## 함께 보기
사용자 신원 재확인 (Verification)
결제 직전, 계정 정보 변경 직전처럼 로그인과 무관한 시점에 "지금 이 사람이 본인이 맞는지, 이 작업을 승인하는지"를 사용자 폰에 묻는 API다. 사용자는 1pass 앱에서 내용을 확인하고 승인하거나 거절하며, RP 는 그 결정을 서명된 영수증(JWS) 으로 받는다.
로그인 세션을 새로 만들지 않는다. 산출물은 토큰이 아니라 결정이다.
⚠️ Warning: 능력 플래그가 필요하다 개발자 콘솔에서 앱별로 켜야 하고, 켠 뒤에도 어떤 purpose 를 쓸지 따로 선택해야 한다. 아무것도 선택하지 않은 상태는 "전부 금지"다.
🚨 Danger: 현재 구현 상태 — 사용자 결정 화면은 준비 중 서버 브로커(생성·폴링·소비·취소·영수증)는 동작하지만, 사용자가 승인/거절할 화면이 아직 없다. 생성 응답의
verification_url(/verify/:id)에 대응하는 라우트와 1pass 앱의 확인 시트가 미구현이라, 지금 요청을 만들면 사용자는 결정할 수단이 없어 그대로 만료된다.
지금은 스테이징에서 계약을 맞춰보는 용도로만 쓴다. 실사용 통합은 이 안내가 사라진 뒤에 시작하는 것이 맞다.
언제 쓰나
| 쓴다 | 쓰지 않는다 |
|---|---|
| 결제 승인, 송금 확인 | 로그인 자체 → OAuth 로그인 |
| 이메일·비밀번호·2FA 변경 확인 | 세션 재인증만 필요 → prompt=login |
| 데이터 내보내기 승인 | AI 에이전트의 도구 실행 게이팅 → Agent Approval |
| 민감 작업 전 본인 확인(step-up) |
준비
- 콘솔에서 켠다 — 개발자 콘솔 → 앱 → "신원 재확인 요청" 체크
- purpose 를 고른다 — 필요한 것만.
high_risk_action은 자유 문구 지면이라 명시적으로 골라야 열린다 - confidential client 여야 한다 —
client_id는 앱 바이너리에 담기는 공개값이라, public client 를 허용하면 그 값을 아는 누구나 임의 사용자에게 승인 프롬프트를 쏠 수 있다. 이 API 는 HTTP Basic 인증만 받는다
전체 흐름
RP 서버 logi 사용자 폰
| | |
|-- POST /rp/v1/verifications -> |
|<- auth_req_id, number_match |-- 푸시 ---------------------->|
| | |
| (number_match 를 내 화면에 표시) (사용자가 내용 확인 →
| | 6자리 입력 → FaceID)|
|-- GET .../{id} (폴링) ---->|<-------------------------------|
|<- status: approved, receipt | |
| | |
|-- POST .../{id}/consume --->| (1승인 = 1실행 바인딩)
|<- status: consumed, receipt |
| |
| (영수증 검증 후 실제 작업 실행)폴링이 기본이다. 웹훅도 동시에 지원한다(verification.requested 이벤트) — 자세한 것은 Webhook 연동.
1. 요청 생성
POST /rp/v1/verifications
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/json{
"user_sub": "사용자의 pairwise sub",
"purpose": "payment",
"idempotency_key": "order-8821-confirm",
"nonce": "3f9a2b7c8d1e4f60a5b3c2d1e0f9a8b7",
"binding_digest": "실행할 작업의 SHA-256 hex(64)",
"context": { "amount": 12000, "currency": "KRW", "merchant": "OO상점" },
"requested_expiry": 180,
"number_match_required": true
}| 필드 | 필수 | 설명 |
|---|---|---|
user_sub | ✅ | 로그인 때 받은 그 사용자의 sub. 앱마다 값이 다르다(pairwise) |
purpose | ✅ | 아래 5종 중 하나. 콘솔에서 허용한 것만 |
idempotency_key | ✅ | 같은 키 + 같은 페이로드 재요청은 기존 요청을 그대로 돌려준다 |
nonce | ✅ | 영수증이 이 요청의 것임을 묶는다. 16자 이상, 매번 새로 만든다 |
binding_digest | 승인받은 작업과 실제 실행할 작업을 묶는 해시. consume 때 다시 낸다 | |
context | purpose 별 | 승인 화면에 보여줄 내용. payment·account_change 는 필수 |
requested_expiry | 초. 60~600 사이로 잘린다. 기본 180 | |
number_match_required | 6자리 확인번호를 요구한다. payment·account_change 는 지정 여부와 무관하게 항상 켜진다 |
응답:
{
"auth_req_id": "vrq_...",
"status": "pending",
"expires_in": 180,
"interval": 2,
"number_match": "042917",
"delivery_targets": 2,
"verification_url": "https://api.1pass.dev/verify/vrq_...",
"reused": false
}number_match는 내 화면에 표시하는 값이다. 사용자가 폰에서 이 6자리를 직접 입력해야 승인이 성립한다. 푸시 알림에는 절대 담기지 않는다delivery_targets가0이면 사용자에게 푸시를 보낼 기기가 없다. 즉시 다른 확인 수단으로 넘어가야 한다reused: true는 같은 (사용자, 앱, purpose) 로 이미 떠 있는 요청을 돌려줬다는 뜻이다
purpose 와 context 슬롯
승인 화면의 문장을 조립하는 것은 logi 다. RP 는 타입이 정해진 값만 채운다. 자유 문구를 허용하면 그 자리가 "logi 보안팀입니다" 같은 사칭 지면이 되기 때문이다.
| purpose | context 슬롯 | 확인번호 |
|---|---|---|
payment | amount 정수 필수 · currency [A-Z]{3} 필수 · merchant ≤40자 | 항상 |
account_change | field (email|phone|password|2fa) 필수 · masked_new_value ≤40자 | 항상 |
data_export | data_kind ≤40자 필수 · destination ≤40자 | 선택 |
high_risk_action | action_label ≤40자 필수 | 선택 |
login_step_up | 없음 | 선택 |
필수 슬롯이 하나라도 있는 purpose 는 context 자체를 생략할 수 없다 — 위 네 purpose(payment·account_change·data_export·high_risk_action)가 여기 해당한다. 빈 객체({})도 생략과 같게 본다. "확인번호 선택"과는 별개 축이다.
💡 Tip: amount 는 최소 화폐 단위 정수 ISO 4217 exponent 기준이다 — KRW·JPY 는 원·엔 그대로, USD 는 센트. 소수(
12000.0)나 서식 문자열("12,000")은 거부한다. 같은 금액이 두 표현으로 들어오면 승인 화면 해시가 갈리고, 서식은 폰의 로케일 책임이기 때문이다.
context 를 생략할 수 있는 것은 필수 슬롯이 없는 login_step_up 뿐이고, 그때 폰은 앱 이름과 purpose 라벨만 보여준다. 나머지 네 purpose 는 생략·빈 객체 모두 422 다 — 금액도 대상도 없는 결제 승인 화면은 blind approve 이고, 같은 이유가 내보낼 데이터 종류(data_kind)와 행동 이름(action_label)에도 적용된다.
binding_message 는 모든 purpose 에서 422 다. 위 슬롯을 쓴다.
2. 폴링
GET /rp/v1/verifications/{auth_req_id}
Authorization: Basic ...{ "auth_req_id": "vrq_...", "status": "approved", "receipt": "eyJ..." }interval(초) 간격으로 조회한다. status 는 pending · approved · denied · expired · consumed 다.
결정이 난 요청에는 receipt 가 함께 온다. 아직 결정 전이거나 결정 후 300초가 지나면 키 자체가 빠진다.
3. 소비 (1승인 = 1실행)
승인을 실제 실행 권한으로 바꾸는 지점이다. 반드시 거친다.
POST /rp/v1/verifications/{auth_req_id}/consume
Authorization: Basic ...
{ "binding_digest": "실행할 작업의 SHA-256 hex(64)" }{ "auth_req_id": "vrq_...", "status": "consumed", "receipt": "eyJ..." }- 생성 때 낸
binding_digest와 다르면 409binding_mismatch— 사용자가 본 것과 다른 작업이 실행되는 것을 막는다 - 두 번째 호출은 409
not_consumable— 한 승인은 한 번만 쓴다 - 승인 후 300초가 지나면 소비할 수 없다
- 사용자가 그 사이 앱 연결을 끊었으면 403
app_not_connected
status: "approved" 만 보고 실행하면 이 보호가 전부 무효가 된다.
4. 취소
POST /rp/v1/verifications/{auth_req_id}/cancelRP 가 자기 요청을 접는다(사용자 화면에서 사라진다). pending 만 대상이고, 이미 결정된 요청은 409 not_cancellable.
영수증 검증
영수증은 RS256 JWS 다. logi 를 신뢰하지 않고 검증할 수 있다는 점이 존재 이유이므로, 폴링 응답의 status 를 믿는 대신 이것을 검증한다. 서명키는 로그인에 쓰는 그 JWKS 와 같다 — 새로 배포할 키가 없다.
Node — SDK 로
npm i @logi-auth/serverimport { verifyVerificationReceipt } from "@logi-auth/server";
// jwks 는 https://api.1pass.dev/.well-known/jwks.json 에서 받아 캐시해 둔다
const decision = await verifyVerificationReceipt(receipt, {
jwks,
expected: {
issuer: "https://api.1pass.dev",
clientId: process.env.LOGI_CLIENT_ID,
nonce, // 요청 생성 때 보낸 그 값
bindingDigest, // 지금 실행하려는 작업
},
});
// decision.sub / .authReqId / .purpose / .decidedAt — 이제 실행해도 되는 상태다아래 축을 전부 확인하고, 실패하면 어떤 축이 틀렸는지 code 가 붙은 ReceiptError 를 던진다. 거절 영수증을 검증하려면 expected.decision: "denied" 를 넘긴다(기본값은 "approved").
직접 검증할 때
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(new URL("https://api.1pass.dev/.well-known/jwks.json"));
async function verifyReceipt(receipt, { clientId, nonce, bindingDigest }) {
const { payload, protectedHeader } = await jwtVerify(receipt, JWKS, {
algorithms: ["RS256"],
issuer: "https://api.1pass.dev",
audience: clientId,
clockTolerance: 60,
});
// 같은 키로 서명된 id_token 을 영수증 자리에 끼워넣는 swap 을 헤더에서 끊는다
if (protectedHeader.typ !== "verification_receipt+jwt") throw new Error("not a receipt");
// 이 영수증이 방금 내가 만든 그 요청의 것인지
if (payload.nonce !== nonce) throw new Error("nonce mismatch");
if (payload.decision !== "approved") throw new Error("not approved");
// 지금 실행하려는 작업이 승인받은 그 작업인지
if (bindingDigest && payload.binding_digest !== bindingDigest) {
throw new Error("binding mismatch");
}
return payload;
}🚨 Danger: 네 가지를 모두 봐야 한다 서명만 검증하면 다른 RP 의 영수증, 다른 요청의 영수증, 오래된 영수증, 다른 작업의 영수증이 전부 통과한다.
aud·nonce·exp·binding_digest는 서명과 함께 봐야 의미가 있다.
클레임
| 클레임 | 값 |
|---|---|
iss / aud | logi issuer / 내 client_id |
sub | 사용자의 pairwise sub (로그인 때 받은 값과 같다) |
jti | auth_req_id — 요청당 하나 |
iat / exp | 결정 시각 / 결정 시각 + 300초 |
nonce | 생성 때 보낸 값 |
purpose / decision / decided_at / auth_time | purpose, approved|denied, 결정 시각 |
display_digest | 사용자가 실제로 본 화면 전체의 해시. RP 는 이 값을 다른 경로로 받지 않으므로 대조 대상이 아니라 감사 기록이다 |
binding_digest | 승인된 작업 바인딩 (요청에 없었으면 생략) |
amr / acr | 패스키 사용자 확인(UV)을 거쳤을 때만. 증거가 없으면 생략한다 |
영수증은 결정론적이다. 같은 결정을 여러 번 조회해도 바이트가 같다. 첫 응답을 놓치고 다시 폴링해도 같은 토큰이 오므로, jti 로 재생을 막는 구현이 정상적인 재시도까지 거절하지 않는다.
거절(denied)에도 영수증이 나온다. "사용자가 명시적으로 거절했다"를 기록에 남길 수 있어야 하기 때문이다.
💡 Tip: 검증기를 그대로 돌려볼 수 있다 저장소의
server/script/verify_verification_receipt.rb는 logi 서버 코드를 한 줄도 쓰지 않고 JWKS 만으로 영수증을 검증하는 독립 스크립트다. 위 네 축을 모두 확인하며, 통합 전에 실제 영수증을 넣어 동작을 확인하는 용도로 쓸 수 있다.
에러
| 상태 | 코드 | 뜻 |
|---|---|---|
| 401 | invalid_client | Basic 인증 실패, 또는 public client |
| 403 | verification_not_enabled | 콘솔에서 이 기능이 꺼져 있다 |
| 403 | purpose_not_enabled | 기능은 켜졌지만 이 purpose 는 허용되지 않았다 |
| 403 | app_not_connected | 사용자가 이 앱과 연결돼 있지 않다(또는 연결을 끊었다) |
| 403 | blocked_by_user | 사용자가 이 앱의 확인 요청을 차단했다 |
| 422 | invalid_request | 필드 누락·형식 오류·슬롯 스키마 위반·binding_message 사용 |
| 422 | unknown_purpose | purpose 가 정의된 5종 밖이다 |
| 422 | user_unresolved | user_sub 로 사용자를 찾을 수 없다 |
| 409 | idempotency_conflict | 같은 키를 다른 내용으로 재사용했다 |
| 409 | in_flight_conflict | 같은 사용자·앱·purpose 로 다른 내용의 요청이 이미 떠 있다 |
| 409 | binding_mismatch | 소비하려는 작업이 승인받은 작업과 다르다 |
| 409 | not_consumable | 미승인·이미 소비됨·소비 창 만료 |
| 409 | not_cancellable | 이미 결정된 요청은 취소할 수 없다 |
| 429 | rate_limited | 한도 초과 (Retry-After 참조) |
| 429 | cool_down_active | 거절·무응답 뒤 쿨다운 중 (Retry-After 참조) |
purpose_not_enabled 는 나머지 페이로드 검증보다 먼저 판정한다. 갖고 있지 않은 능력에 대해서는 검증 동작조차 노출하지 않는다.
한도와 프롬프트 피로 방어
승인 프롬프트를 무한정 쏠 수 있으면 사용자는 결국 습관적으로 승인하게 된다. 네 겹으로 막는다.
| 방어 | 내용 |
|---|---|
| in-flight 억제 | 같은 (사용자, 앱, purpose) 로 떠 있는 요청은 1개. 같은 내용이면 기존 것을 돌려주고, 다르면 409 |
| 거절 쿨다운 | 1시간 내 첫 거절 60초, 두 번째부터 10분 |
| 무응답 쿨다운 | 연속 3회 무응답이면 15분 |
| 레이트리밋 | 앱당 분당 60건, (사용자, 앱) 당 시간당 10건 |
사용자는 1pass 앱에서 특정 RP 의 확인 요청을 아예 차단할 수 있다. 차단되면 403 blocked_by_user 이고, 이 상태는 RP 가 해제할 수 없다.
알아둘 것
- 잠금화면에는 앱 이름과 purpose 라벨만 뜬다. 금액도, 확인번호도, RP 가 보낸 어떤 문자열도 담기지 않는다. 잠금화면 문자열의 저자는 항상 logi 다
- 사용자를 찾지 못한 요청도 한도에 걸린다.
user_sub를 바꿔가며 실재 여부를 캐내는 열거를 막기 위해 별도 스로틀이 붙어 있다 - 영수증의
exp는 300초이고 소비 창과 같다. 승인을 받아두고 나중에 쓰는 설계는 성립하지 않는다 - 기기 서명 영수증은 아직 없다. 현재 영수증의 서명자는 logi 서버다. 기기(패스키) 서명은 credential 공개키가 RP 에 노출되는 문제와 WebAuthn challenge 유도 변경이 선행돼야 해서 후속 과제다
함께 보기
- API 레퍼런스 — 요청·응답 스키마 전체
- Webhook 연동 — 폴링 대신 밀어받기
- Rate Limits
- Agent Approval — AI 에이전트의 도구 실행 게이팅(다른 표면이다)