doc/development/email_one_time_passwords.md
Email One-Time Passwords (Email OTP) is a two-factor authentication (2FA) method for GitLab.com users who sign in with passwords. Users receive a one-time code by email during login and must enter it to complete authentication.
For information on this feature that are not development-specific, see the feature documentation.
From January 2026 GitLab.com is rolling out Email OTP as a mandatory minimum. Developers of this feature should be mindful of feature flags, GitLab instance settings, and the need for future dated enrollment.
You can triage and debug issues raised by Email OTP with the GitLab production logs.
Query for Email OTP verification events:
json.message: "Email Verification" AND json.username:replace_username_here
Add the json.event column to see event types. These logs appear when:
email_otp_required_after is 7 days or less)VerifiesWithEmail behavior)Example log showing successful sign-in flow, searching by IP address:
Event reasons are defined in
VerifiesWithEmail constants.
As with other 2FA methods, users enrolled in Email OTP cannot authenticate API requests with passwords. Look for successful password validation followed by 401 responses.
Search for find_with_user_password succeeded, then look at
time-adjacent records or records with the same IP to identify the
request record and its response.
json.message: "find_with_user_password" AND json.username:replace_username_here
Example showing Git operations with Email OTP enrolled:
Note the find_with_user_password succeeded message appears even though
authentication ultimately fails with 401.
View user preference modifications:
json.meta.caller_id: "UserSettings::ProfilesController#update" AND json.params.value: "email_otp_required_as_boolean"
Expand the record to view the preferences options being submitted. A
parameter value of 1 indicates the user is enrolling in Email OTP, a
0 indicates they are unenrolling.
All user activity:
json.username: "USERNAME" OR json.meta.user: "USERNAME"
Session events:
json.controller: "SessionsController" AND json.action: (new OR create OR resend_verification_code OR successful_verification)
Password-authenticatable operations:
json.controller: (Repositories::GitHttpController OR JwtController) AND json.path: "/PROJECT_PATH"
Email OTP is part of the Email Verification logic. This includes verification of a provided code when a user signs in from a new IP address, or signs in after they have been locked.
It is distinct from the Identity Verification feature, and from Devise's Confirmable feature, both of which occur during the user registration flow.
Controllers:
SessionsController - Authentication entry pointVerifiesWithEmail - Sends and verifies codes during sign inModels:
User - Delegates Email OTP attributes to UserDetailUserDetail - Stores Email OTP stateUsers::EmailOtpEnrollment - Enrollment logic and state managementHelpers:
VerifiesWithEmailHelper - Backend and frontend helper methodsSessionsHelper - Session-related helpersuser_details table stores:
email_otp - Hashed OTP code (nil after use)email_otp_required_after - Enforcement timestamp, controlling
enrollment stateemail_otp_last_sent_at - Last code delivery timeemail_otp_last_sent_to - Address the code was sent toThe email_otp_required_after value is automatically managed by
Users::EmailOtpEnrollment#set_email_otp_required_after_based_on_restrictions.
Enrollment states include nil (not enrolled), a future date (upcoming or
current warning period), or a past date (enforcement active).
Updating a User through
Users::UpdateService
enforces state management, potentially overriding the value, using
set_email_otp_required_after_based_on_restrictions. It was done for the rollout
purposes and may be removed in the future.
The same set_email_otp_required_after_based_on_restrictions method call
also occurs in User#email_based_otp_required? as this method is the SSoT for
checking whether email OTP is required for a user and
is being used for all flows where email OTP requirement is applicable.
This behavior is expected and generates logs with set_email_otp_required_after_based_on_restrictions
method name in event.message. Code comments explain the state transitions.
Email OTP does not satisfy group or instance 2FA requirements. Only App-based TOTP and WebAuthn fulfill these policies. However, if a user has no other 2FA methods configured, Email OTP is required until they add a App-based TOTP or WebAuthn method. This requirement is intentional to provide security.
Rate limiting exists to prevent brute-force attacks on the email verification flow. The module implements two rate limits:
user_sign_in): Applied when a user
enters a correct password but requires email verification. This
prevents attackers from guessing passwords by observing when the
email verification page is displayed.email_verification_code_send): Applied when users request a resend
of their verification code, preventing abuse of the email sending
mechanism.email_verification): Applied when users submit a verification
token, preventing brute-force attempts to guess the token.Verification codes expire after a fixed time period. If the user doesn't verify before the code expires, they can request a new code.
Verification codes are sent to the primary email address. If the user has a confirmed secondary address, they can send a new code there as well. This also sends a security notification to the primary email address.
Two-factor authentication is offered in this order:
Users with confirmed secondary email addresses can resend a new code if they cannot access their primary email address.
Feature flags:
email_based_mfa - Global toggle for Email OTP enforcementenrol_new_users_in_email_otp - Controls automatic enrollment for new usersApplication setting:
require_minimum_email_based_otp_for_users_with_passwords - Makes Email OTP mandatory for users without other 2FAConfigure GDK to match GitLab.com:
# Admin > Settings > General > New user account restrictions
ApplicationSetting.current.update!(
require_admin_approval_after_user_signup: false,
email_confirmation_setting: 'hard' )
# Admin > Settings > General > New user account restrictions
ApplicationSetting.current.update!(
anti_abuse_settings: {
require_email_verification_on_account_locked: true
}
)
Test enrollment states using commands like those below:
user = User.find_by(username: 'test_user')
# Enable Email OTP
Feature.enable(:email_based_mfa, user)
# Disable Email OTP
Feature.disable(:email_based_mfa, user)
# Require Email OTP as a minimum
ApplicationSetting.current.update!(sign_in_restrictions: {require_minimum_email_based_otp_for_users_with_passwords: true })
# Or allow users to disable it
ApplicationSetting.current.update!(sign_in_restrictions: {require_minimum_email_based_otp_for_users_with_passwords: false })
# Enrol new users when they sign up
Feature.enable(:enrol_new_users_in_email_otp)
# Or make it opt-in
Feature.disable(:enrol_new_users_in_email_otp)
# Set enrollment date via UpdateService (triggers automatic enrollment logic)
Users::UpdateService.new( user, { user: user, email_otp_required_after: date } ).execute!
# Or set directly, bypassing set_email_otp_required_after_based_on_restrictions
user.update(email_otp_required_after: date) # nil to unenroll
View emails at https://gdk.test:3443/rails/letter_opener.
Warning period phases are defined in code - see
VerifiesWithEmail
and
VerifiesWithEmailHelper
for threshold values.
For end-to-end production and staging tests to function properly, GitLab
allows QA users to bypass Email OTP when the User-Agent for the
request matches the configured GITLAB_QA_USER_AGENT.
Test files:
spec/requests/verifies_with_email_spec.rb - Integration tests for Email OTP sign-in flowspec/models/concerns/users/email_otp_enrollment_spec.rb - Unit tests for enrollment state managementspec/helpers/verifies_with_email_helper_spec.rb - Tests for Email OTP helper methodsUser documentation:
Technical documentation:
Internal support:
#mfa_default_planning