Verifying the FIDO Metadata Blob

The metadata blob is a signed document, and every fact inside it is worthless until that signature has been checked against a root you pinned deliberately. This page covers exactly what to verify, in what order, and — the part most implementations get wrong — what to do when verification fails without taking your registration endpoint down with it. It sits under Authenticator Metadata and MDS3, which explains why you would consume metadata at all.


Verification Requirements

Check Why it exists Failure means
Signature over the payload The blob is attacker-influenceable in transit The document is not the Metadata Service’s
Certificate chain to a pinned root A valid signature from the wrong issuer proves nothing Trust anchoring is broken or incomplete
Certificate validity dates Roots and intermediates rotate Your trust store is out of date
Sequence number monotonicity A replayed old blob hides recent revocations A stale or rolled-back copy was served
nextUpdate in the future Silent sync failure is the common decay mode Your fetch job has been failing unnoticed

Only the first and last rows are commonly implemented. The middle three are where real deployments quietly lose the property they think they have.

What a failed blob verification actually means Four distinct failures, only one of which is a genuine security event. Four failures, four different responses signature invalid the document did not come from the Metadata Service — keep the cached copy chain incomplete an intermediate is missing from the fetched token — a transport or parsing bug certificate expired your pinned root or an intermediate has aged out — update the trust store number went backwards a stale or rolled-back copy was served — refuse and alert

Root Cause Analysis

1. Verification is skipped entirely. The most common state. The blob is fetched over TLS, parsed, and used — and because TLS authenticated the server rather than the document, anything that can influence the egress path can influence your enrolment policy. This is not a theoretical concern in environments with corporate interception proxies.

2. The signature is checked but the chain is not anchored. A signature verifies against whatever certificate the token itself supplies, which proves only that the document is internally consistent. Without validating that certificate up to a root you chose in advance, the check is circular.

3. The trust store is never updated. Certificates expire. A pinned root that has aged out causes verification to start failing on a perfectly good blob, and because the failure is in your infrastructure rather than in the data, it is frequently misdiagnosed as a service outage.

4. Failure fails closed. An implementation that treats a verification error as fatal will stop serving metadata entirely, and if registration policy depends on those entries, registrations stop with it. The blast radius of an upstream publishing hiccup becomes your enrolment funnel.

The verification order that matters Signature first, then chain, then freshness — each step assumes the previous one passed. Never parse the payload before verifying the envelope raw token fetched over TLS header extract the certificate chain signature verify against the pinned root freshness number advanced, nextUpdate future Parsing entries before verification means an invalid blob has already reached your data structures by the time you reject it.

Step-by-Step Resolution

Step 1 — pin the root deliberately

Embed the FIDO Alliance root certificate in your deployment rather than fetching it alongside the blob. A root retrieved over the same channel as the document it authenticates provides no additional assurance.

Step 2 — verify the envelope before touching the payload

Extract the certificate chain from the token header, build the path to your pinned root, check validity dates and any available revocation information, then verify the signature. Only after all of that should the payload be parsed.

Step 3 — compare the sequence number

Read the blob’s number and compare it with the one you already hold. Equal means no update; greater means accept; lower means refuse and alert.

Step 4 — write atomically

Replace the cached entry set in one operation. A partially written cache is worse than a stale one, because lookups will return unknowns for models you actually support.

Step 5 — record what you verified

Store the sequence number, the nextUpdate value and the verification timestamp alongside the entries, so that an operator can answer “how current is this” without reading a log.

What to do when verification fails Failing closed on the blob means failing closed on registrations, which is almost never the behaviour you want. Degrade to the last good copy, never to no copy keep the previously verified blob in place alert on the verification failure, not on the fetch continue serving registrations from the cached entries escalate only when the cache age exceeds your tolerance A verification failure is a problem with the update, not with the data you already hold and already trusted.

Verification and Testing

Two fixtures prove the check is wired in: a correctly signed blob that must load, and one with a single byte altered in the payload that must be refused. Without the second fixture, an implementation that silently skips verification passes every test you have.

Add a third fixture with a valid signature from a certificate that does not chain to your pinned root. This is the case that distinguishes a real anchoring check from a self-consistency check, and it is the one most implementations fail.

Finally, assert the failure behaviour rather than only the failure detection: after a refused update, the previously cached entries must still be queryable and the registration path must still work. That test is what stops a future refactor turning a metadata problem into a sign-in outage.


Pitfalls

1. Fetching the root alongside the blob. It makes the verification circular. Pin it in your deployment.

2. Treating a verification failure as an empty cache. Keep the last good copy; an unverifiable update says nothing about data you already verified.

3. Alerting on the fetch rather than on the age. A job that succeeds while writing nothing is the failure you will actually get.


Frequently Asked Questions

Can I skip verification if I fetch over TLS from the official endpoint?

No. TLS authenticates the server you connected to, not the document you received, and it does not survive an interception proxy that your own organisation operates. The blob is signed precisely so that its authenticity does not depend on the transport.

What should happen to registrations while the cache is stale?

They should continue, using the entries you already verified. The data is incomplete rather than wrong: models certified since your last successful update will look unknown, which is the same outcome as an uncertified device and should be handled by the same policy.

How do I know my pinned root is still correct?

Watch the certificate validity dates in your monitoring, and treat the expiry as a scheduled maintenance item rather than an incident waiting to happen. A verification failure that begins on a specific date, on a blob that verifies elsewhere, is almost always an expired anchor on your side.

Is it safe to cache the parsed entries rather than the raw blob?

Yes, provided you record what you verified. Keeping the sequence number and the verification timestamp alongside the parsed entries gives you everything needed to reason about freshness, and the raw token is large and of no further use once it has been checked.


Where should the verification code live?

In a small module with no dependency on your web framework, taking a raw token and a trust anchor and returning either a verified entry set or a typed failure. Verification logic that can only run inside a scheduled job is logic that can only be tested by running the job, and the feedback loop for a trust-anchor problem is far too slow for that. A pure function is also what lets you keep the three fixtures described above in an ordinary unit test rather than in an integration suite nobody runs locally.

How often does the blob actually change?

Infrequently — new entries appear as models are certified, and status reports are added when certification changes. Because the update cadence is low, a daily fetch is generous and a failure to update for a day or two has no practical consequence. What matters is noticing when the gap becomes weeks, which is why the age of the cache belongs on a dashboard rather than in a log file. A directory frozen months in the past looks identical to a working one until a user presents hardware that shipped after the freeze.

Does verification need to happen on every lookup?

No, and it should not. Verify once per update, when the blob is fetched, and store the parsed entries with the evidence of that verification. Re-verifying on every registration would put a signature check and a chain build on the request path for no additional assurance, since the data has not changed since the last time it was checked.

Related