Module Compatibility & Integration Pitfalls
TFA 2.x interacts deeply with Drupal core's authentication pipeline, form alteration lifecycle, and session management. Integrating TFA into sites with third-party authentication, single sign-on (SSO), custom login redirects, or administrative tooling requires careful architectural planning.
Single Sign-On (SSO) and externalauth Integrations
The externalauth module (and modules extending it, such as samlauth, openid_connect, simplesamlphp_auth, cas, social_auth, and OAuth2 clients) provides external authentication mapping for Drupal.
---
title: SSO / externalauth Interaction with TFA Fail-Closed Architecture
---
flowchart TD
SSO_IDP["Identity Provider (IdP)<br>(SAML, OIDC, CAS, OAuth2)"] -->|User Authenticated| SSO_MOD["Drupal SSO Module Controller"]
SSO_MOD -->|Calls| EXT_AUTH["ExternalAuth::loginRegister() / login()"]
EXT_AUTH -->|Calls| FINALIZE["user_login_finalize()"]
FINALIZE -->|Triggers| SET_ACCT["AccountProxy::setAccount()"]
SET_ACCT -->|Dispatches| SET_USER["AccountEvents::SET_USER"]
SET_USER --> TFA_SUB["TfaUserSetSubscriber::rejectUserIfTfaBypassed()"]
TFA_SUB --> EVAL{"Did request satisfy TFA?"}
EVAL -->|No tfa_complete flag & TFA required| REJECT["Purge Session & Throw TfaAccessDenied<br><b>HTTP 403 Access Denied</b>"]
EVAL -->|Provider in tfa.auth_provider_bypass| ALLOW["Login Permitted"]
EVAL -->|TFA not required for user| ALLOW
Why SSO Logins Trigger Access Denied by Default
When an external identity provider completes authentication, the SSO module typically calls ExternalAuth::login() or ExternalAuth::loginRegister(). Under the hood, externalauth invokes user_login_finalize($account).
user_login_finalize() calls AccountProxy::setAccount(), which dispatches AccountEvents::SET_USER. TfaUserSetSubscriber intercepts this event to verify that TFA requirements were satisfied.
Because standard SSO controller callbacks bypass user.auth and TFA's login form, neither cache.tfa_memcache nor the session carries a tfa_complete flag. If the resolved Drupal user belongs to a role where TFA is required, TFA fails closed: it destroys the session and returns an HTTP 403 access denied fault.
Architectural Solutions for SSO
Depending on your security model, choose one of the following approaches:
Option A: Identity Provider Handles MFA (Recommended for Enterprise SSO)
If your enterprise Identity Provider (e.g., Okta, Azure AD / Entra ID, PingFederate, Google Workspace) already enforces multi-factor authentication:
1. Treat the SSO provider as a trusted authentication authority.
2. If the SSO module registers an authentication_provider service, add its provider ID to tfa.auth_provider_bypass in your services.yml:
ExternalAuth::login() directly rather than an authentication provider service), implement an event subscriber or hook that manages the login context, or configure TFA role requirements so that external SSO users are not forced into Drupal-level TOTP duplicate challenges.
Option B: Drupal Enforces Second Factor After SSO
If the external IdP performs only primary (password) authentication and Drupal must enforce the second factor:
1. Do not exempt the SSO provider.
2. The SSO module must be configured or extended to initiate the TFA challenge flow (/tfa/{uid}/{hash}) upon user mapping rather than completing login finalization immediately.
3. Once the user satisfies the TFA challenge form, TFA will finalize the login and write the tfa_complete marker to the session.
Pre-Login Interception Points (for Dual-Layer Local TFA)
If local Drupal TFA is required on top of external SSO, a custom event subscriber or hook must intercept the SSO flow before userLoginFinalize() to initiate the /tfa/{uid}/{hash} challenge:
| SSO / Auth Module | Finalization Method | Pre-Login Event / Hook for TFA Redirection |
|---|---|---|
externalauth (Core Service) |
ExternalAuth::userLoginFinalize() |
ExternalAuthEvents::AUTHMAP_ALTER / ExternalAuthEvents::LOGIN |
samlauth |
SamlController::acs() |
SamlauthEvents::USER_SYNC |
openid_connect |
OpenIDConnectAutoDiscover::authorize() |
OpenIdConnectEvents::USERINFO_ALTER / OpenIdConnectEvents::POST_AUTHORIZE |
cas |
CasUserManager::login() |
CasEvents::PRE_LOGIN |
social_auth |
SocialAuthUserManager::authenticateUser() |
SocialAuthEvents::USER_LOGIN |
Option C: Role Separation for Local vs. Federated Users
- Create distinct roles for federated accounts (e.g., SSO Authenticated) versus local break-glass/administrative accounts (e.g., Direct Administrator).
- Configure TFA's "Roles required to set up TFA" to target only local credential users, preventing unnecessary TFA lockouts for federated SSO users.
Stateless Gateways & APIs (jsonapi, rest)
Core's JSON:API and REST modules consume authenticated sessions and tokens to expose API endpoints.
Route Protection & Intermediate Login State
JSON:API dynamic route generation (Drupal\jsonapi\Routing\Routes) attaches all registered container authentication providers to route _auth options ($routes->addOptions(['_auth' => $this->providerIds])).
Because JSON:API endpoints accept cookie authentication without requiring a CSRF token on GET requests:
- Intermediate Login State: When a user enters credentials on /user/login and is redirected to /tfa/{uid}/{hash}, TFA strips the uid from $form_state. The session remains anonymous (holding only PrivateTempStore data for OTP verification). TfaUserSetSubscriber allows anonymous requests to proceed, and restricted JSON:API endpoints return standard HTTP 401/403 access denied via entity access checks.
- Direct Session Forgery: If an authenticated UID is forged into a session without going through TFA, TfaUserSetSubscriber intercepts AccountEvents::SET_USER, throws TfaAccessDenied (HTTP 403), and immediately invalidates the session.
OAuth2 & Bearer Tokens (simple_oauth)
The simple_oauth module implements SimpleOauthAuthenticationProvider using RFC 6750 Bearer Tokens.
Risk Vectors & Architecture
- Stateless Verification:
simple_oauthdoes not calluser.authduring resource requests. - Unexempt Provider: If
oauth2is omitted fromtfa.auth_provider_bypass,TfaUserSetSubscriberfails closed (HTTP 403) for all bearer requests made by TFA-required accounts. - Exempt Provider: If
oauth2is added totfa.auth_provider_bypass, bearer token requests are permitted without Drupal TFA validation. - Resource Owner Password Credentials (ROPC) Grants: When Drupal acts as an OAuth2 authorization server handling ROPC or custom password grants, grant plugins handle credentials directly. If a grant plugin validates passwords without routing through
user.auth(or requiring token-appended passwords), tokens will be minted without 2FA validation. This must be audited and enforced at the grant plugin level. - Token Revocation Gap: Invalidation of a Drupal cookie session does not automatically revoke active OAuth2 access or refresh tokens.
Alternate Identifier Login Modules (mail_login, email_registration)
Modules that allow users to authenticate using an email address or alternative username (such as mail_login) decorate or wrap user.auth.
Potential Pitfalls
user.authDecoration Order: TFA decoratesuser.authwithdecoration_priority: 100. If an email login module decoratesuser.authwith a lower priority, TFA runs first.- Username Resolution in
TfaUserAuth:TfaUserAuth::authenticate($username, $password)expects$usernameto match a Drupal account'snameproperty (loadByProperties(['name' => $username])). If an email address is passed and the module resolves the username inside the core authentication handler rather than prior touser.auth,TfaUserAuthmay fail to locate the user context, resulting in authentication failures.
Recommended Integration Practice
- Ensure any module converting email addresses to usernames performs the resolution before invoking
user.auth, or ensure the decorator priorities place username normalization outsideTfaUserAuth. - Always integration-test logging in with both the username and email address when using these modules.
Login Redirect Modules (login_destination, Custom Redirect Handlers)
Modules that alter user redirects upon login (e.g., login_destination, custom hook_user_login implementations, or $form_state->setRedirect() calls) can conflict with TFA's challenge redirection.
Conflict Mechanism
- On valid credential entry, TFA's
TfaLoginFormHelper::tfaValidateSubmit()unsets the$form_stateuidto prevent core'sUserLoginForm::submitForm()from establishing a session immediately. - TFA appends
TfaLoginFormHelper::tfaSetRedirectAfterSubmit()to$form['#submit']to redirect the user to/tfa/{uid}/{hash}and strips thedestinationquery parameter. - If another module's submit handler runs after TFA's handler and unconditionally sets
$form_state->setRedirect(), it can overwrite TFA's redirect to the entry form.
Symptoms & Mitigation
- Symptom: User enters credentials and is redirected to a standard destination page (or home page) but appears as an unauthenticated (anonymous) user because the session was never finalized.
- Resolution: Adjust module weights via
hook_module_implements_alter()or ensure custom redirect modules verify whether a redirect has already been set to atfa.*route before altering$form_state.
Account Switching & Administrative Impersonation (masquerade, devel)
Modules like masquerade or core's account_switcher service permit administrators to switch account contexts.
Service Switching vs. Interactive Impersonation
- Service-Level Switching (
account_switcher): TFA decorates Drupal core'saccount_switcherservice (\Drupal\tfa\TfaAccountSwitcher,decoration_priority: 250). DuringswitchTo()andswitchBack(),TfaAccountSwitchersets a temporarytfa_account_switcher_bypassflag incache.tfa_memcache.TfaUserSetSubscriberdetects this flag and permits the switch without challenging the administrator for the target user's second factor. - Interactive Session Switching (
masquerade): Themasquerademodule implements browser session impersonation viaMasquerade::switchUser(). It calls$this->currentUser->setAccount($target_user),$session->set('uid', $target_user->id()), and invokeshook_user_login(). It does not callaccount_switcher::switchTo(). - Switching to a TFA-Disabled User:
TfaUserSetSubscriberpermits the switch because TFA is not active for the target user. - Switching to a TFA-Enabled User (Lockout Behavior):
AccountProxy::setAccount()triggersAccountEvents::SET_USER. Because neitheraccount_switchernor interactive OTP validation settfa_completefor the target UID,TfaUserSetSubscriberfails closed withTfaAccessDenied(HTTP 403) and invalidates the session, logging the administrator out.
Security Implications
- Upstream Protection: The security of account switching depends entirely on the administrator's original session having completed TFA. If an administrator is authenticated via TFA, their administrative actions (including switching to another user) are considered authorized.
- Custom Impersonation Tools: Any custom module that attempts user switching by directly invoking
AccountProxy::setAccount()rather than using theaccount_switcherservice will be blocked byTfaUserSetSubscriber(failing closed with HTTP 403). Always use\Drupal::service('account_switcher')for programmatic user switching.
Password Resets, One-Time Logins, and Drush (drush uli)
TFA intercepts one-time login links (password reset URLs) via TfaOneTimeLoginEventSubscriber listening to the user.reset.login route at priority 31.
Integration Considerations
- Core Password Reset Flow: When a user visits a valid reset link, TFA redirects the user to the TFA entry form to validate their second factor before allowing password modification.
drush uli: Standarddrush ulilinks route through the standarduser.reset.logincontroller and are protected by TFA if the user has TFA enabled.- Custom One-Time Login / Magic Link Controllers: Any custom module generating magic login URLs that bypasses the core
user.reset.loginroute and directly callsuser_login_finalize()will be intercepted and rejected byTfaUserSetSubscriberunless properly integrated with TFA's validation lifecycle.
Service Decoration & Compiler Pass Collisions
Because TFA heavily utilizes Symfony service decoration and container compiler passes, be aware of:
- Service Definition Swapping: If a custom module replaces a core service definition (e.g.,
$container->setDefinition('user.auth', ...)in a customServiceProvider) instead of decorating it, TFA's decorator is silently removed. Always use service decoration (decorates: service_name) rather than replacing service definitions. - Compiler Pass Ordering:
TfaAuthDecoratorCompilerruns during the container compilation phase. If another module modifiesauthentication_providerdefinitions after TFA's compiler pass executes, decorations may be overwritten or dropped. - Multi-Interface Authentication Providers:
TfaAuthDecoratorCompileronly automatically decorates providers implementingAuthenticationProviderInterfaceorAuthenticationProviderInterface+AuthenticationProviderChallengeInterface. If a custom authentication provider also implements additional interfaces (such asAuthenticationProviderFilterInterface), TFA will skip automatic decoration and require custom decorator handling. - Prohibited Bypass List Naming Caveat: In
TfaAuthDecoratorCompiler, the prohibited bypass list specifies$bypass_prohibited = ['cookie', 'http_basic']. Core's HTTP Basic Auth provider uses the service tagprovider_id: basic_auth. Even ifbasic_authis placed intfa.auth_provider_bypass, Layer 2 (TfaUserAuth) still enforces TFA token verification becauseBasicAuth::authenticate()delegates directly touser.auth. However, site administrators must never declarebasic_authintfa.auth_provider_bypass.