Skip to content

TFA Enforcement Architecture

TFA 2.x enforces two-factor authentication across three independent, layered mechanisms. Understanding this defense-in-depth architecture is essential when diagnosing authentication failures, evaluating third-party module integrations, or configuring security exemptions.

---
title: TFA 2.x Three-Layer Defense-in-Depth Architecture
---
flowchart TD
    REQ["Incoming HTTP / API Request"] --> L1

    subgraph L1 ["Layer 1: Authentication Provider Decoration"]
        direction TB
        L1_COOKIE["Cookie / Session Provider<br><i>(Non-exemptable)</i>"]
        L1_BASIC["HTTP Basic Auth Provider<br><i>(Non-exemptable)</i>"]
        L1_OTHER["Other Providers (JWT, OAuth, API Token)<br><i>(Exempted via tfa.auth_provider_bypass)</i>"]

        L1_COOKIE -->|Authenticates session| L3
        L1_BASIC -->|Delegates to user.auth| L2
        L1_OTHER -->|Decorated via compiler pass<br>Sets tfa_auth_method_bypass_approved flag| L3
    end

    subgraph L2 ["Layer 2: user.auth Service Decoration (TfaUserAuth)"]
        direction TB
        L2_FORM["Login Form / Direct user.auth Call"]
        L2_AUTH["TfaUserAuth (Priority 100)"]
        L2_CHECK["Check password + TFA token / validation plugin"]

        L2_FORM --> L2_AUTH
        L2_AUTH --> L2_CHECK
        L2_CHECK -->|Success| L2_FLAG["Set 'tfa_complete' flag in cache.tfa_memcache"]
        L2_FLAG --> L3
    end

    subgraph L3 ["Layer 3: Fail-Closed Backstop (TfaUserSetSubscriber)"]
        direction TB
        L3_EVENT["AccountEvents::SET_USER<br><i>AccountProxy::setAccount()</i>"]
        L3_EVAL{"Evaluate UID Validation Conditions"}
        L3_ALLOW["Permit Account Switch / Request Continues"]
        L3_DENY["Purge Session & Throw TfaAccessDenied<br><b>HTTP 403 Access Denied</b>"]

        L3_EVENT --> L3_EVAL
        L3_EVAL -->|1. Provider Exempt Flag Match| L3_ALLOW
        L3_EVAL -->|2. Legitimate AccountSwitcher Call| L3_ALLOW
        L3_EVAL -->|3. Anonymous Account| L3_ALLOW
        L3_EVAL -->|4. Validated Session Flag Matches UID| L3_ALLOW
        L3_EVAL -->|5. Memory Cache tfa_complete Matches UID| L3_ALLOW
        L3_EVAL -->|6. TFA Disabled or Not Required for User| L3_ALLOW
        L3_EVAL -->|No match| L3_DENY
    end

Layer 1: Authentication Provider Decoration

Drupal processes incoming requests through services tagged authentication_provider. TFA intercepts these providers dynamically:

  1. Mandatory Core Providers:
  2. cookie (Session): Hardcoded as non-exemptable. Sessions must carry a verified tfa_complete marker set by tfa_user_login() during login finalization.
  3. http_basic: Hardcoded as non-exemptable. Calls the user.auth service directly, requiring credentials to include the TFA token appended to the password or satisfy an active login plugin.

  4. Exemptable Providers:

  5. Providers configured in tfa.auth_provider_bypass (such as machine-to-machine JWT or API key modules) are dynamically decorated during container compilation by TfaAuthDecoratorCompiler.
  6. When an exempted provider successfully authenticates a request, its decorator records the authenticated UID in the request-scoped tfa_auth_method_bypass_approved memory cache key.

Layer 2: user.auth Service Decoration (TfaUserAuth)

TFA decorates Drupal core's user.auth service (\Drupal\tfa\TfaUserAuth, decoration_priority: 100):

  • Intercepts username and password validation attempts.
  • Evaluates whether the target user requires TFA.
  • Validates the second factor (either appended to the password or handled via challenge plugins).
  • If authentication succeeds, sets the tfa_complete flag in the request-scoped cache.tfa_memcache bin for the target UID.

Layer 3: Fail-Closed Backstop (TfaUserSetSubscriber)

The ultimate safety net in TFA 2.x is TfaUserSetSubscriber, which listens to AccountEvents::SET_USER with priority 50.

Because Drupal core dispatches AccountEvents::SET_USER whenever AccountProxy::setAccount() is executed (during every authenticated request, login completion, and account switch), TFA validates every attempt to set the active user context.

TfaUserSetSubscriber::rejectUserIfTfaBypassed() evaluates the following conditions in strict sequence:

  1. Exempt Authentication Provider: An exempted provider recorded tfa_auth_method_bypass_approved matching the exact UID during this request.
  2. Account Switcher: The account_switcher service is actively switching users (guarded by TfaAccountSwitcher).
  3. Anonymous User: Setting the account to UID 0 is always allowed.
  4. Validated Session: The user's active session carries a tfa_complete attribute matching the exact UID (set during tfa_user_login()).
  5. Memory Cache tfa_complete: The cache.tfa_memcache bin contains a tfa_complete record matching the exact UID (set by TfaUserAuth during this request).
  6. TFA Disabled / Not Required: TFA is globally disabled, disabled for the user, or the user's role is not required to use TFA and grace period/skip rules apply.

The Fail-Closed Guarantee

If none of the six criteria are satisfied, TfaUserSetSubscriber: 1. Destroys the current session ($session->invalidate()). 2. Throws a \Drupal\tfa\Exceptions\TfaAccessDenied exception. 3. Renders an HTTP 403 maintenance/fault response to the client.

This ensures that third-party code, incomplete login paths, or forgotten integration points cannot silently bypass two-factor authentication.