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 for | Use something else for |
|---|---|
| Payment approval, transfer confirmation | Login itself → OAuth login |
| Confirming email, password, or 2FA changes | Session re-authentication only → prompt=login |
| Approving a data export | Gating an AI agent's tool call → Agent Approval |
| Step-up confirmation before a sensitive action |
Setup
- Enable it in the console — Developer console → your app → "Identity verification requests"
- Pick your purposes — only the ones you need.
high_risk_actioncarries a free-text slot, so it only opens if you select it explicitly - You must be a confidential client —
client_idis 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
POST /rp/v1/verifications
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/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
}| Field | Required | Description |
|---|---|---|
user_sub | ✅ | The sub you received at login. The value differs per app (pairwise) |
purpose | ✅ | One of the five below, and only if allowed in the console |
idempotency_key | ✅ | Same key + same payload returns the existing request |
nonce | ✅ | Binds the receipt to this request. At least 16 characters, freshly generated each time |
binding_digest | Ties the approved action to the one you actually execute. Re-submitted at consume | |
context | per purpose | What the approval screen shows. Required for payment and account_change |
requested_expiry | Seconds, clamped to 60–600. Defaults to 180 | |
number_match_required | Requires a 6-digit confirmation code. Always on for payment and account_change, regardless of what you send |
Response:
{
"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_matchis 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 notificationdelivery_targets: 0means there is no device to push to. Fall back to another confirmation channel immediatelyreused: truemeans 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."
| Purpose | Context slots | Confirmation code |
|---|---|---|
payment | amount int required · currency [A-Z]{3} required · merchant ≤40 | Always |
account_change | field (email|phone|password|2fa) required · masked_new_value ≤40 | Always |
data_export | data_kind ≤40 required · destination ≤40 | Optional |
high_risk_action | action_label ≤40 required | Optional |
login_step_up | None | Optional |
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
GET /rp/v1/verifications/{auth_req_id}
Authorization: Basic ...{ "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.
POST /rp/v1/verifications/{auth_req_id}/consume
Authorization: Basic ...
{ "binding_digest": "SHA-256 hex(64) of the action you will execute" }{ "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
POST /rp/v1/verifications/{auth_req_id}/cancelThe 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
npm i @logi-auth/serverimport { 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 nowIt 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
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, andbinding_digestonly mean something when checked alongside the signature.
Claims
| Claim | Value |
|---|---|
iss / aud | logi issuer / your client_id |
sub | The user's pairwise sub (the same value you got at login) |
jti | auth_req_id — one per request |
iat / exp | Decision time / decision time + 300s |
nonce | The value you sent at creation |
purpose / decision / decided_at / auth_time | Purpose, approved|denied, decision time |
display_digest | Hash 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_digest | The approved action binding (omitted if the request had none) |
amr / acr | Only 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.rbin 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
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_client | Basic authentication failed, or a public client |
| 403 | verification_not_enabled | The capability is off in the console |
| 403 | purpose_not_enabled | Enabled, but this purpose is not allowed |
| 403 | app_not_connected | The user is not connected to this app (or disconnected it) |
| 403 | blocked_by_user | The user blocked verification requests from this app |
| 422 | invalid_request | Missing field, bad format, slot schema violation, or use of binding_message |
| 422 | unknown_purpose | Purpose is outside the defined five |
| 422 | user_unresolved | user_sub does not resolve to a user |
| 409 | idempotency_conflict | The same key was reused with different content |
| 409 | in_flight_conflict | A different request for the same user, app, and purpose is already outstanding |
| 409 | binding_mismatch | The action being consumed differs from the approved one |
| 409 | not_consumable | Not approved, already consumed, or the consume window elapsed |
| 409 | not_cancellable | An already-decided request cannot be cancelled |
| 429 | rate_limited | Limit exceeded (see Retry-After) |
| 429 | cool_down_active | Cooling 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.
| Defense | Detail |
|---|---|
| In-flight suppression | One outstanding request per (user, app, purpose). Identical content returns the existing one; different content returns 409 |
| Decline cool-down | 60 seconds for the first decline within an hour, 10 minutes from the second |
| Non-response cool-down | 15 minutes after three consecutive unanswered prompts |
| Rate limits | 60 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_subvalues are real - Receipt
expis 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
- API reference — full request and response schemas
- Webhook integration — push instead of poll
- Rate Limits
- Agent Approval — gating AI agent tool calls (a different surface)