StatusList2021 & Revocation

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

Last reviewed September 16, 2026Fresh

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"
{
  "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.

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:

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.

  1. Fetch the list once with ?issuer=<did>&type=statuslist.
  2. Store encodedList, indexMap and the ETag.
  3. Look the credential id up in indexMap. An id that is absent has no bit, so fall back to a single-credential lookup.
  4. Base64-decode encodedList to bytes, 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 on the Cache-Control window of 300 seconds, sending If-None-Match so an unchanged list costs a 304.
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