Skip to content

Pre-Production Verification Checklist

This document is an operational verification checklist and audit guide to run against a Drupal site after the TFA module (2.x) has been installed and configured, but before deploying to production.

Its primary purpose is to verify that two-factor authentication cannot be silently bypassed by custom code, contrib module conflicts, or infrastructure misconfigurations, and that any deliberate bypass is properly documented with compensating controls.

For full architectural background on TFA's 3-layer security model, see Enforcement Architecture.

1. Environment & Infrastructure Readiness

1.1 Cache Backend Verification (cache.tfa_memcache)

TFA relies on a dedicated cache.tfa_memcache bin for request-scoped security flags (tfa_complete, tfa_auth_method_bypass_approved, tfa_account_switcher_bypass).

Critical Security Requirement

This bin must use non-persistent memory storage (Drupal\Core\Cache\MemoryBackend). If shared persistent caches (Redis, Memcached, database cache) are inadvertently applied to this bin, authentication approval flags will leak across requests and user sessions. See Install & Infrastructure Hardening.

Run the following Drush verification command against the runtime environment:

drush php:eval "var_dump(get_class(\Drupal::service('cache.tfa_memcache')));"
* Pass criteria: Output confirms string(...) "Drupal\Core\Cache\MemoryBackend". * Remediation if failed: Ensure $settings['cache']['bins']['tfa_memcache'] = 'cache.backend.memory'; is defined in settings.php after all global cache backend declarations.

1.2 Encryption Profile & Key Decryption Pre-Flight

TFA validation plugins (TOTP, HOTP, Recovery Codes) encrypt sensitive seeds and tokens using Drupal's Key and Encrypt modules. Uncaught encryption service exceptions (e.g., missing key files, invalid key configuration) produce fatal HTTP 500 errors during user authentication.

Execute a decryption pre-flight check for the active encryption profile:

drush php:eval "
  \$config = \Drupal::config('tfa.settings');
  \$profile_id = \$config->get('encryption_profile') ?: \$config->get('default_validation_plugin');
  echo 'Configured encryption profile: ' . \$profile_id . PHP_EOL;
  if (\$profile = \Drupal\encrypt\Entity\EncryptionProfile::load(\$profile_id)) {
    \$service = \Drupal::service('encryption');
    \$encrypted = \$service->encrypt('test-payload', \$profile);
    \$decrypted = \$service->decrypt(\$encrypted, \$profile);
    echo (\$decrypted === 'test-payload') ? 'SUCCESS: Encryption profile operational.' : 'FAIL: Decryption mismatch.';
  } else {
    echo 'FAIL: Encryption profile could not be loaded.';
  }
  echo PHP_EOL;
"
* Pass criteria: Output confirms SUCCESS: Encryption profile operational. * Remediation if failed: Check Key entity configuration at /admin/config/system/keys and Encryption Profile at /admin/config/system/encryption/profiles.

1.3 Version Pinning in composer.json

TFA 2.x public APIs and service decorators are under active evolution. * Check: Verify that composer.json pins an exact version or commit hash (e.g., "drupal/tfa": "2.0.0-alpha5" or specific commit) rather than an open wildcard. * Procedure: Review the root composer.json and ensure test suites are re-run on any version bump.

2. Service Decoration & Compiler Integrity

Verify that TFA's Symfony service decorators and container compiler passes remain active in the compiled container.

2.1 user.auth Decoration Chain

TFA decorates user.auth (TfaUserAuth, decoration_priority: 100) to enforce token validation during credential checks.

drush php:eval "var_dump(get_class(\Drupal::service('user.auth')));"
* Pass criteria: Outermost service is Drupal\tfa\TfaUserAuth (or a known, tested decorator wrapping it). * Investigation: If another class is returned, search custom and contrib modules for user.auth overrides:
grep -rn "user.auth" web/modules/custom web/modules/contrib --include="*.services.yml"
Refer to Module Compatibility: Service Decoration Collisions.

2.2 account_switcher Decoration Chain

TFA decorates account_switcher (TfaAccountSwitcher, decoration_priority: 250) to track legitimate administrative switches.

drush php:eval "var_dump(get_class(\Drupal::service('account_switcher')));"
* Pass criteria: Class is Drupal\tfa\TfaAccountSwitcher.

2.3 authentication_provider Tag Registrations

Enumerate all registered authentication providers on the site:

drush php:eval "print_r(array_keys(\Drupal::getContainer()->findTaggedServiceIds('authentication_provider')));"
* Review every provider returned: - cookie and http_basic: Protected by core TFA handlers; must not be exempted. - Third-party API / OAuth / JWT providers: Must either delegate to user.auth, be explicitly listed in tfa.auth_provider_bypass, or be documented as an accepted exception. - Review Exempting Authentication Providers and SSO / externalauth Integrations.

3. Code-Path & Session Integrity Audits

TfaUserSetSubscriber intercepts AccountEvents::SET_USER with priority 50 to fail closed against unvalidated account assignments. Audit custom and contrib code for non-standard session or user manipulation.

3.1 Direct Session UID Injection

Search for code writing raw UIDs into sessions, bypassing user_login_finalize():

grep -rn "session->set('uid'" web/modules/custom --include="*.php"
grep -rn "\$_SESSION\['uid'\]" web/modules/custom --include="*.php"
* Verification: Any custom endpoint setting session state must invoke user_login_finalize() and go through the TFA challenge flow.

3.2 Direct AccountProxy::setAccount() Invocations

Search for direct setAccount() calls in custom modules:

grep -rn "setAccount(" web/modules/custom --include="*.php"
* Verification: Direct calls must not be used to switch user contexts in background jobs. Code should use \Drupal::service('account_switcher')->switchTo($account) instead. See AccountEvents::SET_USER Protection.

Search for user_login_finalize() outside standard login flows:

grep -rn "user_login_finalize(" web/modules/custom --include="*.php"
* Verification: TFA protects core user.reset.login routes. Any custom magic-link, token login, or impersonation controller calling user_login_finalize() will be blocked by TfaUserSetSubscriber unless integrated with TFA. See Module Compatibility: Password Resets & Drush.

3.4 Direct Manipulation of Internal TFA Flags

Scan the codebase outside the tfa module for unauthorized reading/writing of internal memory cache or session flags:

grep -rnE "(tfa_complete|bypass_tfa_auth_for_user|tfa_auth_method_bypass_approved|tfa_account_switcher_bypass)" \
  --exclude-dir="tfa" \
  web/modules/ web/sites/ web/themes/
* Pass criteria: Zero occurrences outside the core tfa module folder. Any external writing to these flags represents a potential authentication bypass.

3.5 Account Switcher Caller Audit

Search custom and contrib modules for direct calls to account_switcher:

grep -rnE "(account_switcher->switchTo|service\(['\"]account_switcher['\"]\))" web/modules/custom
* Pass criteria: All invocations must be inside trusted background workers (e.g., cron, queue workers, migrations). No invocation may accept an untrusted UID directly from unvalidated request parameters.

3.6 Discovery of externalauth SSO Consumers

Enumerate all active modules and code consuming externalauth across the codebase:

# 1. List enabled contrib modules dependent on externalauth
drush pm:list --status=enabled --filter="externalauth"

# 2. Search codebase for direct service injections or invocations of userLoginFinalize
grep -rnE "(externalauth\.externalauth|\$this->externalAuth|ExternalAuthInterface|userLoginFinalize)" \
  web/modules/custom web/modules/contrib
* Review: For each provider, verify whether MFA is handled at the IdP level (exempt via role separation or tfa.auth_provider_bypass) or if Drupal must challenge the second factor. See Module Compatibility: SSO.

4. Form Lifecycle & Module Compatibility

4.1 Login Form Alter Handlers

Audit modules altering user_login_form or user_login_block:

drush php:eval "print_r(\Drupal::moduleHandler()->getImplementations('form_alter'));"
* Review: Ensure no contrib or custom module resets $form['#validate'] or $form['#submit'] after TFA registers tfaLoginFormPreValidation, tfaValidateSubmit, and tfaSetRedirectAfterSubmit. * See Login Form Alterations.

4.2 Login Redirect Handlers (login_destination, Custom Redirects)

  • Risk: If a module attaches a post-login redirect handler that runs after TFA's submit handler and unconditionally overwrites $form_state->setRedirect(), the user may be redirected away from /tfa/{uid}/{hash}. Because the UID was stripped, the user arrives at the destination unauthenticated.
  • See Module Compatibility: Login Redirect Modules.

4.3 Alternate Identifier Logins (mail_login, email_registration)

  • Risk: If an email-to-username module converts credentials inside user.auth rather than before, TfaUserAuth may fail account lookup (loadByProperties(['name' => $username])).
  • See Module Compatibility: Alternate Identifiers.

5. Documented Exception Register

Before going live, any authentication provider, API endpoint, or administrative script that deliberately operates without Drupal TFA enforcement must be reviewed and recorded in the project's exception register:

Provider / Path Mechanism Justification Compensating Control Approved By Review Date
e.g., oauth2_token Listed in tfa.auth_provider_bypass Machine-to-machine REST API Short-lived JWTs, IP allowlist, automated token rotation SecOps Lead YYYY-MM-DD
e.g., samlauth Listed in tfa.auth_provider_bypass Enterprise SSO IdP MFA enforced upstream at Okta/Azure AD IdP CISO / IAM Team YYYY-MM-DD
e.g., drush uli CLI one-time login Emergency break-glass admin recovery Server SSH key required, bastion host logging Lead DevOps YYYY-MM-DD

If no exceptions are approved, verify that tfa.auth_provider_bypass is empty in all services.yml files.

6. End-to-End Runtime Test Protocol

Execute the following 8 tests against the built staging/pre-production environment:

Test 1: Baseline TFA Challenge & Unauthenticated State

  1. Enable TFA and configure a TOTP seed for a test account whose role requires TFA.
  2. Submit valid username and password on /user/login.
  3. Verify: Browser redirects to /tfa/{uid}/{hash}.
  4. Open a second tab to /user or any authenticated page.
  5. Verify: User remains anonymous (session is not established until the second factor is validated).

Test 2: Token Entry & Login Completion

  1. On the /tfa/{uid}/{hash} challenge screen, enter the correct 6-digit TOTP code.
  2. Submit the form.
  3. Verify: User is authenticated, redirected to their account or target destination, and the session functions normally.

Test 3: Invalid / Expired Code Rejection & Flood Control

  1. Submit an invalid TOTP code on /tfa/{uid}/{hash}.
  2. Verify: Access is rejected with an error message; session remains unauthenticated.
  3. Submit repeated invalid attempts.
  4. Verify: Flood control engages and blocks further attempts for the configured window.

Test 4: Direct Session Injection Immunity (Fail-Closed Check)

  1. In a non-production test harness, programmatically write an arbitrary UID into the session storage without calling TFA validation.
  2. Send a request with that session cookie.
  3. Verify: TfaUserSetSubscriber rejects the request with an HTTP 403 Access Denied and destroys the session.

Test 5: Redirect Module Interaction

  1. If using login_destination or custom redirect rules, initiate a login with a ?destination=/admin/reports/status query parameter.
  2. Verify: The initial redirect lands on /tfa/{uid}/{hash} (TFA challenge is not bypassed).
  3. Complete the TFA challenge.
  4. Verify: Post-TFA redirect lands on the expected destination /admin/reports/status.

Test 6: Alternate Login Identifier (Email Login)

  1. If mail_login or similar modules are enabled, attempt logging in using the user's email address and password.
  2. Verify: TFA challenge screen is reached and authentication completes normally.

Test 7: One-Time Login / Password Reset Flow

  1. Generate a password reset link for a TFA-enabled user (drush uli --name=testuser or via /user/password).
  2. Visit the reset link in a clean browser session.
  3. Verify: TFA redirects to the challenge form before allowing the user to set a new password or access authenticated routes.

Test 8: Administrative Account Switching (masquerade)

  1. Log in as an administrator who has completed TFA.
  2. Use masquerade or core's account_switcher to switch to a test user.
  3. Verify: Switch succeeds without requiring the test user's TOTP code.
  4. Switch back to the administrator.
  5. Verify: Administrative session remains intact and valid.

Test 9: Stateless Gateway & Intermediate Session Isolation (JSON:API / REST)

  1. Initiate a login at /user/login with a TFA-required account, stopping at /tfa/{uid}/{hash}.
  2. Copy the session cookie from developer tools and send curl -b "SSESS...=..." https://example.com/jsonapi/user/user.
  3. Verify: Endpoint returns HTTP 401/403 (session is anonymous). The browser session remains valid to complete the OTP challenge.

Test 10: HTTP Basic Auth Passcode Concatenation

  1. Send a request with standard HTTP Basic Auth headers (username:password) for a TFA-required account.
  2. Verify: Request returns HTTP 401/403.
  3. Send the request with the TOTP code appended to the password (username:password<totp_code>).
  4. Verify: Request returns HTTP 200 OK.

Test 11: Federated SSO Skip Depletion & Lockout Check

  1. For an active SSO provider (samlauth, openid_connect, etc.), configure a test account whose role requires TFA, with allowed skips set to 1, but leave TFA unconfigured.
  2. Initiate login via the SSO provider.
  3. Verify: First login succeeds, displaying a warning that 0 skips remain.
  4. Log out and initiate a second SSO login with the same unconfigured account.
  5. Verify: Login fails closed with an HTTP 403 Forbidden (no session established).