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:
- Mandatory Core Providers:
cookie(Session): Hardcoded as non-exemptable. Sessions must carry a verifiedtfa_completemarker set bytfa_user_login()during login finalization.-
http_basic: Hardcoded as non-exemptable. Calls theuser.authservice directly, requiring credentials to include the TFA token appended to the password or satisfy an active login plugin. -
Exemptable Providers:
- Providers configured in
tfa.auth_provider_bypass(such as machine-to-machine JWT or API key modules) are dynamically decorated during container compilation byTfaAuthDecoratorCompiler. - When an exempted provider successfully authenticates a request, its decorator records the authenticated UID in the request-scoped
tfa_auth_method_bypass_approvedmemory 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_completeflag in the request-scopedcache.tfa_memcachebin 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:
- Exempt Authentication Provider: An exempted provider recorded
tfa_auth_method_bypass_approvedmatching the exact UID during this request. - Account Switcher: The
account_switcherservice is actively switching users (guarded byTfaAccountSwitcher). - Anonymous User: Setting the account to UID
0is always allowed. - Validated Session: The user's active session carries a
tfa_completeattribute matching the exact UID (set duringtfa_user_login()). - Memory Cache
tfa_complete: Thecache.tfa_memcachebin contains atfa_completerecord matching the exact UID (set byTfaUserAuthduring this request). - 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.