Skip to content

Graduated authentication freshness: track per-factor authentication age on the session #24270

Description

@GirlBossRush

Summary

Track when each authentication factor was last satisfied, per session, instead of treating authentication as a single binary state with one timestamp. Consumers (stages, policies, providers) then decide which factors are stale and replay only those.

This matches the UX of providers like Google: an existing session that needs revalidation prompts only for the factor that has aged out (say, the password), rather than the full login flow with MFA again.

Problem

Once a session is no longer considered fresh, the user generally re-runs the whole authentication flow. But authentication isn't binary. A password, a WebAuthn assertion, and a TOTP code all have different sensible lifetimes, and a session is often still valid while only one of them has expired.

authentik already has several partial answers to this, each with a different scope and none visible to the others:

  • AuthenticatorValidateStage.last_auth_threshold lets users skip MFA within a window, but the record lives in a signed browser cookie scoped to one stage PK and one device PK (authentik/stages/authenticator_validate/stage.py, validate_mfa_cookie). Policies, other stages, and providers can't see it, and the same factor validated through a different stage instance doesn't count.
  • The session records how the user authenticated via PLAN_CONTEXT_METHOD / PLAN_CONTEXT_METHOD_ARGS, but it's a single method string set with setdefault (first writer wins) and no per-factor timestamps.
  • The OIDC provider reconstructs amr and auth_time from the last LOGIN event (authentik/providers/oauth2/id_token.py), so every factor inherits the timestamp of the last full login.
  • max_age on the authorize endpoint is honored (authentik/providers/oauth2/views/authorize.py), but by forcing a complete re-run of the authentication flow. There is no way to satisfy it by replaying only what's stale.

These fragments show the demand for per-factor freshness; the gap is a unified record they can all read and write. #20259 (MFA skipping broken since 2025.12) and #22466 (authentik_device cookie not set) are both breakage in the cookie-based fragments. #14440 (step-up authentication via ACR values, RFC 9470) is confirmed but has no session model to build on. This issue proposes that model.

Proposal

At validation time, each authenticating stage records a factor entry on the session:

Session
├── created
├── last activity
└── authenticated factors
    ├── { method: "pwd", authenticated_at: ... }
    ├── { method: "hwk", device: <webauthn device pk>, authenticated_at: ... }
    └── { method: "otp", device: <totp device pk>, authenticated_at: ... }

Entries store bare facts only: which factor, which device where applicable, and when. Freshness windows live on the consumer, not the record, consistent with how last_auth_threshold already works. Nothing expires on the producer side; each consumer evaluates the timestamps against its own requirement at check time.

Consumers would include:

  • Stages: a stage can skip itself when its factor is fresh enough. This generalizes last_auth_threshold from a per-stage cookie to session state, and gives the password stage the same capability for free.
  • Policies: expose the factor records in the policy engine, so flows can branch on "password fresh within 30 days, MFA fresh within 12 hours" and replay only the stale stage.
  • OIDC provider: compute amr from the recorded factors and auth_time from the most recent entry satisfying the request. max_age and future acr_values handling replay only what's stale instead of the whole flow.

Worked example

Password window 30 days, MFA window 12 hours:

Existing session
        │
        ▼
Need AAL2?
        │
        ▼
Password still fresh? ── yes ──▶ replay only the MFA stage

If instead only the password has aged out, only the password stage replays. If the session itself has expired, the normal full flow runs; factor records die with the session.

Design positions

Questions we expect to come up, with a proposed answer for each:

  • Per authenticator or per stage? Per authentication method (AMR-style: pwd, otp, hwk, ...), with the device PK recorded where it applies. The same TOTP device validated through two different flows is one factor; keying on stage instances would fragment freshness across flows, which is exactly the limitation of today's MFA cookie.
  • Where do freshness windows live? On the requirement (stage config or policy), never on the record. The session stores timestamps; consumers decide what "fresh" means for their purpose.
  • How do multiple MFA methods interact? Each method keeps its own entry. A requirement like "MFA fresh within 12h" is satisfied if any method in the allowed device classes (mirroring authenticator_validate's device_classes) has a fresh entry.
  • Should this feed acr? Yes, with weakest-link semantics: the ACR asserted at token issuance is derived from the set of currently fresh factors, so a partial replay can never claim more assurance than what's fresh. This is the substrate support for the step-up authentication using ACR values #14440 needs.

Security considerations

  • Partial replay must be downgrade-safe: satisfying one stale factor never upgrades the session's asserted assurance beyond what the full fresh set supports.
  • Factor records are bound to the session and disappear on logout or revocation, same as the session itself. This is already stronger binding than the current MFA cookie, which survives independently in the browser.
  • Records should surface in the admin session list and in events, so operators can audit which factors backed a given session.

Benefits

Open questions

  • Storage: a JSON field on the session vs. a related model. A related model is queryable for admin/audit views; a JSON field is lighter.
  • Should the existing last_auth_threshold MFA cookie be deprecated onto this, or kept as a browser-side transport with the session records as the source the stage consults first?
  • SAML parity: AuthnContextClassRef could be derived from the same records; in scope or follow-up?
  • Flow designer UX for expressing "replay only stale stages" (the skip mechanism exists via stage-level skipping; the question is how it's configured).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions