Assurance Is a Claim, Not a Ceremony
Put a hardware key in front of a sensitive download and you face a design choice that looks like an implementation detail and is not.
The first option is to make the thing that serves the file its own relying party: it runs its own WebAuthn ceremony, holds its own credential registrations, and verifies the signature itself. The second is to let the identity provider be the only party that ever touches the credential, and have the file service verify what the identity provider asserts about the authentication that already happened.
We took the second. It is federation in the sense NIST SP 800-63C means it — an asserted level rather than a level you verified yourself — and it is worth being precise about what that buys and what it obliges.
What the second enrollment really costs
The case for the first design is that it is self-contained. The case against it is everything that follows from a second enrollment surface.
A user who enrolls a key twice has two credentials to lose, in two systems with two revocation paths. An operator offboarding that user has two places to check, and the second one is the one nobody remembers. An auditor asking "which authenticator authorised this download" gets an answer from a system that has no idea what happened at login. And the moment those two systems disagree about who someone is, the delivery layer is the one that has to be wrong, because it is not the identity system.
Federation removes that entire surface. It replaces it with a narrower obligation: verify the assertion properly, and be honest about what the assertion means.
Three claims, and the one everybody forgets
An asserted level arrives as claims in a token. Three of them carry the whole contract.
What happened. The authentication context class — acr — is the identity provider's statement about how the user proved themselves this time. It is the claim the gate actually turns on.
Who it was for. The audience. Without an audience check, a token minted for your CMS satisfies your file service, and every other client of the same identity provider becomes a way in. This is the check most often left out, because leaving it out never breaks anything visibly.
When. Expiry — and this is the sleeper. Most verifiers check that a token has not expired. Very few check when the authentication ceremony actually occurred. If yours is one of the many, then token lifespan is your assurance window, whether or not anyone decided that. A twelve-hour token means a touch at nine in the morning is still authorising downloads at nine at night.
There are two honest fixes. Verify authentication time or request a maximum age, and reject a token whose ceremony is older than your policy allows. Or shorten the token lifespan deliberately, so the mechanism you already have expresses the policy you actually want. We took the second, chose a window measured in minutes, and — this ordering matters — only shortened it once the application could transparently refresh an expired token. A short lifespan without a refresh path does not tighten assurance; it strands sessions.
Do not name it after a certification you have not been assessed against
Whatever string your identity provider stamps into that claim becomes a published contract. Ours is disclosed to unauthenticated clients by design: when a request arrives without sufficient authentication, the correct response is a challenge that tells the client what would be sufficient — the pattern RFC 9470 describes for step-up. The required value goes out in a header, to anyone who asks.
That fact settled a naming argument for us. The obvious candidate was an assurance-level label borrowed from the NIST vocabulary. It reads well and everybody recognises it. It is also a compliance claim, published in a header, that we would have to defend on request — and a federated relying party checking an asserted level is not the same thing as a verifier assessed at that level. Two different claims, one convenient string.
We used a value from the OpenID Enhanced Authentication Profile vocabulary instead — one that describes what actually took place: phishing-resistant, hardware-protected. It is not shorter or clearer. It is just true, and it is a sentence we can finish in front of an assessor.
The general rule: an assurance string is a description of an event, not a certificate. If it reads as a certificate, expect to be asked for the certificate.
Everything fails closed, including the parts nobody tests
A federated gate has more ways to be misconfigured than a self-contained one, because it depends on claims arriving. That makes the failure direction the most important design decision in the whole feature.
An empty list of accepted assurance values denies rather than admits. A missing audience denies. A token with no expiry is refused outright, because a token that never expires cannot be reasoned about. A missing claim is a failed check, not a skipped one.
This sounds obvious written down. It is worth stating anyway, because the natural shape of the code is the opposite: you fetch a claim, you find nothing, and the tidy-looking thing to do is carry on. Fail-open bugs almost never look like decisions. They look like an if that nobody wrote.
What this design does not give you
It does not make your file service a hardware verifier, and you should not describe it as one. It inherits the identity provider's rigour: if that provider will accept a weaker authenticator for the same assurance value, your gate accepts it too, and neither the token nor your code will tell you. The strength of the whole arrangement is set at the identity provider and asserted downstream.
What it does give you is one credential, one enrollment, one revocation path, one place where authentication strength is decided, and a download that cannot be authorised by anything the identity provider did not just witness.
That is a smaller claim than "hardware-verified downloads". It is also one you can hold up in a review without qualifying it afterwards, which is the only kind of security claim worth publishing.