Verifiable Credentials
Issue, verify, present, and revoke W3C Verifiable Credentials using PasskeyBridge's self-hosted VC engine with hybrid post-quantum signatures.
Overview
PasskeyBridge issues, verifies and revokes W3C Verifiable Credentials from its own edge functions. A credential is a JWT-VC, or an SD-JWT-VC when you ask for selective disclosure, signed with the tenant's ECDSA key and accompanied by a detached ML-DSA post-quantum proof. The engine is self-hosted: no third-party credential service is involved unless you configure one for shield-vc-verify, which delegates presentation verification to walt.id, Trinsic or any endpoint that accepts a vp_token.
Four functions carry the lifecycle, all reachable at https://api.passkeybridge.io/v1/<function>. Calling the Supabase functions host directly answers 403 Direct access denied.
| Function | Purpose | Auth |
|---|---|---|
shield-vc-issue | Issue a JWT-VC or SD-JWT-VC | API key with vc_issue, or a tenant admin session JWT |
shield-vc-present | Build an OpenID4VP request, verify a presentation | API key with vc_verify, or a tenant member session JWT |
shield-vc-status | Public status reads, plus revoke, suspend and reinstate | Reads need no auth; writes need vc_revoke or a tenant admin session JWT |
shield-vc-oid4vci | OID4VCI pre-authorized code issuance | Metadata and token are public; offers need vc_issue |
Two more sit alongside them: shield-vc-verify hands a presentation to an external provider, and shield-did-resolve publishes each tenant's DID document so a verifier can fetch the signing keys.
What the platform stores about a credential is its id, type, issuer DID, a hash of the subject identifier, timestamps and the names of its claims. Claim values are written into the credential you receive and are never stored server-side, so put hashes or non-identifying values in claims; the platform does not hash claim values for you.
Enabling credentials for a tenant
A new tenant has credentials switched off. shield-vc-issue, and the offer and credential actions of shield-vc-oid4vci, read shield_tenants.vc_enabled and answer 422 until it is set:
{
"error": "Verifiable Credentials not enabled for this tenant",
"hint": "Enable VC in your tenant settings or upgrade your plan."
}Set it by completing Dashboard > Onboarding > Verifiable credentials, where "Start" opens the wizard. The Identity tab's "Add provider" button opens the same wizard. Its steps are:
- Provider. PasskeyBridge Native (the built-in engine, no third-party key), walt.id (BYOK), Trinsic (BYOK) or Custom Provider (BYOK).
- API credentials. Connection label, API key and endpoint for a BYOK provider. Skipped for the native engine.
- Accepted credentials. The credential types this tenant will accept, plus optional trusted issuer DIDs, one per line. Both apply to external-provider verification through
shield-vc-verify. - Activate Pillar II. The "Enable Pillar II" button saves.
Saving calls shield-admin-mutations action save_vc_provider, which inserts a shield_vc_providers row and sets vc_enabled = true and vc_provider on the tenant. Choosing a BYOK provider with no API key is rejected with Missing api_key for a BYOK provider; a BYOK endpoint must start with https://.
Confirm it worked: the Identity tab lists the provider with an Active badge, and GET https://api.passkeybridge.io/v1/shield-did-resolve/<slug> starts returning a service block naming the issuer, verifier and status endpoints. That block is emitted only while vc_enabled is true.
Credential lifecycle
A credential is one row in shield_vc_issued plus the JWT you were handed at issuance. The row carries is_valid and revoked_at, and every status answer is derived from those two fields and expires_at.
| State | Row condition | How it is reached |
|---|---|---|
active | is_valid = true, not past expires_at | Default after issuance |
suspended | is_valid = false, revoked_at null | POST /shield-vc-status with action: "suspend" |
revoked | revoked_at set | POST /shield-vc-status with action: "revoke" |
expired | Past expires_at | Time passing |
unknown | No row with that credential id | The credential was not issued by this platform |
Status is evaluated in that order, so a revoked credential reports revoked even after it expires.
Suspension is reversible with action: "reinstate". Revocation is not: reinstating a revoked credential answers 422 with "Cannot reinstate a revoked credential—revocation is permanent". Both suspension and revocation set the credential's bit in the StatusList2021 bitstring, so an offline verifier sees one bit for either state.
Expiry defaults to 90 days from issuance. expiration_days on the issue request overrides it, and a registered schema's ttl_days caps it: the effective lifetime is the smaller of the two.
Issuing credentials
POST https://api.passkeybridge.io/v1/shield-vc-issue builds the JWT-VC, signs it, writes shield_vc_issued, and returns the token. Authenticate with an API key carrying vc_issue plus x-pb-tenant-id, or with a tenant admin session JWT (a non-admin member gets 403 Only admins can issue credentials).
curl -X POST https://api.passkeybridge.io/v1/shield-vc-issue \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: <tenant uuid>" \
-H "Content-Type: application/json" \
-d '{
"subject_did": "did:web:example.com:users:alice",
"credential_type": "IdentityAttestation",
"claims": { "verificationLevel": "enhanced" },
"expiration_days": 90
}'| Field | Type | Default | Meaning |
|---|---|---|---|
subject_did | string | required | DID of the subject. Becomes sub and credentialSubject.id |
credential_type | string | required | Second entry in the type array after VerifiableCredential |
claims | object | {} | Claim names and values merged into credentialSubject |
expiration_days | number | 90 | Lifetime. Capped by a registered schema's ttl_days |
issuer_did | string | did:web:passkeybridge.io:tenants:<slug> | Issuer DID written to iss. Must be this tenant's own DID; any other value answers 400, because the credential is signed with this tenant's key |
disclosure_frame | object | absent | Marks claims selectively disclosable. Its presence switches the format to SD-JWT-VC |
holder_public_jwk | object | absent | Holder public JWK. Embedded as cnf.jwk only when disclosure_frame is also present |
The response carries the token and the proof metadata:
{
"credential": {
"jwt": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6...",
"format": "jwt-vc",
"credential_id": "urn:uuid:3f6b1c9e-...",
"issuer": "did:web:passkeybridge.io:tenants:acme",
"subject": "did:web:example.com:users:alice",
"type": "IdentityAttestation",
"issued_at": "2026-09-16T10:00:00.000Z",
"expires_at": "2026-12-15T10:00:00.000Z"
},
"pqc": {
"algorithm": "ML-DSA-65",
"key_fingerprint": "did:web:passkeybridge.io:tenants:acme#gen-1a2b3c4d",
"proof_format": "ES256+ML-DSA",
"standard": {
"alg": "ES256",
"kid": "did:web:passkeybridge.io:tenants:acme#gen-1a2b3c4d",
"pqc": {
"alg": "ML-DSA-65",
"sig": "<base64 ML-DSA signature>",
"qn": "<hex quantum nonce>",
"kid": "did:web:passkeybridge.io:tenants:acme#mldsa-1"
}
}
},
"record_id": "b7c2e0d4-..."
}format is jwt-vc or sd-jwt-vc. proof_format is ES256+ML-DSA, ES384+ML-DSA or PQC-HYBRID. key_fingerprint is the DID verificationMethod id for a standards-based credential, and a SHA-256 domain fingerprint for the legacy envelope. record_id is the shield_vc_issued row id, null if the insert failed (the credential is still valid and returned). The response also carries latency_ms, the duration of the call as the function measured it.
An SD-JWT-VC issuance adds an sd object with disclosure_count, disclosure_digests and disclosures, each disclosure being { claim, encoded, digest }. Store them: the holder needs them to present a claim, and the platform does not keep a copy.
| Status | Body | Cause |
|---|---|---|
400 | Missing subject_did—the DID of the credential subject | subject_did absent |
400 | Missing credential_type—e.g. 'IdentityAttestation', 'CarrierVerification' | credential_type absent |
400 | Missing x-pb-tenant-id header | API key sent without the tenant header |
400 | Invalid JSON body | Body did not parse |
401 | Invalid API key or Missing authorization or x-pb-api-key header | No usable credential |
403 | API key missing 'vc_issue' scope (header x-pb-reason: missing-scope) | Key lacks the scope |
403 | Only admins can issue credentials | Session JWT whose membership role is not admin |
404 | No tenant found | Session JWT with no tenant membership |
413 | Payload too large | Body over the medium JSON limit |
422 | Verifiable Credentials not enabled for this tenant | vc_enabled is false |
422 | Credential claims do not match registered schema plus validation_errors | Schema registry rejected the claims |
429 | tenant_quota_exceeded plus limit and retry_after_seconds (header x-pb-reason: quota-exceeded) | Per-tenant minute quota |
Confirm it worked in Dashboard > Credentials > Issued credentials: the new row shows the credential type, its urn:uuid id, a Valid badge and the PQC algorithm badge. The Events tab gains a vc_issued event and the Audit log a vc.issue entry.
JWT-VC structure
A standards-based credential is an ordinary compact JWS. Any JOSE library can verify it against the key published in the issuer's DID document.
{
"alg": "ES256",
"typ": "JWT",
"kid": "did:web:passkeybridge.io:tenants:acme#gen-1a2b3c4d"
}alg is ES256 for a P-256 tenant key and ES384 for a P-384 key. typ is vc+sd-jwt when a disclosure frame was used. kid is the issuer DID plus the tenant key id, which is exactly the verificationMethod id in the DID document.
| Claim | Type | Meaning |
|---|---|---|
iss | string | Issuer DID |
sub | string | Subject DID |
jti | string | Credential id, urn:uuid:... |
iat | number | Issued at, Unix seconds |
nbf | number | Not before, equal to iat |
exp | number | Expiry, Unix seconds |
vc | object | @context, type and credentialSubject |
_sd_alg | string | sha-256, present only on SD-JWT-VC |
cnf | object | Holder public key, present only on an SD-JWT-VC issued with holder_public_jwk |
The post-quantum layer travels beside the token rather than inside it. The pqc.standard object in the issuance response holds the detached ML-DSA signature, the quantum nonce needed to check it, and the verificationMethod id of the ML-DSA key. The signed input is <header>.<payload>:qnonce:<qn>, so a verifier reconstructs that string, fetches #mldsa-1 from the issuer DID document and checks the signature with a stock ML-DSA implementation. Keep pqc.standard if you intend to check the post-quantum layer later: the platform stores it on the credential row but does not return it from any read endpoint.
A legacy PQC-HYBRID credential instead puts everything in the signature segment: base64url JSON of { classical, pqc, alg, fp, qn }, where classical is an HMAC-SHA-256 that only PasskeyBridge can verify. Tenants get this envelope when shield_tenants.proof_format_preference is hmac, and as a fallback if key provisioning fails during issuance. Verification detects the envelope from the header alg, so old credentials keep working.
Verification
POST /v1/shield-vc-present with action: "verify" runs the native engine over a VP JWT, a standalone JWT-VC or an SD-JWT-VC. Seven checks are reported per credential, and valid is true only when all seven are:
| Check | What it means |
|---|---|
format_valid | Three JWT segments and a payload that decodes to JSON with iss, sub and vc |
signature_valid | The signature verified (see below for what that covers) |
not_expired | exp is in the future |
not_before_valid | nbf is in the past |
issuer_resolvable | The issuer DID resolved, or resolution was skipped |
sd_disclosures_valid | Every presented disclosure matches a digest in _sd, with no duplicates. True when there are no disclosures |
kb_jwt_valid | A credential carrying cnf.jwk presented a KB-JWT whose signature and sd_hash check out. True when there is no cnf claim |
What `signature_valid` covers. For a standards-based credential, shield-vc-present verifies the ES256 or ES384 JWS against the tenant's published EC key, falling back to the key in the issuer's DID document. It also re-checks the detached ML-DSA proof when it can find one: the proof is stored beside the credential at issuance, and the endpoint looks it up by the credential's jti within your tenant and verifies it against the #mldsa-1 key. What a failing proof means depends on where it came from: one you assert with pqc_proof makes signature_valid false, because you told us it belongs to this credential; one we read back from our own storage does not, because you did not supply it and the classical signature over the same bytes verified. For a legacy PQC-HYBRID credential both layers sit in the signature segment and signature_valid covers both.
The `post_quantum` block. Each credential in the response carries post_quantum: { checked, verified } on the standards-based envelope. checked: false means no stored proof was found, which is the honest answer for a credential another issuer signed or one issued before the proof was kept, and it does not make the credential invalid. checked: true with verified: false means a proof was found and did not verify; source says whether you asserted it or we stored it. A stored proof that fails most often means the platform signing key moved after the credential was issued, which is our drift rather than a problem with your credential, so it stays valid and the panel says so. The block is absent on a legacy credential, where there is no separate proof to report. The proof is also returned at issuance in pqc.standard, so you can verify it yourself against the published key without asking us.
`skip_issuer_resolution` is narrower than it looks. Setting it to true marks issuer_resolvable as passing and suppresses the DID fetch. For a standards-based credential that fetch is also where the verifying key comes from, so the ES256 check has nothing to verify against and signature_valid comes back false. Leave it unset for standards-based credentials. It is usable only for legacy PQC-HYBRID credentials, whose signature is checked with keys the server derives locally. The dashboard's Presentation exchange panel sends skip_issuer_resolution: true, so it reports a signature failure against a default credential.
Holder binding. A credential with cnf.jwk fails verification unless the SD-JWT-VC is presented with a KB-JWT appended. expected_nonce and expected_audience reach both levels: a VP JWT's own nonce and aud claims, and the KB-JWT on a standalone SD-JWT-VC or on one carried inside a presentation. Send the nonce from the authorization request you issued, and a KB-JWT bound to a different request or a different verifier is refused. Leave them out and the KB-JWT's signature, typ and sd_hash are still checked, but nothing ties the presentation to this request, so a captured one replays.
External provider verification. POST /v1/shield-vc-verify is a different endpoint: it forwards the vp_token to the provider configured for the tenant. walt.id is called at /openid4vc/verify, Trinsic at /presentations/verify, and a custom provider receives { vp_token } at its configured URL. Without an active provider row it answers 422 No active VC provider configured. It authenticates with any active API key for the tenant and does not check a scope. The response is { status, verified, credential_type, issuer, subject_hash, constraint_failure, playbook_executed, actions_count, latency_ms, pqc_signature, pqc_algorithm }; claim values are never returned, and only claim field names are recorded on the event. subject_hash there is an unkeyed SHA-256 of the subject id the provider reported.
OpenID4VP presentation exchange
shield-vc-present also builds the request a wallet answers. action: "create_request" takes a presentation_definition and an optional response_uri, and returns an authorization request with response_type: "vp_token", response_mode: "direct_post", a client_id of did:web:api.passkeybridge.io:t:<tenant uuid>, and a fresh nonce and state. Deliver it to the wallet as a deep link or QR code, then submit the vp_token the wallet returns with action: "verify" and the same definition.
Matching follows Presentation Exchange 2.0: each input descriptor's field constraints are evaluated with a small JSONPath resolver and a JSON Schema subset, and the response carries a presentation_submission, per-descriptor descriptor_matches, and unsatisfied_descriptors. A failed match sets verified to false with HTTP 200.
The full request and response shapes, the supported filter operators and the order they are applied in are in Presentation Exchange (OpenID4VP).
DID resolution
The engine resolves two DID methods, and publishes tenant documents for a third party to resolve.
`did:web`. did:web:example.com is fetched from https://example.com/.well-known/did.json, and did:web:example.com:users:alice from https://example.com/users/alice/did.json. The fetch is HTTPS with a five-second timeout, private and cloud-metadata addresses are refused before the request is made, and a document whose id does not equal the requested DID is rejected.
`did:key`. Ed25519 only, base58btc multibase with the z prefix and the 0xed01 multicodec. Any other key type, X25519 included, answers unsupportedKeyType. Resolution is local, with no network call.
Your tenant's document. It is served at https://passkeybridge.io/tenants/<slug>/did.json, the address did:web:passkeybridge.io:tenants:<slug> resolves to, and equivalently at GET https://api.passkeybridge.io/v1/shield-did-resolve/<slug>. It contains the tenant's EC signing key as a JsonWebKey2020 verificationMethod whose id is the kid in issued credentials, and the ML-DSA public key as #mldsa-1. Both are listed in assertionMethod. The service array with the issue, verify and status endpoints appears only while vc_enabled is true. An unknown slug answers 404 Tenant not found, which is the correct did:web answer.
GET /v1/shield-did-resolve?did=did:key:z6Mk... resolves any did:key and returns a full DID resolution result. The same endpoint with a did:web parameter redirects (302) to the document URL, and only for passkeybridge.io and its api subdomain; use a universal resolver for external did:web identifiers. The endpoint is GET-only and public; anything else answers 405.
Revocation and StatusList2021
Status is served by shield-vc-status. Reads are public: GET /v1/shield-vc-status?credential_id=<id> returns one credential's state, and ?issuer=<did>&type=statuslist returns the tenant's StatusList2021 credential with the bitstring and an indexMap from credential id to bit position.
Writes need vc_revoke on an API key, or a tenant admin session JWT, and take action of revoke, suspend or reinstate with a credential_id. Each write rebuilds the bitstring and returns the result inline in a status_list object, so a verifier that refetches immediately sees the new state.
One deviation to plan for: encodedList is the base64 of the raw bitstring, with no GZIP layer, so decode it with base64 alone. Full request shapes, the bit layout, caching and the error table are in StatusList2021 & Revocation.
Hybrid PQC signatures
Every credential from the native engine carries two signatures made over the same input. The classical layer is an ECDSA JWS: ES256 on P-256 by default, ES384 on P-384 for tenants whose pqc_level is ml-dsa-87. The post-quantum layer is ML-DSA (NIST FIPS 204), ML-DSA-65 by default and ML-DSA-87 on that same tier, produced by the pinned mldsa-wasm build.
Signing. The header and payload are encoded and joined as <header>.<payload>. The tenant's ECDSA private key, decrypted from shield_tenant_keys, signs that string and the result becomes the JWT's third segment. A quantum nonce is drawn from the QRNG-seeded DRBG, and ML-DSA signs <header>.<payload>:qnonce:<nonce> as a detached proof. The JWS is returned as the credential; the detached proof, its algorithm, key id and nonce travel in pqc.standard.
Verifying. The JWS is checked against the EC key in the issuer's DID document, which is what makes the credential verifiable by software that has never heard of PasskeyBridge. The ML-DSA proof is checked by reconstructing the nonce-fortified input and verifying against the #mldsa-1 public JWK from the same document. shield-vc-present performs both for a credential your tenant issued, because it holds the stored proof. Any relying party can perform the second for itself from the published key and the proof returned at issuance, which is the reason that key is published.
Keys. One active signing key per tenant, provisioned on first issuance if absent, or imported as a P-256 or P-384 BYOK key. Rotation moves the old key into a 24-hour grace window during which it still verifies. The kid on each credential names the key that signed it, so a verifier can pick the right one from the document.
Entropy. The quantum nonce comes from an AES-CTR DRBG seeded from the entropy pool, which draws from Outshift QRNG first, then QCi uQRNG, then ANU QRNG, and falls back to the platform CSPRNG. Every draw is health-tested before use, and a reseed during issuance writes an entropy audit row that a provenance receipt can later resolve.
Cross-pillar binding
Passing bind_to_signal_hash on a shield-vc-present verify request links the verified credential to a carrier signal. The binding is written only when verification succeeded and at least one credential payload was decoded.
{
"action": "verify",
"vp_token": "eyJ...",
"bind_to_signal_hash": "<signal digest you already hold>"
}The row lands in shield_cross_references with match_method: "native_vc", match_score: 1.0, the keyed digest of the VC subject identifier in vc_subject_hash, your value in sim_signal_hash as supplied, and entity_profile_hash computed as SHA-256 of <signal hash>:<subject hash>:native:VerifiableCredential. The response returns the row id as cross_reference_id. An existing active row for the same pair is reused rather than duplicated; the lookup matches the current pepper, the previous pepper during a rotation, and the legacy unkeyed digest, so older rows still resolve.
The row is stamped hash_version: 1. The subject digest is keyed HMAC-SHA-256, but bind_to_signal_hash is a value the platform did not compute and cannot key, so the column reports the weaker of the two schemes present. Bindings created through shield-cross-reference from raw identifiers are stamped 2 or 3 on the same rule: 3 only when both digests were keyed as subjects, otherwise 2.
Revocation is not automatic. A carrier hard signal does not revoke credentials, cross-references or passkeys today: the cascade covers agent delegates and A2A negotiations. To act on a signal, call shield-vc-status with action: "revoke" yourself, from your own playbook or webhook handler. The cross-pillar revocation cascade is the subject of patent application 64/006,812 and is not shipped behaviour.
Bindings established from raw identifiers, passkey binding and the Enterprise cross-reference actions are in Deterministic Cross-Reference Binding.
Availability and scopes
No VC function checks the tenant's plan. The gate is the vc_enabled flag described above, which the onboarding wizard sets on any plan.
| Capability | What gates it |
|---|---|
| Issuance, verification, presentation, status | vc_enabled on the tenant |
| OID4VCI offers and credential requests | vc_enabled on the tenant |
| Public status reads and the StatusList2021 bitstring | Nothing; unauthenticated |
| External provider verification | An active shield_vc_providers row |
| ML-DSA-87 and ES384 keys | shield_tenants.pqc_level, moved by the billing add-on |
Cross-reference actions in shield-cross-reference | plan = enterprise, enforced in the function |
Settings shows the tenant's current algorithm and offers the ML-DSA-87 upgrade through Contact sales. Changing pqc_level affects keys minted afterwards; an existing key keeps its curve until it is rotated.
| Scope | Grants |
|---|---|
vc_issue | shield-vc-issue, and OID4VCI offer |
vc_verify | shield-vc-present, both actions |
vc_revoke | shield-vc-status writes: revoke, suspend, reinstate, refresh |
admin satisfies any of them but cannot be minted onto a key; it is held by a tenant admin acting through a dashboard session. Mint scoped keys at Dashboard > API keys > "Generate API key".
Related from the blog
- ML-DSA-87 vs SLH-DSA-256: Choosing the Right Post-Quantum Signature for Long-Lived Identity Assertionsengineering · 19 min read
- OpenID4VP over the Digital Credentials API: Browser-Native Wallet Selection in Chrome and Safariengineering · 17 min read
- Credential Chaining: Deriving Trust from a Sequence of Issuer Attestationsengineering · 18 min read