Skip to content

Identity Verification

Source: en/guide/verification.md · Live: https://docs.1pass.dev/en/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: ## When to use it
  • L50-L55: ## Setup
  • L56-L76: ## The full flow
  • L77-L148: ## 1. Create a request
    • L128-L148: ### Purposes and context slots
  • L149-L163: ## 2. Poll
  • L164-L185: ## 3. Consume (one approval = one execution)
  • L186-L193: ## 4. Cancel
  • L194-L276: ## Verifying the receipt
    • L198-L221: ### Node — with the SDK
    • L222-L255: ### Verifying it yourself
    • L256-L276: ### Claims
  • L277-L298: ## Errors
  • L299-L311: ## Limits and prompt-fatigue defenses
  • L312-L318: ## Things to know
  • L319-L325: ## See also

Identity Verification

This API asks a user's phone "is this really you, and do you approve this action?" at moments unrelated to login — right before a payment, right before an account change. The user reviews the details in the 1pass app and approves or declines, and the RP receives that decision as a signed receipt (JWS).

It does not create a login session. The output is a decision, not a token.

⚠️ Warning: A capability flag is required You enable it per app in the developer console, and even then you must separately select which purposes you may use. Selecting nothing means "all denied."

🚨 Danger: Current status — the user-facing decision screen is not shipped yet The server broker (create, poll, consume, cancel, receipts) works, but there is no screen for the user to approve or decline on. Neither the route behind the verification_url (/verify/:id) returned at creation nor the confirmation sheet in the 1pass app exists yet, so a request you create today gives the user no way to decide and simply expires.

Treat this as a staging-only contract rehearsal for now. Start a production integration after this notice is gone.

When to use it

Use it forUse something else for
Payment approval, transfer confirmationLogin itself → OAuth login
Confirming email, password, or 2FA changesSession re-authentication only → prompt=login
Approving a data exportGating an AI agent's tool call → Agent Approval
Step-up confirmation before a sensitive action

Setup

  1. Enable it in the console — Developer console → your app → "Identity verification requests"
  2. Pick your purposes — only the ones you need. high_risk_action carries a free-text slot, so it only opens if you select it explicitly
  3. You must be a confidential clientclient_id is a public value shipped inside app binaries, so allowing public clients would let anyone who knows it fire approval prompts at arbitrary users. This API accepts HTTP Basic authentication only

The full flow

RP server                     logi                          User's phone
   |                            |                                |
   |-- POST /rp/v1/verifications ->                              |
   |<- auth_req_id, number_match |-- push ---------------------->|
   |                            |                                |
   | (show number_match on your screen)     (user reviews, types
   |                            |          6 digits, Face ID)    |
   |-- GET  .../{id} (poll) ---->|<-------------------------------|
   |<- status: approved, receipt |                                |
   |                            |                                |
   |-- POST .../{id}/consume --->|  (one approval = one execution)
   |<- status: consumed, receipt |
   |                            |
   | (verify the receipt, then execute)

Polling is the default. Webhooks are supported in parallel (verification.requested) — see Webhook integration.

1. Create a request

http
POST /rp/v1/verifications
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/json
json
{
  "user_sub": "the user's pairwise sub",
  "purpose": "payment",
  "idempotency_key": "order-8821-confirm",
  "nonce": "3f9a2b7c8d1e4f60a5b3c2d1e0f9a8b7",
  "binding_digest": "SHA-256 hex(64) of the action you will execute",
  "context": { "amount": 12000, "currency": "KRW", "merchant": "Acme Store" },
  "requested_expiry": 180,
  "number_match_required": true
}
FieldRequiredDescription
user_subThe sub you received at login. The value differs per app (pairwise)
purposeOne of the five below, and only if allowed in the console
idempotency_keySame key + same payload returns the existing request
nonceBinds the receipt to this request. At least 16 characters, freshly generated each time
binding_digestTies the approved action to the one you actually execute. Re-submitted at consume
contextper purposeWhat the approval screen shows. Required for payment and account_change
requested_expirySeconds, clamped to 60–600. Defaults to 180
number_match_requiredRequires a 6-digit confirmation code. Always on for payment and account_change, regardless of what you send

Response:

json
{
  "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 is meant to be shown on your screen. The user types those six digits on their phone for the approval to count. It never appears in the push notification
  • delivery_targets: 0 means there is no device to push to. Fall back to another confirmation channel immediately
  • reused: true means an in-flight request for the same (user, app, purpose) was returned instead of a new one

Purposes and context slots

logi composes the sentence on the approval screen. The RP supplies typed values only. Free text there would become a surface for impersonation — "this is the logi security team."

PurposeContext slotsConfirmation code
paymentamount int required · currency [A-Z]{3} required · merchant ≤40Always
account_changefield (email|phone|password|2fa) required · masked_new_value ≤40Always
data_exportdata_kind ≤40 required · destination ≤40Optional
high_risk_actionaction_label ≤40 requiredOptional
login_step_upNoneOptional

A purpose with any required slot cannot omit context at all — that covers payment, account_change, data_export, and high_risk_action. An empty object ({}) counts as omission. This is a separate axis from the "confirmation number" column.

💡 Tip: amount is an integer in the minor currency unit Per the ISO 4217 exponent — won and yen as-is for KRW/JPY, cents for USD. Decimals (12000.0) and formatted strings ("12,000") are rejected: one amount arriving as two representations would split the approval-screen digest, and formatting is the phone's locale responsibility.

The only purpose you can omit context for is login_step_up, which declares no required slot; there the phone shows just your app name and the purpose label. The other four reject both omission and an empty object ({}) with 422 — a payment approval screen with no amount and no target is a blind approval, and the same reasoning covers the exported data kind (data_kind) and the action name (action_label).

binding_message returns 422 for every purpose. Use the slots above.

2. Poll

http
GET /rp/v1/verifications/{auth_req_id}
Authorization: Basic ...
json
{ "auth_req_id": "vrq_...", "status": "approved", "receipt": "eyJ..." }

Poll at the returned interval (seconds). status is one of pending, approved, denied, expired, consumed.

Decided requests carry a receipt. Before a decision, or more than 300 seconds after one, the key is absent entirely.

3. Consume (one approval = one execution)

This is where an approval becomes permission to execute. Always go through it.

http
POST /rp/v1/verifications/{auth_req_id}/consume
Authorization: Basic ...

{ "binding_digest": "SHA-256 hex(64) of the action you will execute" }
json
{ "auth_req_id": "vrq_...", "status": "consumed", "receipt": "eyJ..." }
  • A digest that differs from the one sent at creation returns 409 binding_mismatch — this is what stops an action other than the one the user saw from being executed
  • A second call returns 409 not_consumable. One approval is used once
  • After 300 seconds the approval can no longer be consumed
  • If the user disconnected your app in the meantime, 403 app_not_connected

Executing on status: "approved" alone discards all of these protections.

4. Cancel

http
POST /rp/v1/verifications/{auth_req_id}/cancel

The RP withdraws its own request, clearing it from the user's screen. Only pending requests qualify; an already-decided one returns 409 not_cancellable.

Verifying the receipt

The receipt is an RS256 JWS. Its whole point is that it verifies without trusting logi, so verify it rather than believing the status field. The signing key is the same JWKS you already use for login — there is no new key to deploy.

Node — with the SDK

bash
npm i @logi-auth/server
js
import { verifyVerificationReceipt } from "@logi-auth/server";

// Fetch jwks from https://api.1pass.dev/.well-known/jwks.json and cache it
const decision = await verifyVerificationReceipt(receipt, {
  jwks,
  expected: {
    issuer:   "https://api.1pass.dev",
    clientId: process.env.LOGI_CLIENT_ID,
    nonce,          // the value you sent when creating the request
    bindingDigest,  // the action you are about to execute
  },
});
// decision.sub / .authReqId / .purpose / .decidedAt — safe to act on now

It checks every axis below and throws a ReceiptError carrying a code that names which one failed. Pass expected.decision: "denied" to verify a decline (the default is "approved").

Verifying it yourself

js
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,
  });

  // Cut off, at the header, any attempt to swap in an id_token signed by the same key
  if (protectedHeader.typ !== "verification_receipt+jwt") throw new Error("not a receipt");

  // Is this receipt for the request I just created?
  if (payload.nonce !== nonce) throw new Error("nonce mismatch");
  if (payload.decision !== "approved") throw new Error("not approved");

  // Is the action I am about to execute the one that was approved?
  if (bindingDigest && payload.binding_digest !== bindingDigest) {
    throw new Error("binding mismatch");
  }

  return payload;
}

🚨 Danger: Check all four Verifying only the signature lets through another RP's receipt, another request's receipt, a stale receipt, and a receipt for a different action. aud, nonce, exp, and binding_digest only mean something when checked alongside the signature.

Claims

ClaimValue
iss / audlogi issuer / your client_id
subThe user's pairwise sub (the same value you got at login)
jtiauth_req_id — one per request
iat / expDecision time / decision time + 300s
nonceThe value you sent at creation
purpose / decision / decided_at / auth_timePurpose, approved|denied, decision time
display_digestHash of everything the user actually saw. You never receive this value through any other channel, so treat it as an audit record rather than something to compare against
binding_digestThe approved action binding (omitted if the request had none)
amr / acrOnly when passkey user verification (UV) took place. Omitted when there is no evidence

Receipts are deterministic. Polling the same decision repeatedly yields byte-identical tokens, so an implementation that blocks replay by jti will not reject a legitimate retry after a dropped response.

Declines (denied) produce a receipt too — you need to be able to record that the user explicitly refused.

💡 Tip: You can run the verifier as-is server/script/verify_verification_receipt.rb in the repository verifies a receipt using nothing but the JWKS, without requiring a single line of logi server code. It checks all four axes above and is useful for confirming behavior against a real receipt before you integrate.

Errors

StatusCodeMeaning
401invalid_clientBasic authentication failed, or a public client
403verification_not_enabledThe capability is off in the console
403purpose_not_enabledEnabled, but this purpose is not allowed
403app_not_connectedThe user is not connected to this app (or disconnected it)
403blocked_by_userThe user blocked verification requests from this app
422invalid_requestMissing field, bad format, slot schema violation, or use of binding_message
422unknown_purposePurpose is outside the defined five
422user_unresolveduser_sub does not resolve to a user
409idempotency_conflictThe same key was reused with different content
409in_flight_conflictA different request for the same user, app, and purpose is already outstanding
409binding_mismatchThe action being consumed differs from the approved one
409not_consumableNot approved, already consumed, or the consume window elapsed
409not_cancellableAn already-decided request cannot be cancelled
429rate_limitedLimit exceeded (see Retry-After)
429cool_down_activeCooling down after a decline or non-response (see Retry-After)

purpose_not_enabled is decided before the rest of the payload is validated. A capability you do not hold should not even expose its validation behavior.

Limits and prompt-fatigue defenses

If approval prompts could be fired without limit, users would eventually approve out of habit. Four layers prevent that.

DefenseDetail
In-flight suppressionOne outstanding request per (user, app, purpose). Identical content returns the existing one; different content returns 409
Decline cool-down60 seconds for the first decline within an hour, 10 minutes from the second
Non-response cool-down15 minutes after three consecutive unanswered prompts
Rate limits60 per minute per app, 10 per hour per (user, app)

Users can block verification requests from a specific RP outright in the 1pass app. Blocked requests return 403 blocked_by_user, and the RP cannot lift it.

Things to know

  • The lock screen shows only your app name and the purpose label. No amount, no confirmation code, no RP-supplied string of any kind. logi always authors what appears there
  • Requests that fail to resolve a user still count against limits. A separate throttle exists to stop enumeration of which user_sub values are real
  • Receipt exp is 300 seconds, matching the consume window. Collecting approvals to use later is not a supported design
  • Device-signed receipts do not exist yet. Today the signer is the logi server. Passkey signing requires solving credential public-key exposure to RPs and changing WebAuthn challenge derivation first, so it remains follow-up work

See also

최종 수정:

Identity가 제품의 신뢰를 만듭니다.