StatusList2021 & Revocation
Revoke, suspend, and reinstate credentials using W3C StatusList2021 bitstrings.
Overview
shield-vc-status answers the question "is this credential still good?" two ways: one credential at a time, or as a single W3C StatusList2021 bitstring covering every credential a tenant has issued. Reads need no authentication, so a verifier who holds only a credential can resolve its status without an account.
- One bit per credential.
0is active,1is revoked or suspended. Both states set the bit, so the list answers "still valid" rather than distinguishing the two. - Most significant bit first. Credential at index
iis bytefloor(i / 8), bit7 - (i % 8). - Base64 of the raw bitstring.
encodedListis plain base64 with no compression. The StatusList2021 specification calls for a GZIP layer; this implementation omits it, so decode with base64 alone and do not attempt to gunzip. - Ordered by issuance. Credentials are ordered by
created_atascending, up to 16384 per tenant. - Cached. Status lists carry
Cache-Control: public, max-age=300and anETag; single and batch lookups carrymax-age=60.
Writes, meaning revoke, suspend, reinstate and an explicit rebuild, need vc_revoke on an API key or a tenant admin session JWT. Every write rebuilds the bitstring before responding.
Credential states
| State | Row condition | Reversible |
|---|---|---|
active | is_valid true, expires_at in the future | n/a |
suspended | is_valid false, revoked_at null | Yes, with reinstate |
revoked | revoked_at set | No |
expired | expires_at in the past | No |
unknown | No row with that credential id | n/a |
Evaluation order is revoked, then suspended, then expired, then active, so a revoked credential keeps reporting revoked after its expiry passes, and a suspended credential reports suspended even when expired.
unknown is not an error. It answers 200 with "message": "Credential not found in registry" and means the credential was not issued by this platform, or was issued under a different credential id.
Reinstating a revoked credential is refused: 422 with "Cannot reinstate a revoked credential—revocation is permanent". Suspension exists for the reversible case, such as a hold during an investigation.
Single credential status
curl "https://api.passkeybridge.io/v1/shield-vc-status?credential_id=urn:uuid:abc123"{
"credential_id": "urn:uuid:abc123",
"status": "active",
"credential_type": "IdentityAttestation",
"issuer": "did:web:passkeybridge.io:tenants:acme",
"issued_at": "2026-03-15T10:00:00.000Z",
"expires_at": "2026-06-13T10:00:00.000Z",
"revoked_at": null,
"checked_at": "2026-03-27T14:30:00.000Z"
}This lookup is not scoped to a tenant: any credential id resolves, which is what lets a third-party verifier use it.
Up to 100 ids can be checked at once, either as a query parameter or as a public POST:
curl "https://api.passkeybridge.io/v1/shield-vc-status?credential_ids=urn:uuid:abc123,urn:uuid:def456"
curl -X POST https://api.passkeybridge.io/v1/shield-vc-status \
-H "Content-Type: application/json" \
-d '{"action":"batch_status","credential_ids":["urn:uuid:abc123","urn:uuid:def456"]}'Both return { "credentials": [...], "checked_at": "..." } with one entry per requested id, in request order, and status: "unknown" for ids that do not resolve. Ids beyond the first 100 are dropped silently, so send at most 100. The GET form carries the full fields shown above; the POST form returns credential_id, status, credential_type and issuer only.
A GET with no credential_id, credential_ids or issuer is a health probe and returns {"ok": true, "service": "shield-vc-status"}. A GET with an unrecognised combination answers 400 Missing query parameter plus a hint listing the three supported forms.
StatusList2021 bitstring
curl "https://api.passkeybridge.io/v1/shield-vc-status?issuer=did:web:passkeybridge.io:tenants:acme&type=statuslist"{
"statusListCredential": {
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://w3id.org/vc/status-list/2021/v1"
],
"id": "https://api.passkeybridge.io/v1/shield-vc-status?issuer=did%3Aweb%3Apasskeybridge.io%3Atenants%3Aacme&type=statuslist",
"type": ["VerifiableCredential", "StatusList2021Credential"],
"issuer": "did:web:passkeybridge.io:tenants:acme",
"issued": "2026-03-27T14:30:00.000Z",
"credentialSubject": {
"id": "did:web:passkeybridge.io:tenants:acme#status-list",
"type": "StatusList2021",
"statusPurpose": "revocation",
"encodedList": "QA=="
}
},
"totalCredentials": 3,
"revokedCount": 1,
"indexMap": {
"urn:uuid:abc123": 0,
"urn:uuid:def456": 1,
"urn:uuid:9f10ab": 2
},
"generated_at": "2026-03-27T14:30:00.000Z"
}That encodedList is the whole example: three credentials, the one at index 1 revoked or suspended, padded to a minimum of one byte. Base64 QA== decodes to the single byte 0x40, which is 01000000 with the most significant bit as index 0.
The statusListCredential object is a plain JSON document. It is not signed, so it carries no proof of its own; its integrity rests on the TLS connection to api.passkeybridge.io.
Responses carry ETag, Last-Modified and Cache-Control: public, max-age=300. Send the ETag back to skip the rebuild:
curl -H 'If-None-Match: "sl-3f9c1a2b4d5e6f70"' \
"https://api.passkeybridge.io/v1/shield-vc-status?issuer=did:web:passkeybridge.io:tenants:acme&type=statuslist"A match answers 304 with no body. The ETag is derived from the bitstring alone, so it is stable while no status changes, even though generated_at moves on every rebuild.
The list covers at most 16384 credentials, ordered by creation. A tenant past that number gets a truncated list, and credentials beyond the cap have no bit and no indexMap entry; check those with a single-credential lookup.
Issuer DID format
The issuer parameter must be a tenant DID in exactly this shape, and the slug is looked up in shield_tenants:
did:web:passkeybridge.io:tenants:<slug>Anything else answers 400, with the same body for a malformed DID and for a well-formed DID whose slug matches no tenant:
{
"error": "Invalid issuer DID format. Expected did:web:passkeybridge.io:tenants:{slug}"
}An unknown slug does not answer 404 here, so treat a 400 from this endpoint as "no list for that issuer" rather than proof of a typo.
It is the same DID the issuance engine writes into iss, and the same one shield-did-resolve publishes a document for, so a verifier can take iss from a credential and use it here unchanged.
Revoke, suspend, reinstate
Writes take an API key with vc_revoke, or a tenant admin session JWT. A session JWT from a member who is not an admin answers 403 Only admins can manage credential status. The credential must belong to the authenticated tenant.
curl -X POST https://api.passkeybridge.io/v1/shield-vc-status \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: <tenant uuid>" \
-H "Content-Type: application/json" \
-d '{"action":"revoke","credential_id":"urn:uuid:abc123","reason":"key_compromise"}'action is revoke, suspend or reinstate. reason is optional and recorded in the audit log, defaulting to manual_revocation or manual_suspension.
{
"credential_id": "urn:uuid:abc123",
"status": "revoked",
"revoked_at": "2026-09-16T10:00:00.000Z",
"status_list": {
"refreshed": true,
"totalCredentials": 3,
"revokedCount": 1,
"etag": "\"sl-3f9c1a2b4d5e6f70\"",
"generated_at": "2026-09-16T10:00:00.000Z"
}
}status is revoked, suspended or active after a reinstate, and revoked_at appears only on a revoke. The status_list block is the rebuild that ran inline, so a verifier refetching immediately sees the new bit. If the rebuild fails the write still stands and the block reads {"refreshed": false, "error": "Auto-refresh failed"}.
action: "refresh" rebuilds the list without changing any credential. It takes an optional issuer_did, which must belong to the caller's tenant, and returns the status list credential with its counts and ETag.
| Status | Body | Cause |
|---|---|---|
400 | Missing credential_id | Field absent on a lifecycle action |
400 | Missing x-pb-tenant-id header | API key sent without the tenant header |
400 | Unknown action. Supported: revoke, suspend, reinstate, refresh, batch_status | Unrecognised action |
401 | Invalid API key or Missing authorization | No usable credential |
403 | API key missing 'vc_revoke' scope | The key lacks the scope |
403 | Only admins can manage credential status | Session JWT whose membership role is not admin |
403 | Issuer DID does not match your tenant | refresh with another tenant's issuer_did |
404 | Credential not found for this tenant | Unknown id, or one belonging to another tenant |
409 | Credential already revoked plus revoked_at | Revoking twice |
422 | Cannot reinstate a revoked credential—revocation is permanent | Reinstating a revoked credential |
405 | Method not allowed | Anything other than GET, POST, HEAD or OPTIONS |
Every write lands in the audit log as vc.revoke, vc.suspend, vc.reinstate or statuslist.refresh. A revoke additionally writes a vc_revoked row to shield_events; suspend and reinstate write audit entries only.
Confirm it worked in Dashboard > Credentials > Issued credentials: the row's badge moves to Revoked or Suspended, and a suspended credential offers a Reinstate button while a revoked one offers none.
SDK usage
import { PBWallet } from "@passkeybridge/vc-wallet-sdk";
const wallet = new PBWallet({
apiKey: process.env.PB_API_KEY!,
tenantId: process.env.PB_TENANT_ID!,
});
// Public reads: no scope needed.
const status = await wallet.checkStatus("urn:uuid:abc123");
// { credentialId, status: "active", credentialType, issuer, issuedAt, expiresAt }
const { credentials, checkedAt } = await wallet.batchCheckStatus([
"urn:uuid:abc123",
"urn:uuid:def456",
]);
// Lifecycle and rebuild: vc_revoke scope.
await wallet.revoke("urn:uuid:abc123", "key_compromise");
await wallet.suspend("urn:uuid:def456", "investigation");
await wallet.reinstate("urn:uuid:def456");
const list = await wallet.refreshStatusList();
// { etag, totalCredentials, revokedCount, generatedAt, statusListCredential }checkStatus returns a StatusResult object, so read status.status for the state itself. batchCheckStatus accepts up to 100 ids and throws before sending if given more.
The SDK has no method for fetching a status list by issuer: refreshStatusList calls the authenticated rebuild, which returns the list for your own tenant. To read another issuer's list, request the public endpoint directly:
const res = await fetch(
"https://api.passkeybridge.io/v1/shield-vc-status?issuer=" +
encodeURIComponent("did:web:passkeybridge.io:tenants:acme") +
"&type=statuslist",
);
const { statusListCredential, indexMap } = await res.json();Offline verification
The bitstring is a static artifact, so a verifier can cache it and resolve status without a network call per credential.
- Fetch the list once with
?issuer=<did>&type=statuslist. - Store
encodedList,indexMapand theETag. - Look the credential id up in
indexMap. An id that is absent has no bit, so fall back to a single-credential lookup. - Base64-decode
encodedListto bytes, then read bytefloor(i / 8), bit7 - (i % 8). - A set bit means revoked or suspended. Treat both as "do not accept".
- Refresh on the
Cache-Controlwindow of 300 seconds, sendingIf-None-Matchso an unchanged list costs a304.
function isRevoked(encodedList: string, index: number): boolean {
const bytes = Uint8Array.from(atob(encodedList), (c) => c.charCodeAt(0));
const byte = bytes[Math.floor(index / 8)] ?? 0;
return (byte & (1 << (7 - (index % 8)))) !== 0;
}Two properties to design around. Expiry is not in the bitstring, so check exp on the credential yourself. And a reinstated credential clears its bit, so a cached copy can be wrong in the permissive direction for as long as you hold it: shorten the refresh window where that matters, or confirm with a single-credential lookup before a high-value decision.
Related from the blog
- Credential Revocation at Scale: Bitstring Status List vs Accumulator Proofsengineering · 17 min read
- Verifiable Credentials Without the Blockchain: A Pragmatic Approach to Digital Identityengineering · 16 min read
- OpenID4VP over the Digital Credentials API: Browser-Native Wallet Selection in Chrome and Safariengineering · 17 min read