Skip to main content

StatusList2021 & Revocation

Revoke, suspend, and reinstate credentials using W3C StatusList2021 bitstrings.

Last reviewed September 26, 2026Current

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. 0 is active, 1 is 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 i is byte floor(i / 8), bit 7 - (i % 8).
  • Base64 of the raw bitstring. encodedList is 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_at ascending, up to 16384 per tenant.
  • Cached. Status lists carry Cache-Control: public, max-age=300 and an ETag; single and batch lookups carry max-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

StateRow conditionReversible
activeis_valid true, expires_at in the futuren/a
suspendedis_valid false, revoked_at nullYes, with reinstate
revokedrevoked_at setNo
expiredexpires_at in the pastNo
unknownNo row with that credential idn/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"

Without a credential the answer is the public view: the status list credential and when it was generated.

{
  "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-09-26T14:30:00.000Z",
    "credentialSubject": {
      "id": "did:web:passkeybridge.io:tenants:acme#status-list",
      "type": "StatusList2021",
      "statusPurpose": "revocation",
      "encodedList": "H4sIAAAAAAAA..."
    }
  },
  "generated_at": "2026-09-26T14:30:00.000Z"
}

encodedList is the bitstring, one bit per credential in issuance order, most significant bit first, padded to at least 131,072 bits, then GZIP-compressed and base64-encoded. A set bit means revoked or suspended.

The issuer view. The issuing tenant can also read the map from credential id to bit position and the totals. Send an API key of that tenant carrying vc_issue, vc_revoke or admin in x-pb-api-key, or a tenant admin session JWT, and the body adds totalCredentials, revokedCount and indexMap. That response is Cache-Control: private, no-store. A key that is presented but invalid for the issuer answers 401, and a key without one of those scopes answers 403; neither falls back to the public view. Until 2026-09-26 the map and the totals were on the public response. They let anyone list every credential id a tenant had issued, which a standard status list never discloses, so they moved behind authentication.

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.

Public responses carry ETag, Last-Modified and Cache-Control: public, max-age=300. Send the ETag back to skip the body:

curl -H 'If-None-Match: "slp-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 public ETag (prefix slp-) is derived from the bitstring alone, so it is stable while no status changes, even though generated_at moves on every rebuild. The issuer view has its own ETag (prefix sl-), which also changes when a credential is added.

A tenant holding more credentials than the list can carry gets 503 rather than a partial list. Check those credentials 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.

StatusBodyCause
400Missing credential_idField absent on a lifecycle action
400Missing x-pb-tenant-id headerAPI key sent without the tenant header
400Unknown action. Supported: revoke, suspend, reinstate, refresh, batch_statusUnrecognised action
401Invalid API key or Missing authorizationNo usable credential
403API key missing 'vc_revoke' scopeThe key lacks the scope
403Only admins can manage credential statusSession JWT whose membership role is not admin
403Issuer DID does not match your tenantrefresh with another tenant's issuer_did
404Credential not found for this tenantUnknown id, or one belonging to another tenant
409Credential already revoked plus revoked_atRevoking twice
422Cannot reinstate a revoked credential—revocation is permanentReinstating a revoked credential
405Method not allowedAnything 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. It returns the bitstring only, without the id-to-index map:

const res = await fetch(
  "https://api.passkeybridge.io/v1/shield-vc-status?issuer=" +
    encodeURIComponent("did:web:passkeybridge.io:tenants:acme") +
    "&type=statuslist",
);
const { statusListCredential } = await res.json();

Offline verification

The bitstring is a static artifact, so a party that knows a credential's bit position can cache it and resolve status without a network call per credential.

Who knows the position. The issuing tenant does: it reads indexMap from the issuer view (see StatusList2021 bitstring) with its own key. A third-party verifier does not, because credentials issued today carry no credentialStatus entry naming their index and the public list no longer publishes the map. A third-party verifier checks the one credential it was shown with ?credential_id=, or up to 100 at once with ?credential_ids=.

For the issuing tenant:

  1. Fetch the issuer view once with ?issuer=<did>&type=statuslist and your API key.
  2. Store encodedList, indexMap and the ETag.
  3. Look the credential id up in indexMap. An id that is absent was issued after your copy; refetch or use a single-credential lookup.
  4. Base64-decode encodedList, GZIP-decompress it, then read byte floor(i / 8), bit 7 - (i % 8).
  5. A set bit means revoked or suspended. Treat both as "do not accept".
  6. Refresh within 300 seconds, sending the stored ETag back in If-None-Match so an unchanged list costs a 304. Tags are compared weakly, so the W/"…" form the edge returns matches, and so do a comma-separated list and *.
async function isRevoked(encodedList: string, index: number): Promise<boolean> {
  const gz = Uint8Array.from(atob(encodedList), (c) => c.charCodeAt(0));
  const raw = new Uint8Array(await new Response(
    new Blob([gz]).stream().pipeThrough(new DecompressionStream("gzip")),
  ).arrayBuffer());
  const byte = raw[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