A QR code, a camera, and a question
You’ve seen the flow, maybe without thinking about it: a kiosk or a web page shows a QR code, the user opens a mobile app, points the camera at it, and a moment later the app asks, “Do you want to share proof of employment with this organization?”
That moment looks like magic, but it is a protocol conversation. Inside those few seconds, a verifier has stated exactly what it needs, the user’s device has found a credential that satisfies the request, the user has decided to share it, and a signed response has gone back for verification.
This article walks through that conversation from the side developers actually ship: an iOS application that is the wallet. We’ll look at what a digital wallet really is under the hood, and then integrate the PingOne Wallet SDK for iOS step by step — pairing, credential issuance, and handling an OpenID4VP presentation request. By the end, the QR-code moment should feel less like magic and more like a state machine you can debug.
Decentralized identity: Proof moves into the user’s hands
For most of the history of digital identity, proof lived on servers. A login produces a token; a token is checked by another server. The user never holds anything — they hold a session at best. Every “proof of who you are” depended on a central party being online and agreeing to vouch for you, again and again.
Decentralized identity inverts this. Trust no longer flows through a central service at verification time; it is carried in cryptographically signed data that the user holds. Two building blocks make that possible:
- Verifiable Credentials (VCs): A W3C standard format for tamper-evident statements — “this organization asserts this person is an employee with this title,” signed by the issuer. A credential in a wallet is a verifiable credential (or a closely related format), plus the cryptographic material that proves it belongs to the device presenting it.
- Decentralized Identifiers (DIDs): A W3C standard for identifiers the user controls, resolvable to public keys without a phone home to a central identity provider. Wallets and issuers use DIDs as the signing and addressing layer: the issuer signs with its DID, the wallet proves possession of its DID’s private key, and the verifier checks signatures against the DID documents. The iOS SDK this article uses ships a DID SDK for exactly this layer.
Put together, the model looks like this: the user’s device holds a credential — issued once by an organization the user trusts — and presents it to whoever needs verification, when it is needed. Ping’s framing puts it well: a credential is like a long-lived SAML assertion or OIDC ID token that doesn’t have to be reissued on every sign-on. An “Employee” credential might carry your employer and job title; an application verifies it and acts on what it says.
Three roles make this work, and it is worth naming them because they map to real products:
- Issuer: The organization that vouches for the claims and signs the credential (for example, an employer issuing an Employee credential).
- Holder: The user, whose wallet app stores and guards the credential.
- Verifier: The application or service that requests and checks the credential.
%%{init: {'flowchart': {'curve': 'linear'}}}%%
flowchart LR
I["Issuer"]
H["Holder\n(wallet)"]
V["Verifier"]
I -- "1. issues & signs credential" --> H
H -- "2. presents proof (when asked)" --> V
V -. "3. checks signature against issuer DID" .-> I
The decentralized identity trust triangle: the issuer signs once, the holder presents on demand, and the verifier checks the signature without calling the issuer.
Notice what is absent: No server needs to be online to vouch for the user at step 2. The verifier can check the issuer’s signature on its own, from the credential and the public key material. That is the “decentralized” in decentralized identity — trust travels with the credential instead of being mediated live by an identity provider.
The credentials move over open standards. OpenID4VP (OpenID for Verifiable Presentations) defines how a verifier states what it needs and how the wallet answers. PingOne supports OpenID4VP-based credentials, which means a PingOne-issued credential can be verified by other standards-compliant products, along with a PingOne-proprietary format that adds capabilities inside the Ping ecosystem. Knowing which format you’re working with matters for interoperability decisions, and we’ll return to that in the limitations.
What a wallet actually is
Before the code, a mental model. A digital wallet is two things:
- A key store: Credentials are cryptographically bound to the device. On iOS this means keys live in the Secure Enclave and data in the keychain — which is also why a wallet doesn’t run on a simulator. The wallet signs challenges with these keys, which is how a verifier knows the credential is being presented by the device it was issued to.
- A state machine: The wallet moves through a small number of states — unpaired → paired → receiving → presenting — and every interesting event (a pairing request, a new credential, a verification request) is a message that arrives from the outside and pushes the machine to its next state.
That second point is the one that saves you debugging time. When your integration misbehaves, the question is almost never “which of fifty settings is wrong?” It is “which message arrived, and what state did it move us to?” The SDK’s design makes this visible, as the walkthrough shows.
The walkthrough: Integrating the Wallet SDK on iOS
We’ll build this in four stages:
- Instantiate the wallet client and get an identity for the wallet instance.
- Pair the wallet with a user in a PingOne environment.
- Receive credentials (issuance and revocation).
- Handle a verification request: match, consent, present.
The snippets below are illustrative, adapted from the PingOne Wallet SDK sample app. They show the shape of the integration; the sample app in the SDK repository is the complete, tested reference.
Stage 1: Instantiate the client and get the wallet’s identity
The wallet SDK instance in your app has an applicationInstanceId — a UUID that uniquely identifies it on the PingOne platform. Your app creates the client with a builder, then reads that identifier:
let clientBuilder = PingOneWalletClient.Builder(forRegion: PingOneRegion.NA)
// Set a storage manager for encrypted local storage
if let storageManager = storageManager {
clientBuilder.setStorageManager(storageManager)
}
clientBuilder.build()
.onError { error in
// handle initialization failure
}
.onResult { client in
// The applicationInstanceId identifies this wallet instance
let applicationInstanceId = client.getApplicationInstance(
forRegion: PingOneRegion.NA)?.getId()
// Send it to a trusted backend service (next stage)
}
Two things are happening that are worth noticing. First, the builder pattern gives you async success and failure paths (onResult / onError) — this convention runs through the whole SDK. Second, the applicationInstanceId is not a user identifier. It identifies this wallet instance on this device. Connecting it to a user is the next stage, and it is deliberately not the app’s job to do that directly.
Stage 2: Pair the wallet with a user
A wallet only becomes useful when it is bound to a user in a PingOne environment. The design principle here matters: your mobile app should not call PingOne OAuth APIs directly. Instead, the app sends the applicationInstanceId to a backend service it already trusts, and the backend performs the pairing:
- Your app authenticates the user with whatever mechanism it already uses.
- The app sends the
applicationInstanceIdto your trusted backend. - The backend calls the PingOne API to create a digital wallet for the user, passing the
applicationInstanceIdin the request body. - PingOne sends a pairing request to the wallet.
The pairing request itself is a nice example of the security model. It arrives as an end-to-end encrypted message containing a cryptographically random challenge string. The wallet signs the challenge with its device keys and returns the signature — proving, without any shared secret having crossed the network, that the request reached the device it was meant for.
Your app learns about the pairing request through a callback:
func handleCredentialRequest(_ presentationRequest: PresentationRequest) {
guard let pairingRequest = presentationRequest.getPairingRequest() else {
return // not a pairing request; handle as a presentation (Stage 4)
}
pingoneWalletClient.pairWallet(for: pairingRequest)
.onResult { _ in
// wallet paired with the user
}
.onError { err in
// pairing failed
}
}
From here on, the wallet is paired: it is bound to a user and ready to hold credentials.
Stage 3: Receive credentials
Credentials reach the wallet over the PingOne Mailbox service, and the delivery model explains most issuance questions before they become support tickets:
- When a user’s attributes change, PingOne’s credential lifecycle manager evaluates whether a credential should be issued or revoked, stages the change, and a periodic process issues or revokes accordingly. Issuance is scheduled, not real-time — if you’re testing and don’t see a credential arrive, check whether the next processing cycle has run.
- Issued and revoked credentials travel to the wallet as end-to-end encrypted mailbox messages.
- The SDK polls the mailbox (every 3 seconds by default) and calls your app through the callback handler when something arrives.
The callbacks mirror the lifecycle:
func handleCredentialIssuance(issuer: String,
message: String?,
challenge: Challenge?,
claim: Claim,
errors: [PingOneWallet.WalletException]) -> Bool {
// Persist the credential to your secure storage
dataRepository.saveCredential(claim)
return true
}
func handleCredentialRevocation(issuer: String,
message: String?,
challenge: Challenge?,
claimReference: ClaimReference,
errors: [PingOneWallet.WalletException]) -> Bool {
// Remove or mark the revoked credential
dataRepository.saveCredentialReference(claimReference)
return true
}
Note what the SDK does not do here: it doesn’t decide where credentials live in your app. Storage is yours — the sample app uses a DataRepository backed by Secure Enclave keys, and the protocol it implements is designed to be replaced with your own implementation.
Stage 4: The verification request — the heart of the flow
Now the QR-code moment. A verifier shows a code (or sends a deep link, or a push notification); the SDK parses it, fetches the presentation request, and calls the same handleCredentialRequest callback you saw in Stage 2. This time it is not a pairing request, and your job is to answer three questions in order:
What is being asked? The presentation request describes the claims the verifier needs — a job title, an employment status, a date of birth — in the language of the OpenID4VP presentation exchange.
Do we have it? Matching happens locally, on the device, before the user is involved:
let matcherResults = pingoneWalletClient
.findMatchingCredentialsForRequest(presentationRequest)
.getResult()
let matchingCredentials = matcherResults.filter { !$0.claims.isEmpty }
guard !matchingCredentials.isEmpty else {
// Tell the user what is missing rather than failing silently
return
}
This ordering is deliberate and it is the privacy core of the design: the request is evaluated against the wallet’s contents on-device. Only after a match exists does the user see anything.
Does the user consent? This is your app’s moment. If multiple credentials match, the app lets the user choose. Then the app presents:
pingoneWalletClient.presentCredentials(credentialPresentation)
.onResult { result in
switch result.getPresentationStatus() {
case .success:
// Verified. The verifier accepted the presentation.
case .failure:
// The presentation was rejected
case .requiresAction(let action):
// The verifier sent a follow-up action to handle
}
}
.onError { err in
// transport-level failure
}
The three-way status is worth designing your UI around. success closes the loop; failure usually means the verifier’s policy rejected what was presented; requiresAction means the conversation isn’t over — the verifier has replied with another step the wallet needs to perform.
The whole flow on one page
sequenceDiagram
participant V as Verifier
participant P as PingOne
participant W as Wallet app (your iOS app)
V->>P: QR code / deep link
P->>W: Presentation request (encrypted mailbox)
W->>W: Match credentials (on-device)
W->>W: User selects + consents
W->>P: Signed presentation
P->>V: Result
The verification flow end to end: the request arrives over the encrypted mailbox, matching and consent happen on-device, and only the signed presentation leaves the wallet.
Pairing and issuance follow the same mailbox pattern: a message arrives encrypted, the SDK decrypts and verifies it, the app gets a callback, and the user’s data stays on the device until the user acts.
Limitations and what to know before you build
- Device, not simulator: The wallet uses the Secure Enclave, the keychain, and (in the sample app) the camera for profile creation. Plan for on-device testing from day one.
- Scheduled issuance: Credential issue and revoke run on a processing schedule, not instantly. When your test credential doesn’t appear and you’re sure the polling is fine, the schedule is the suspect.
- Two credential formats: OpenID4VP-based credentials interoperate with other standards-compliant verifiers; PingOne’s proprietary format offers additional capabilities within the Ping ecosystem. Choose deliberately, because it decides who can verify what you issue.
- Verifier-side setup: A verifying environment needs an issuer profile configured before it can verify credentials — a detail that surprises teams testing cross-environment verification.
- The snippets are illustrative: They are adapted from the sample app and simplified for length. Treat the sample app as the source of truth, and validate against the product documentation for your SDK version.
Takeaways
- A digital credential is a long-lived, signed set of claims the user holds — the decentralized identity answer to “prove this about me” without a fresh token exchange every time.
- A wallet is a key store plus a state machine. Debugging your integration means asking which message arrived and what state it produced.
- Integration has exactly four stages: instantiate and get the wallet’s identity, pair through a trusted backend, receive lifecycle events via encrypted mailbox messages, and answer presentation requests with match → consent → present.
- Matching happens on-device before consent. That ordering is not an implementation detail — it is the privacy property that makes the pattern worth adopting.
Where to go next
- Explore the PingOne Wallet SDK for iOS sample app, which contains the complete, runnable version of every snippet in this article.
- Read the PingOne Credentials product documentation for issuance rules, credential formats, and verifier configuration.
- Read the OpenID4VP specification if you want to understand what is actually inside a presentation request.
Join the discussion on the Ping Identity developer community.
