Email One-Time Passwords (Email OTP) development

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.

Logging

You can triage and debug issues raised by Email OTP with the GitLab production logs.

Email OTP events during sign-in

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:

  • Account requires Email OTP.
  • Account is in grace period (email_otp_required_after is 7 days or less)
  • Account is locked (pre-existing VerifiesWithEmail behavior)

Example log showing successful sign-in flow, searching by IP address:

Email OTP sign-in logs showing a user signing in, including a failed code entry and code resend

Event reasons are defined in VerifiesWithEmail constants.

Password API authentication failures

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:

Git operation logs showing the GitLab::Auth log followed by the request log with 401 status

Note the find_with_user_password succeeded message appears even though authentication ultimately fails with 401.

Enrollment changes

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.

Additional searches

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"

Architecture

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.

Code

Controllers:

Models:

Helpers:

Database schema

user_details table stores:

  • email_otp - Hashed OTP code (nil after use)
  • email_otp_required_after - Enforcement timestamp, controlling enrollment state
  • email_otp_last_sent_at - Last code delivery time
  • email_otp_last_sent_to - Address the code was sent to

State management

The 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 grace 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. The same check also occurs during sign in. This behavior is expected and generates logs with the method name in event.message. Code comments explain the state transitions.

Security

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:

  1. Sign-in rate limiting (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.
  2. Email verification code resend rate limiting (email_verification_code_send): Applied when users request a resend of their verification code, preventing abuse of the email sending mechanism.
  3. Email verification token validation rate limiting (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.

Authentication precedence

Two-factor authentication is offered in this order:

  1. WebAuthn (if configured)
  2. App-based TOTP (if configured)
  3. Email OTP (if permitted and required)

Users with confirmed secondary email addresses can resend a new code if they cannot access their primary email address.

Configuration

Feature flags:

Application setting:

Testing

Configure GDK to match GitLab.com:

# Admin > Settings > General > Sign-up restrictions
ApplicationSetting.current.update!(
  require_admin_approval_after_user_signup: false,
  email_confirmation_setting: 'hard' )

# Admin > Settings > General > Sign-in 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.

Grace period phases are defined in code - see VerifiesWithEmail and VerifiesWithEmailHelper for threshold values.

QA integration

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:

Additional resources

User documentation:

Technical documentation:

Internal support: