One Key for Login and Delivery
Organisations that deliver sensitive files from a content management system tend to grow a second credential system to protect them. The identity provider guards the login. The download endpoint grows its own passwords, its own tokens, or — at the far end — its own hardware-key enrollment.
Each parallel system is a second place to enroll, a second place to revoke, and a second story to tell an auditor. The divergence between them is where the incidents live.
We built the opposite arrangement on our own platform and then used it ourselves before recommending it to anyone. This is what it looks like and what it cost to get right.
The arrangement
The identity provider is the only party that ever touches the hardware credential. A user enrolls one security key there, once. Ordinary single sign-on uses it.
When that user opens a link to a gated file, the delivery layer does not run a ceremony of its own. It requires a token whose claims show the identity provider just ran one — the authentication context, the audience naming the delivery service, and an expiry — and it refuses anything else. Every comparison is exact. Every missing piece denies: no signing secret, no service; no audience, no download; the wrong authentication context, a challenge rather than bytes.
The user-visible result is that one physical key opens the site and authorises the download, with no second enrollment anywhere and one audit trail to read.
The delivery layer is our own open-source module for Drupal, so the gate is inspectable rather than a claim we make about ourselves. The pattern is not specific to it: any service willing to verify three claims and fail closed can sit on the same side of the arrangement.
Lesson one: the vendor's canonical flow shape is load-bearing
Identity providers that support step-up authentication generally model it as conditional logic on an authentication level: run this extra factor when the session has not yet reached the level the client asked for.
Our first attempt kept the ordinary password form outside that conditional structure, on the reasonable-sounding theory that the everyday login path should not be restructured to add an occasional extra step. It worked in the sense that the step-up fired. It also fired on every ordinary login.
The reason is worth internalising, because it generalises: a level condition typically evaluates true when the session has no recorded level at all, and levels are only recorded when a level-conditioned block completes. Leave the password form outside that structure and every session stays level-less, so every condition is true, so the hardware step is demanded of everyone every time.
We found it in minutes, on a non-production environment, with a throwaway test account — which is the only reason it is a lesson rather than an incident. The fix was the documented shape all along, and we confirmed the semantics by reading the provider's source at the exact version we were running. The prose documentation alone would not have settled it.
Lesson two: the dangerous failures return success
Three times in one build, an administrative interface accepted a write and did nothing with it.
A policy object was submitted with one field name in the wrong case; the server dropped the unrecognised key and returned a success. A binding was cleared by sending an empty structure, which the API accepted and ignored — the documented way to clear it was to send the field with an empty value. A list of token scopes, pinned explicitly for good reasons years earlier, silently suppressed the very claims the new gate depended on; nothing anywhere reported a conflict, because from the server's point of view there wasn't one.
None of those raised an error. All three would have shipped as "configured" and behaved as "not configured". The only discipline that catches this class is unglamorous: after every write, read the state back and compare it to what you intended — and before trusting any check, prove it can fail as well as pass. A verification that has only ever been observed passing is not a verification.
Lesson three: stage it so that lockout is structurally impossible
The risk in this work is not that hardware authentication is hard. It is that authentication changes are one of the few things in a software estate that can lock the people fixing them out of the system.
Four rules made that impossible rather than unlikely. Enrollment stays optional, so the existing login keeps working for everyone at every step. The new flow binds to a single client, never the realm-wide default, so the blast radius is one application. An administrative path that the flow under test cannot govern stays available throughout — for most providers, an administration realm the application flows do not apply to. And the rollback is exercised for real, before enforcement, not written down and hoped for: unbind, confirm the gate is inert, re-apply, confirm it returns.
Ordering matters within the feature too. We shortened the assurance window — the period during which an earlier key touch still authorises a download — only after the application could transparently refresh an expired token. Done in the other order, the tightening does not increase assurance; it just strands people mid-session and teaches them to distrust the feature.
What we would tell someone considering it
The technical work is a few days. The decisions are the part worth slowing down for: which party is allowed to hold the credential, what the assurance claim is named, how long a ceremony stays valid, and what happens when a claim is missing rather than wrong.
Get those four right and the implementation is small. Get them wrong and you will have built a second credential system with extra steps — which is the thing you were trying to avoid.