Holder app integration
You're building a wallet — the app the user opens to approve credential issuance, store SD-JWT VC credentials, and present them to verifiers. Or you don't want to build one at all and instead point users to the official Owl ID Wallet.
Setup
Pure TypeScript — runs in browsers, Node, and React Native shells. WebAuthn passkey support requires a browser context (or a native bridge that exposes the WebAuthn API).
The happy path is the first four sections: register a passkey, store the credential, build a presentation, and answer a verifier QR with respondToPresentation. Everything below that — driving the WebSocket yourself, OpenID4VP direct_post, choosing a proving backend — is opt-in, and you only need it if the one-call helper doesn't fit.
WebAuthn / passkey lifecycle
WebAuthn is the unlock + user-verification gate for the wallet's holder key — never the JWS signer. KB-JWTs are signed by a wallet-held Ed25519 or P-256 key (standard EdDSA / ES256 JWS).
import {
registerCredential,
authenticate,
isWebAuthnSupported,
sealHolderKey,
openHolderKey,
KeyPair,
} from '@owlid/sdk'
if (!isWebAuthnSupported()) throw new Error('WebAuthn not available')
// 1. Register a passkey.
const cred = await registerCredential({
rpName: 'My Wallet',
rpId: 'wallet.example.com',
userName: user.email,
userVerification: 'required',
})
// 2. Mint the wallet's holder signing key + seal its 32-byte seed with the
// passkey PRF. Returns { blob, credentialId } — persist the blob, and the
// credentialId it reports, which is the passkey that actually supplied the
// PRF output (pass null to let the browser show the passkey picker).
const holderKey = KeyPair.generate()
const { blob: wrapped } = await sealHolderKey(cred.credentialId, holderKey.toHex())
// 3. Later — UV gate, then open. Returns { seedHex, credentialId }.
await authenticate({
rpId: 'wallet.example.com',
credentialId: cred.credentialId,
userVerification: 'required',
})
const { seedHex: holderKeyHex } = await openHolderKey(cred.credentialId, wrapped)
Local credential storage
import { storage, proofStorage, SdJwtVc, buildCardShape } from '@owlid/sdk'
// addCredential(credential, wrappedKey) stores the SD-JWT VC and the
// PRF-wrapped holder key together, keyed by credentialId. credentialId
// and issuer are derived from the SD-JWT VC string itself.
const parsed = SdJwtVc.parse(sdJwtVc)
await storage.addCredential(
{
credentialId: parsed.credentialId(), // stable id used for revocation lookups
sdJwtVc, // application/dc+sd-jwt string
issuer: parsed.peekIssuer(), // did:web identifier
providerId, // IdP that drove the flow ('didit', 'google', …)
issuedAt: new Date().toISOString(),
holderPublicKeyHex: holderKey.publicKeyHex(), // per-credential cnf key
verifiedClaims, // disclosed claims kept locally for the wallet UI
cardShape: buildCardShape(providerId, verifiedClaims), // per-IdP card UI
},
wrapped, // the blob from sealHolderKey() — see above
)
const list = await storage.listCredentials()
const keyBlob = await storage.getCredentialKeyWrapped(parsed.credentialId())
// IndexedDB-backed proof history for the "recent proofs" UI.
await proofStorage.saveProof({
id: crypto.randomUUID(), // fresh per generation — a reused id overwrites the row
predicateId: 'age_over_18', // stable id, matches a stored row back to its predicate
name: 'Acme Bar', // display name — typically the verifier it was shown to
claim: 'age_over_18', // the claim proven
result: true, // boolean outcome
presentation, // the SD-JWT VC presentation string
createdAt: new Date().toISOString(),
})
storage is a singleton. For custom backends (mobile, encrypted-at-rest), instantiate CredentialStorageManager with your own StorageAdapter.
Build a presentation
import { presentSdJwtVc } from '@owlid/sdk'
// presentSdJwtVc(sdJwtVc, holderKeyHex, disclose, { aud, nonce }) — synchronous.
// holderKeyHex is the seedHex returned by openHolderKey().
const presentation = presentSdJwtVc(
storedCredential.sdJwtVc,
holderKeyHex,
['given_name', 'age_over_18'],
{ aud: verifierOrigin, nonce: verifierNonce }, // bound into the KB-JWT
)
The resulting string is the full SD-JWT VC presentation: issuer JWT + selected disclosures + KB-JWT. Wire-shape: <jwt>~<disc>~<disc>~...~<kb-jwt>.
Respond to a verifier QR (one call)
Scan a verifier QR, prompt the user for consent, and respond — all in one call:
import { respondToPresentation, OwlWallet, openHolderKey, storage } from '@owlid/sdk'
// An OwlWallet wraps your local storage + key-unseal + passkey resolution.
// It solves the verifier's DCQL query and signs the KB-JWTs.
const wallet = new OwlWallet(
storage,
async (credentialId, wrapped) => (await openHolderKey(credentialId, wrapped)).seedHex,
async () => (await storage.loadWebAuthnCredential())?.credentialId ?? null,
)
await respondToPresentation(qrPayload, {
wallet,
// `request` is a PresentationRequest: { verifierName, dcql, nonce, … }
onConsent: async (request) => ui.askApprove(request.verifierName, request.dcql),
})
The helper handles the WebSocket lifecycle, consent prompt, DCQL match, presentation build, and response — OwlWallet is the only building block you supply.
Presentation protocol (driving the WebSocket yourself)
If you need control over the WebSocket (custom retry, native transport), decode the QR, connect, and answer the verifier's DCQL request with OwlWallet.present():
import { decodeSessionEngagement, isPresentationEngagement, resolveWsUrl } from '@owlid/sdk'
import type { WsMessage, PresentationRequest, PresentationResponse } from '@owlid/sdk'
if (isPresentationEngagement(qrPayload)) {
const engagement = decodeSessionEngagement(qrPayload)
const ws = new WebSocket(`${resolveWsUrl(engagement!.ws!.url)}?role=holder`)
ws.onmessage = async (e) => {
const msg: WsMessage = JSON.parse(e.data)
if (msg.type !== 'request') return
const request = msg.payload as PresentationRequest
// The wallet solves the DCQL query and signs one KB-JWT per
// credential, all bound to the verifier's audience + nonce.
const { vpToken, used } = await wallet.present({
dcql: request.dcql,
aud: request.verifierName,
nonce: request.nonce,
})
const response: PresentationResponse = { sessionId: request.sessionId, vpToken, used }
ws.send(JSON.stringify({ type: 'response', payload: response } satisfies WsMessage))
}
}
OpenID4VP
Alternatively the verifier can be a plain HTTPS service that supports OpenID4VP direct_post. Post the presentation to POST /openid4vp/response:
await fetch(`${verificationUrl}/openid4vp/response`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ vp_token: presentation, state: requestState }),
})
Proving backend (WASM vs hosted proof server)
When the holder presents a credential, Owl ID generates a zero-knowledge proof for every predicate the verifier's DCQL request asks for. The SDK supports two backends:
Configure at build time, at runtime, or per-OwlWallet:
import { configure } from '@owlid/sdk'
configure({
verificationUrl: 'https://api.owlid.app',
issuerUrl: 'https://issuer.owlid.app',
// Opt into a hosted proof server. Without `proofServerUrl` the SDK
// falls back to in-process WASM regardless of `provingMode`.
provingMode: 'proof-server',
proofServerUrl: 'https://proofs.owlid.app',
})
Equivalently per-wallet:
const wallet = new OwlWallet(storage, unwrap, resolvePasskey, {
provingConfig: {
mode: 'proof-server',
url: 'https://proofs.owlid.app',
timeout: 60_000,
},
})
The Owl ID holder app exposes a /settings page where the end user picks between the two — operators ship OWLID_PROOF_SERVER_URL (Cloud Run) or VITE_PROOF_SERVER_URL (Vite) to suggest a default endpoint.
If you self-host the proof server, the upstream midnightntwrk/proof-server image does not set CORS headers. Front it with a reverse proxy that injects Access-Control-Allow-Origin for your wallet's Origin — see Dockerfile.proof-server + docker/proof-server/Caddyfile in the Owl ID repo for a working example.
Reference