Session assurance: what a login proves depends on how it happened
Verified directors and public registries, earlier in this series, added a durable fact to the persona model: a proofing level, written once against a persona, true regardless of which session is checking it. That post closed with a boundary I didn't push on at the time - identity verification is about who a login belongs to, and says nothing at all about how strongly any particular session just authenticated as that persona. A password login and a passkey login are the same verified persona, holding the same role, with a completely different answer to the second question. This post is about that second question, and why it can't be answered the same way the first one was.
The same owner, a different session
A password login is enough to see an account balance. It is not enough to see the full transaction history, and it is nowhere near enough to add a new payee - not because the owner is any less verified than they were a minute ago with a passkey, but because this particular login didn't prove very much. Auth0 surfaces exactly this, inside a Post-Login Action, as event.authentication.methods - an array naming every factor actually used in the current authentication: pwd alone, or pwd plus a generic mfa entry for any step-up factor, or passkey on its own. Auth0 has no opinion on what any of that is worth, which is the same split the model already uses for resolving a persona to a person - the IdP authenticates the login, the application decides what the login means.
Why this isn't a relation
Everything else in this model that varies by "how much" - a spend cap, a time-bounded delegation, a task-scoped agent mandate - still ends up written as a tuple naming a specific persona, with a condition bounding it. Session assurance can't work that way, because there is no stable fact to write: it changes on every login, for the same persona holding the same role, and a tuple left behind after the session that justified it expires would keep granting access the session itself no longer earns. So it isn't a relation at all, in the usual sense - it's a public wildcard with a condition that takes nothing from the tuple:
define elevated_session: [persona:* with min_assurance]
define can_view_full: can_view and elevated_session
define can_perform_critical_action: admin and elevated_session
condition min_assurance(session_assurance_level: int, required_assurance_level: int) {
session_assurance_level >= required_assurance_level
}
One tuple, written once per account, switches the check on for every persona at once, purely by giving the condition somewhere to live - unlike the same wildcard on a company registry's public entry, it grants nothing on its own. Both of min_assurance's parameters come from the caller's context at check time, never from the tuple: session_assurance_level is whatever the current session's authentication strength maps to, and required_assurance_level is chosen by the application per action - viewing a full transaction history might need one level, adding a payee another, the same relation and condition serving both. can_view_full and can_perform_critical_action are purely additive; nothing that already depended on can_view, can_manage, or can_spend changes.
The same shape turns up identically across a SIM swap (telco), signing off a benefit decision instead of just reviewing it (citizen services), and raising a spending cap instead of spending against it (parental controls, spending limits) - twelve verticals, one condition, checked the same way everywhere it's needed.
One worked example is worth being specific about, because it's the cleanest illustration of the two questions being genuinely independent: a company's statutory filing needs both verified_director on the business - a durable fact, established once - and can_perform_critical_action on the filing account, which needs a stepped-up session, checked fresh. A director who's been fully verified for years still can't lodge a filing on a password-only session; a co-administrator who isn't yet a verified director can clear the session-assurance bar just as easily as the verified one can. Neither check substitutes for the other, because they aren't answering the same question.
Making the elevated session honest
None of this means anything if session_assurance_level can be true for longer than the login that earned it deserves. A passkey session from an hour ago is not the same fact as a passkey session from ten seconds ago, and the model has no way to tell the difference on its own - it just receives whatever number the application hands it. The application's side of that bargain is exactly what building genuinely ephemeral sessions with Auth0 Actions and Next.js worked through: a Post-Login Action that sets a short absolute expiry and an even shorter idle expiry on the session behind a step-up, using Auth0's api.session APIs. The same Action, in the same authentication event, both stamps the assurance level onto the ID token and bounds how long the session behind it gets to live - one function doing two jobs that only work together. Once that stepped-up session's idle timer runs out, the application simply stops being able to produce a high session_assurance_level, and every check falls back to whatever the baseline session can prove instead. Nothing about elevated_session or min_assurance needs to know any of that happened - there was never a tuple to clean up in the first place.
A process gap worth mentioning
Adversarial review, real bugs, and dead ends covered how an adversarial review pass caught a real OpenFGA semantics bug in this model, and the discipline behind that was verifying a claim against the actual system before it goes in a sentence anywhere. This post is a case of that same discipline failing to apply to itself. I asked for this post, and the process for publishing one has been defined and working for months - draft locally, hand it back to me for review, run an adversarial pass through a different model family, only commit and push once I've said so explicitly. None of that happened. The draft got written and pushed straight to production in one motion, and the only reason I know an adversarial review never ran is that I asked directly whether it had.
Running that review after the fact caught something real: the specific Auth0 authentication-method names in the first version of this post (webauthn-platform, otp) aren't values Auth0 actually documents for event.authentication.methods - the real ones are passkey and an undifferentiated mfa. Same shape of result as the building post described - adversarial review earns its keep by catching a confident first draft's specifics. The difference is that this time the review nearly didn't happen at all, on a post that had already shipped, which is its own finding: having a defined process is not the same thing as that process running.
The repository has the full model, the AMR-to-assurance mapping worked through in more detail, and all ten verticals this extends - financial services, telco, and government business registry among them.