OpenID4VC Credential Revocation Checks Depend on Status List Fetch Discipline
OpenID4VC makes a signed credential tamper-evident. It does not make the credential self-revoking. The revocation decision lives in a separate status list token that the verifier must fetch at presentation time, and the discipline of that fetch is where most real-world failures hide. This piece settles how status list bit indexing works, why fail-open caching is the default bug, and what a verifier should do when the fetch fails.
Signed Credentials Still Expire
A verifiable credential is a set of claims with a cryptographic signature over them. Change one byte and the signature breaks. That property is well understood and widely implemented. What the signature cannot express is a fact that changes after issuance: the holder's license was suspended last Tuesday, the employee left the company, the certificate was withdrawn.
Revocation status therefore lives outside the signed payload, in a status list token the issuer publishes and the verifier fetches. The credential carries a pointer to that list and an index into it. The signature proves the credential was issued. The status check proves it is still good. Those are different questions answered by different network calls.
The gap between the two is where authorization failures occur. A verifier that validates the signature and skips the status fetch has verified a credential that may have been revoked months ago. The signature check passes, the session opens, and nothing in the logs records that the status was never consulted.
Status List Tokens and Bit Indexing
The W3C Token Status List specification encodes revocation as bit positions. Each credential the issuer tracks is assigned an index, and one bit at that index in the list records whether the credential is valid or revoked. A single list token can cover a large population of credentials because the encoding is dense: thousands of statuses in a payload measured in kilobytes.
The issuer publishes the list, the verifier fetches it, and the verifier looks up the bit at the index named in the credential. The lookup itself is trivial. The hard part is knowing whether the list you fetched is the current one. A status list token carries its own validity window and cache directives, and those are as important as the bits.
A stale list is a silent authorization failure. The bit at index 4,211 reads zero because the list was generated before the revocation was processed. The verifier sees a valid credential. Nobody sees an error. The failure is invisible precisely because the check appeared to succeed.
The Fetch Discipline Failure Mode
Most verifier bugs are not cryptographic. They are caching bugs. A team adds a status list fetch, sees latency climb, and puts a one-hour cache in front of it. The cache works, the latency drops, and the revocation window silently widens to an hour. Nothing in the test suite catches this because the tests use fixtures that never change.
Ignoring cache-control headers on the status endpoint is the same bug with extra steps. The issuer sets a short max-age because revocations matter; the verifier overrides it with a local TTL because the fetch is slow. The two policies disagree, and the verifier's policy wins at runtime. A related piece on password manager recovery paths makes a similar point about unaudited fallbacks.
Falling back to last-known-good on fetch errors is the most dangerous variant. The issuer's endpoint times out, the verifier serves the cached list, and the check passes because nothing loaded. Fail-open behavior is the default bug in status checking, and it appears in code that looks correct: a try/catch that logs a warning and returns the previous result.
What Good Verification Looks Like
Fetch before every presentation, not after. The status check belongs in the same critical path as the signature check, and both must complete before any session is issued. Deferring the fetch to a background job means the first presentation after a revocation succeeds when it should not.
Treat fetch failure as revocation-unknown and deny. This is the single decision that separates a working verifier from a broken one. A timeout, a TLS error, a malformed response, or a content type that is not the expected token format all resolve to the same answer: the status cannot be confirmed, so the presentation is refused. The user sees a clear error rather than a silent bypass.
Pin issuer endpoints and validate TLS and content type. The status list URL comes from the credential, which means an attacker who can influence that field can point the verifier at a list they control. Endpoint allowlisting, certificate validation, and strict content-type checks close that path. Bound list size and index range as well, so a hostile list cannot exhaust memory or force an out-of-range read.
Log fetch latency and freshness per verification. The bit lookup is cheap; the evidence that the list was current is what auditors will ask for. A log line recording the fetch time, the list's validity window, and the index consulted turns a silent failure into a diagnosable one.
Operational Costs of Freshness
Every presentation becomes a network round trip. That is the price of a correct revocation check, and it changes the capacity model. A verifier that previously answered in milliseconds now depends on an external endpoint's p99 latency. The issuer's status endpoint faces load proportional to verification volume, not issuance volume, which is a different and often larger number.
CDN caching helps, but freshness budgets shrink under it. A CDN that serves a five-minute-old list is fine for some credential types and unacceptable for others. The issuer's cache-control headers set the ceiling, and the verifier's policy has to live underneath it rather than beside it. The two have to agree, and the agreement has to be tested.
Availability of the issuer becomes the verifier's problem. When the status endpoint is down, a fail-closed verifier stops accepting presentations. That is the correct behavior and a real operational cost. Teams that cannot accept the outage will be tempted toward fail-open, and the temptation should be named in the policy document before the incident, not after.
Concrete Practices for Implementers
Set the maximum cache age on status fetches from the issuer's headers rather than a local constant, and revalidate on every presentation that matters. Implement an explicit deny path for fetch timeout, TLS failure, malformed token, and unexpected content type, and make that path return a distinct error the front end can render. Cache with a short TTL and revalidate on each use, so the cache is an optimization rather than a policy override.
- Monitor the status endpoint as a named dependency with its own health check and alert threshold.
- Document fail-closed behavior in the verifier policy and link it from the error page users see.
- Test with a fixture where the status list changes between two presentations of the same credential, and assert that the second is refused.
The trade-off is real. A fail-closed verifier inherits the issuer's uptime, and some deployments will find that unacceptable. The honest response is to shorten the dependency chain, cache aggressively within the issuer's window, and publish the availability assumption. The dishonest response is a try/catch that returns true.
Why This Matters for Production Deployments
The stakes are not theoretical. A credential that grants access to a physical facility, a financial account, or a privileged API is only as trustworthy as the revocation check behind it. When that check is skipped or served from a stale cache, the verifier is asserting a fact it has not confirmed. The signature still verifies, the user experience is smooth, and the security guarantee is quietly absent.
Consider a mid-sized enterprise that issues employee badges as verifiable credentials. Suppose it revokes access for a departing employee on Friday afternoon. If the verifier caches status lists for an hour, the badge continues to open doors until the cache expires. The window is small in absolute terms, but it is exactly the window an attacker would exploit. The same logic applies to any high-value credential: the longer the cache, the larger the exposure.
Hedged data points from public incident reviews suggest that a meaningful share of authorization bypasses in credential systems trace back to stale or missing status checks rather than broken cryptography. The exact percentages vary by deployment, but the pattern is consistent: teams invest in signature validation and treat revocation as an afterthought. That ordering is backwards for any system where revocation speed matters.
One practical mitigation is to treat the status list as a critical dependency with its own service-level objective. That means monitoring its availability, measuring fetch latency, and alerting when freshness falls outside the policy. It also means designing the user-facing error path so that a fail-closed decision is communicated clearly rather than surfacing as a generic failure. Users who understand why access was denied are less likely to retry blindly or to work around the system.
Another mitigation is to shorten the revocation window by combining short-lived credentials with frequent status checks. If a credential expires in minutes rather than months, the status list becomes a secondary control rather than the primary one. This shifts load from the status endpoint to the issuance endpoint, which may be cheaper to scale. The right balance depends on the credential type and the risk tolerance of the deployment, but the decision should be explicit.
Finally, document the assumptions. A verifier that fails closed is making a statement about its tolerance for issuer downtime. That statement belongs in the architecture decision record, not buried in code comments. When an incident occurs, the team can point to the documented trade-off and explain why the system behaved as designed. Without that record, every outage becomes a debate about whether the fail-closed behavior was a bug or a feature.