Skip to content

Revocation and status lists

Revoking a credential you have already handed out is the problem credentials have that tokens do not: the holder keeps their copy, and it stays cryptographically valid. The answer is a W3C Bitstring Status List — one signed, published bitstring per organization, with a bit reserved for each credential.

Requires credentials:revoke.

POST /v1/credentials/vc_…/revoke
Authorization: Bearer sk_live_…
{ "ok": true }

Revocation flips that credential’s bit. The next verifier to check the list sees notRevoked: false, and valid becomes false.

Writes a vc_revoked audit event and fires a credential.revoked webhook. An org-scoped key can only revoke its own org’s credentials.

Each issued credential is allocated an index in the org’s status list at issuance — atomically, so concurrent issuance never hands out the same index twice. The credential carries a pointer to its own bit:

{
"credentialStatus": {
"id": "https://acme.oauth.work/status/sl_…#4217",
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "4217",
"statusListCredential": "https://acme.oauth.work/status/sl_…"
}
}

The list itself is published as an org-signed credential:

GET https://acme.oauth.work/status/sl_…
Content-Type: application/vc+jwt

It is a gzipped bitstring — 131,072 bits, about 16 KB uncompressed, and far smaller on the wire because a list with a handful of revocations compresses to almost nothing.

This shape is what makes revocation checkable without leaking anything. A verifier fetches the whole list and inspects one bit, so the issuer never learns which credential was being checked — quite unlike asking “is credential 4217 still valid?”

The verify endpoint does it for you and reports the result in checks.notRevoked. To check yourself:

  1. Read credentialStatus.statusListCredential from the credential.
  2. Fetch it and verify its signature against the issuer’s DID document — the list is itself a signed credential, so a tampered list is detectable.
  3. Base64url-decode and gunzip encodedList.
  4. Read the bit at statusListIndex. Set means revoked.
const list = await fetch(statusListCredential).then((r) => r.text())
// verify the JWT, then:
const bytes = gunzip(base64urlDecode(encodedList))
const revoked = (bytes[index >> 3] & (0x80 >> (index & 7))) !== 0

If a credential points at a status list this platform does not host, verification returns not valid, with:

{ "error": "revocation status could not be verified (foreign status list)" }

Failing closed here is deliberate. Treating an unverifiable status as “probably fine” would let anyone defeat revocation by pointing their credential’s status entry at a host that never answers.

A credential issued without an organization gets no status list entry, and therefore cannot be revoked. Its verification result simply has no notRevoked check — absence means “not revocable”, not “not revoked”.

Tenant-issued credentials always get an entry.

Revocation versus expiry versus key rotation

Section titled “Revocation versus expiry versus key rotation”

Three different tools, often confused:

EffectTiming
RevocationOne credential stops verifyingImmediate, at the verifier’s next check
ExpiryThe credential ages outAt its 90-day exp
Key rotationNothing stops verifyingRetired keys stay published on purpose

Rotating a key does not invalidate credentials — that is the point of keeping retired keys published. To pull a credential, revoke it.

GET /v1/organizations/org_01J…/credentials?limit=50&after=…
Authorization: Bearer sk_live_…

Cursor-paginated, showing type, format, holder, expiry, and status — which is where to look when you need to find the credential to revoke.