Skip to main content
Developer SDK

VC Wallet SDK

The official TypeScript SDK for issuing, holding, presenting and verifying W3C Verifiable Credentials on the PasskeyBridge attestation platform. Hybrid post-quantum signatures, an encrypted local wallet, OpenID4VP and OID4VCI, StatusList2021 revocation.

SDK v0.6.0
TypeScript
Apache-2.0
Node 20.19+
No runtime dependencies
Install
npm install @passkeybridge/vc-wallet-sdk
What ships in v0.6.0

v0.6.0 on npm (released 2026-09-18) ships issuance of JWT-VCs and SD-JWT-VCs, server-side verification of single tokens and batches, OpenID4VP request building, a local wallet encrypted at rest, SD-JWT-VC holder binding with Key-Binding JWTs, the OID4VCI pre-authorized code flow, public status reads, lifecycle writes, and StatusList2021 refresh. Everything on this page is in the published package. v0.5.0 fixed redeemOffer(), which returned 401 in every earlier release, and v0.6.0 closed the independent review's findings on key handling, storage and the presentation matcher, so pin ^0.6.0. Release notes are in the package on npm.

Production posture

Every credential function is edge-locked: it refuses requests that do not arrive through the Cloudflare Worker at api.passkeybridge.io. API keys are stored as SHA-256 hashes and checked per scope, issuance is quota-enforced per tenant, and issuer DID resolution goes through an outbound URL validator that blocks private ranges and cloud metadata endpoints. Every issue, verify and lifecycle call writes an audit log row. Live component health is published at /status and tenant signing keys rotate with a 24-hour grace window so verifiers never break.

Quickstart

5 steps

The full happy path: initialize, issue, verify, check status, revoke. Copy, paste, and run with your tenant API key and tenant id set in the environment.

import { PBWallet } from "@passkeybridge/vc-wallet-sdk";

// 1. One client per tenant. Keys are pb_live_... or pb_test_... strings.
const wallet = new PBWallet({
  apiKey: process.env.PB_API_KEY!,
  tenantId: process.env.PB_TENANT_ID!,
});

// 2. Issue a JWT-VC: an ES256 JWS plus a detached ML-DSA-65 proof.
const issued = await wallet.issue({
  subjectDid: "did:web:example.com:users:alice",
  credentialType: "IdentityAttestation",
  claims: { verificationLevel: "enhanced" },
  expirationDays: 90,
});
const { jwt, credential_id } = issued.credential;
console.log(issued.pqc.algorithm);        // "ML-DSA-65"

// 3. Verify it server-side. A bare VC JWT or a VP JWT both work.
const result = await wallet.verifyPresentation({ vpToken: jwt });
console.log(result.verified, result.latencyMs);

// 4. Check status. Public read, no scope needed.
const status = await wallet.checkStatus(credential_id);
console.log(status.status);               // "active"

// 5. Revoke. Needs the vc_revoke scope. Revocation is permanent.
await wallet.revoke(credential_id, "user_requested_deletion");
  • Keys are minted in the dashboard's API keys tab. The credential functions check scopes by name: vc_issue to issue, vc_verify to verify, vc_revoke to revoke, suspend, reinstate or rebuild the status list.
  • Step 1 is local. Steps 2, 3 and 5 call shield-vc-issue, shield-vc-present and shield-vc-status through api.passkeybridge.io/v1 with your key. Step 4 is a public read that needs no key at all.
  • Verification resolves the issuer's did:web document on every call; there is no cache, so budget one HTTPS round trip per verification.

Architecture

Hybrid PQC

Standards-based JWT-VCs carry an ES256 JWS plus a detached ML-DSA-65 (FIPS 204) proof, both verifiable against the keys the issuer publishes in its DID document.

Zero-PII

Subjects are DIDs. The credential API never takes a phone number or email, and cross-reference rows store keyed HMAC-SHA-256 digests of the subject, not the subject.

OpenID4VP

Server-side Presentation Exchange 2.0 matching, nonce and audience checks on VPs and KB-JWTs, and batches of up to 20 tokens per call.

StatusList2021

Public status reads cached for 60 seconds; the bitstring is served with an ETag and answers If-None-Match with 304.

Local wallet

IndexedDB with AES-256-GCM at rest in browsers, memory elsewhere, or any WalletStorage you implement. Holder keys live beside their credentials.

Edge-first

Every function refuses requests that do not arrive through the Cloudflare Worker at api.passkeybridge.io, and issuance is quota-enforced per tenant.

Initialize

Required

One PBWallet per tenant. It carries your API key and tenant UUID on every request and picks a local storage backend for the wallet methods.

import { PBWallet } from "@passkeybridge/vc-wallet-sdk";

const wallet = new PBWallet({
  apiKey: "pb_live_...",                           // pb_test_... for a sandbox key
  tenantId: "your-tenant-uuid",
  // baseUrl: "https://api.passkeybridge.io/v1",   // default; override for staging
  // timeoutMs: 15_000,                            // default, per request
  // persist: true,                                // IndexedDB in browsers, memory elsewhere
  // storage: myWalletStorage,                     // or bring your own WalletStorage
});

console.log(PBWallet.version);                     // the package version
  • Keys are minted in the dashboard's API keys tab. Production keys start with pb_live_, sandbox keys with pb_test_.
  • Requests go to https://api.passkeybridge.io/v1/<function> with x-pb-api-key and x-pb-tenant-id headers and a 15 second timeout.
  • Storage is IndexedDB when the runtime has it and memory otherwise; pass persist: false to force memory in a browser.

Issue credentials

vc_issue

Issue W3C JWT-VCs, or SD-JWT-VCs with selective disclosure, signed under your tenant's DID-published keys.

// JWT-VC by default: an ES256 (P-256) JWS plus a detached ML-DSA-65 proof.
const issued = await wallet.issue({
  subjectDid: "did:web:example.com:users:alice",
  credentialType: "IdentityAttestation",
  claims: {
    verificationLevel: "enhanced",
    verifiedAt: new Date().toISOString(),
  },
  expirationDays: 90,                            // the server default is 90
});

console.log(issued.credential.jwt);              // eyJ...
console.log(issued.credential.credential_id);    // urn:uuid:...
console.log(issued.credential.expires_at);
console.log(issued.pqc.algorithm);               // "ML-DSA-65"
console.log(issued.latency_ms);

// A disclosureFrame switches the format to SD-JWT-VC. The SDK generates a
// P-256 holder key pair, sends the public JWK as cnf.jwk, and keeps the
// private key in the local wallet for presentWithBinding().
const sdJwt = await wallet.issue({
  subjectDid: "did:web:example.com:users:alice",
  credentialType: "KYCAttestation",
  claims: { age_over_18: true, age_over_21: true, country: "US" },
  disclosureFrame: { age_over_18: true, age_over_21: true, country: true },
});
console.log(sdJwt.credential.format);            // "sd-jwt-vc"
console.log(sdJwt.sd?.disclosure_count);         // 3
  • The default proof is a standards-based ES256 JWS plus a detached ML-DSA-65 (FIPS 204) proof, both verifiable against the issuer DID document. Tenants on the ML-DSA-87 add-on sign ES384 plus ML-DSA-87.
  • Expiry defaults to 90 days on the server; a credential schema can cap expirationDays lower.
  • Tenant signing keys rotate with a 24-hour grace window, so a credential issued just before a rotation still verifies.
  • The issuer DID defaults to did:web:passkeybridge.io:tenants:<slug>; pass issuerDid to override.

Verify presentations

vc_verify

Server-side verification of a VP or VC token with replay checks, optional Presentation Exchange 2.0 matching, and optional cross-reference binding.

// One token: a VP JWT from a holder wallet, or a bare VC JWT.
const result = await wallet.verifyPresentation({
  vpToken: "eyJ...",
  expectedNonce: "nonce-from-your-auth-request",   // replay check on the VP / KB-JWT
  presentationDefinition: definition,              // optional Presentation Exchange 2.0
  bindToSignalHash: "carrier-signal-digest",       // optional cross-reference link
});

if (result.verified) {
  console.log(result.holderHash);                  // keyed digest of the holder DID
  console.log(result.credentials?.[0]?.checks);    // signature_valid, not_expired, ...
  console.log(result.crossReferenceId);            // set when bindToSignalHash was passed
  console.log(result.latencyMs);
} else {
  console.error(result.error);
}

// Up to 20 tokens against one definition in a single round trip.
const batch = await wallet.verifyBatchPresentation(tokens, definition, {
  expectedNonce: "nonce-from-your-auth-request",
});
console.log(batch.tokenCount, batch.verified);

// Build an OpenID4VP authorization request for a holder wallet to answer.
const request = await wallet.createPresentationRequest(definition);
console.log(request.nonce, request.authorizationRequest);
  • The verifier resolves the issuer's did:web document on every call to fetch its published EC and ML-DSA keys; there is no cache, so budget one HTTPS round trip per verification.
  • expectedNonce is optional on the wire. Pass it whenever you issued the nonce, or a captured presentation can be replayed.
  • bindToSignalHash links the verified subject to a carrier-signal digest and returns the shield_cross_references row id as crossReferenceId.
  • Batches are capped at 20 tokens per call on both the client and the server; the server rejects larger batches rather than truncating them.

Local wallet

Local

Persist credentials and holder keys on the device. Encrypted at rest in browsers, in memory everywhere else, or in a backend you supply.

// Browser: IndexedDB, encrypted at rest under a non-extractable AES-256-GCM key.
// Node, Deno, edge runtimes and SSR: in memory, for the life of the process.
const stored = await wallet.store(issued.credential, { source: "onboarding" });
await wallet.storeJwt("eyJ...");                      // a credential received out of band

const valid = await wallet.list({ type: "IdentityAttestation", validOnly: true });
const one = await wallet.get("urn:uuid:...");         // null when absent, never throws
await wallet.remove("urn:uuid:...");                  // drops the holder key too
await wallet.clearAll();

// React Native, a CLI, a server-side wallet: implement WalletStorage and pass
// it as `storage` to the constructor.
  • Browser records are encrypted with a non-extractable AES-256-GCM key held in IndexedDB; it never leaves the WebCrypto sandbox.
  • list() returns newest first and filters by type, issuer and validOnly.
  • These methods make no network call and need no scope.

SD-JWT-VC holder binding

Local

Prove possession of the holder key with a Key-Binding JWT and reveal only the claims a verifier asked for.

// Present an SD-JWT-VC with a Key-Binding JWT that proves possession of the
// holder key the SDK generated at issuance.
if (await wallet.hasHolderBinding(credentialId)) {
  const presentation = await wallet.presentWithBinding(
    credentialId,
    "did:web:verifier.example.com",   // audience
    "nonce-from-the-auth-request",    // nonce
    ["age_over_18", "country"],       // reveal only these claims; omit for all
  );
  // "<issuer-jwt>~<disclosure>~<disclosure>~<kb-jwt>"
}

// Pick the wallet credentials that satisfy a presentation definition, then
// resolve them to tokens for verifyBatchPresentation().
const matches = await wallet.selectCredentials(definition);
const tokens = await wallet.assembleBatchTokens(matches.map((c) => c.credentialId));
  • Holder keys are ECDSA P-256. The KB-JWT carries typ: kb+jwt and an sd_hash over the disclosures it accompanies.
  • presentWithBinding() throws not_found for an unknown credential and invalid_params when no holder key is bound to it.

OID4VCI offers and redemption

vc_issue to offer

The OpenID for Verifiable Credential Issuance pre-authorized code flow: the issuer creates an offer, a wallet redeems it.

// Issuer side: create an offer and render credentialOfferUri as a QR code.
const offer = await wallet.createCredentialOffer({
  credentialType: "IdentityAttestation",
  subjectDid: "did:web:example.com:users:alice",
  claims: { verificationLevel: "enhanced" },
  formats: ["jwt_vc_json", "vc+sd-jwt"],
});
console.log(offer.credentialOfferUri, offer.preAuthorizedCode, offer.expiresAt);

// Wallet side: the pre-authorized code flow end to end. Token exchange, a
// holder proof for SD-JWT formats, then the credential request. autoStore
// (default true) keeps the credential and its holder key in the local wallet.
const redeemed = await wallet.redeemOffer(offer.preAuthorizedCode, "vc+sd-jwt");
console.log(redeemed.format, redeemed.stored?.credentialId);
if (redeemed.holderKeyPersisted === false) {
  // The credential arrived but its binding key did not survive. Re-issue it.
}

// Credential configurations and supported formats. No scope required.
const metadata = await wallet.getIssuerMetadata();
  • createCredentialOffer() needs the vc_issue scope. redeemOffer() and getIssuerMetadata() need none: redemption authenticates with the pre-authorized code and the short-lived access token it exchanges for.
  • Fixed in 0.5.0: every earlier release dropped that access token and the credential request answered 401. Pin ^0.6.0.
  • The holder proof omits iss, as OpenID4VCI section 7.2.1 requires for the pre-authorized code flow.

Status and revocation

Public reads

Check credential status singly or in batches, manage lifecycle, and rebuild the StatusList2021 bitstring.

// Public reads: no API key scope is needed.
const status = await wallet.checkStatus("urn:uuid:...");
console.log(status.status);   // "active" | "suspended" | "revoked" | "expired" | "unknown"

// Up to 100 ids in one round trip.
const { credentials, checkedAt } = await wallet.batchCheckStatus(["urn:uuid:1", "urn:uuid:2"]);

// Writes need the vc_revoke scope. Revoke is permanent; suspend is reversible.
await wallet.revoke("urn:uuid:...", "user_requested_deletion");
await wallet.suspend("urn:uuid:...", "under_review");
await wallet.reinstate("urn:uuid:...");

// Force a StatusList2021 bitstring rebuild for your issuer DID.
const list = await wallet.refreshStatusList();
console.log(list.etag, list.totalCredentials, list.revokedCount);
  • Status reads are public and served with Cache-Control: public, max-age=60. The StatusList2021 bitstring carries an ETag and answers If-None-Match with 304.
  • revoke, suspend, reinstate and refreshStatusList need the vc_revoke scope. Reinstate only works on a suspended credential; a revoked one stays revoked.
  • refreshStatusList() rebuilds the list for your own issuer DID; passing another tenant's DID returns 403.

Utilities

Static

Standalone helpers exported from the package root: decoders, the Presentation Exchange matcher, and the holder-key primitives.

import {
  decodeCredential,
  decodeJwtPayload,
  matchCredentials,
  generateHolderKeyPair,
  createKbJwt,
} from "@passkeybridge/vc-wallet-sdk";

// Header and payload of a JWT or SD-JWT. No signature verification.
const { header, payload } = decodeCredential("eyJ...");

// Payload only; null on failure instead of throwing.
const claims = decodeJwtPayload("eyJ...");

// Presentation Exchange 2.0 matcher over any candidate set. A verifier-written
// pattern is capped at 512 characters, refused when a quantified group nests
// another quantifier, and never run against a value over 4096 characters.
const matched = matchCredentials(definition, candidates);

// The holder-side primitives presentWithBinding() is built from.
const pair = await generateHolderKeyPair();
const kbJwt = await createKbJwt(sdJwtWithoutKb, audience, nonce, pair.privateJwk);
  • decodeCredential() and decodeJwtPayload() do not verify signatures. They are for display and routing only.
  • hashPhone() is still exported but deprecated. It is an unkeyed SHA-256 that does not match the platform's keyed HMAC-SHA-256 identifier hash, and the phone keyspace is small enough to reverse by enumeration. Send the raw E.164 number to ingest and let the server key it.

Errors

All methods

Every failure, local or remote, throws a PBWalletError with a stable code you can branch on.

import { PBWalletError } from "@passkeybridge/vc-wallet-sdk";

try {
  await wallet.issue({ subjectDid, credentialType });
} catch (err) {
  if (err instanceof PBWalletError) {
    console.error(err.code);        // "unauthorized" | "forbidden" | "rate_limited" | ...
    console.error(err.httpStatus);  // 401, 403, 429, ... when the edge answered
    console.error(err.requestId);   // x-request-id or cf-ray, for support
    console.error(err.message);
  }
}
  • Codes: invalid_options, invalid_params, network_error, timeout, http_error, invalid_response, unauthorized, forbidden, not_found, rate_limited, server_error, decode_error.
  • A 403 with the x-pb-reason: missing-scope header means the key exists but lacks the scope the function checks.

Required API key scopes

Scope names as the edge functions check them. A key that carries the scope passes; a key that lacks it gets a 403 with the header x-pb-reason: missing-scope.

ScopeMethods
vc_issueissue, createCredentialOffer
vc_verifyverifyPresentation, verify, verifyBatchPresentation, createPresentationRequest
vc_revokerevoke, suspend, reinstate, refreshStatusList
nonecheckStatus, batchCheckStatus, getIssuerMetadata, redeemOffer, and every local wallet method

Try it

Interactive

Send real requests to the PasskeyBridge edge. Pick an endpoint template, edit the request body, and send it.

Loading playground

Ready to integrate? Create your API key in the dashboard.