DEV Community

Cover image for OTP email verification and password reset
Anton Prudkohliad
Anton Prudkohliad

Posted on • Originally published at prudkohliad.com

OTP email verification and password reset

I recently tried to implement a secure OTP-based email verification and password reset flow - only to realize how little concrete, end-to-end guidance is out there. Most write-ups simply skip the gritty parts because everyone seems to rely on external auth providers. I wanted something I could fully control, so I built a self-hosted solution from scratch while thinking carefully about OTP generation, hashing, crypto, race conditions and a smooth user experience.

⚠️ Disclaimer: These are just my personal notes and reflections, not exhaustive security guidance. Evaluate and adapt them to your own system requirements.

General considerations

  • Prevent email enumeration by making sure all requests take constant time. This can be implemented as a middleware that “sleeps” if a request takes less than a certain amount of time - e.g. 500ms.

  • Whenever you need to sign a JWT, use Public-key cryptography - e.g. ES256. This way you can avoid storing the sensitive key in your database. After the JWT has been signed, the private key can be discarded because for further verification you only need the public key.

  • Why ES256 over RS256? – ES256 is faster.

  • Only generate the OTPs right before sending it via email, so that its plaintext version (sensitive data) is not stored in your database. If you have to store it - probably better to encrypt it first using something like AES-256-GCM with key rotation.

Register

This endpoint will create a user record if it does not exist and schedule a background job that will generate and send an OTP to the email address provided by the user.

Register sequence diagram

mermaid code
sequenceDiagram
  participant user as User
  participant api as API
  participant db as DB
  participant job as Background Worker

  user->>api: Register
  api->>db: Open transaction
  db->>api:
  api->>db: Find user by email FOR UPDATE NOWAIT
  db->>api:
  alt user does not exist
    api->>db: Create user
    db->>api:
    alt failed to insert because of the unique email constraint
        api->>user: HTTP 204
    end
  end
  api->>api: Check if email already verified
  alt email already verified
      api->>user: HTTP 204
  end
  api->>api: Check OTP cooldown
  alt cooldown not complete
      api->>user: HTTP 204
  end
  api->>db: Reset hashed OTP, cooldown and attempts
  db->>api:
  api->>db: Commit transaction
  db->>api:
  api->>job: Schedule Background Job
  job->>api:
  api->>user: HTTP 204
  job->>job: Generate OTP
  job->>db: Store hashed OTP
  db->>job:
  job-->>user: Send plaintext OTP via email
Enter fullscreen mode Exit fullscreen mode

⚠️ Important:

  • HTTP 204 No Content is returned always in order to prevent email enumeration
  • During each request the current OTP hash will be overwritten by a new one. So a legitimate user might be locked of verification if an attacker is hammering the register endpoint with the user’s email address. This is mitigated by introducing a “cooldown” a.k.a. rate limit.
  • Mitigate race condition (two or more requests trying to create the user at the same time) in the database by SELECT -ing the user record with FOR UPDATE NOWAIT – and handling the Postgres error 55P03 (If you’re using Rails –ActiveRecord::LockWaitTimeout error will be raised) by returning HTTP 204 – before resetting the hashed OTP, cooldown and attempts, so that parallel requests do not trigger more than one background job

Resend verification email

Resend verification email sequence diagram

mermaid code
sequenceDiagram
  participant user as User
  participant api as API
  participant db as DB
  participant job as Background Worker

  user->>api: Request verification
  api->>db: Open transaction
  db->>api:
  api->>db: Find user by email FOR UPDATE NOWAIT
  db->>api:
  alt user does not exist
      api->>user: HTTP 204
  end
  api->>api: Check if email already verified
  alt email already verified:
      api->>user: HTTP 204
  end
  api->>api: Check OTP cooldown
  alt cooldown not complete
      api->>user: HTTP 204
  end
  api->>db: Reset hashed OTP, cooldown, attempts and time window
  db->>api:
  api->>db: Commit transaction
  db->>api:
  api->>job: Schedule Background Job
  job->>api:
  api->>user: HTTP 204
  job->>job: Generate OTP
  job->>db: Store hashed OTP
  db->>job:
  job-->>user: Send plaintext OTP via email
Enter fullscreen mode Exit fullscreen mode

⚠️ Important:

  • Everything from “Register” applies here as well, except that we don’t create a user record if it does not exist.

Verify email address with OTP

Verify email address with OTP sequence diagram

mermaid code
sequenceDiagram
    participant user as User
    participant api as API 
    participant db as DB

    user->>api: Send OTP and the email address
    api->>db: Find user by email
    db->>api:
    alt user not found
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Check number of attempts
    alt too many attempts
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Check if verification has been requested (email_verification_expires_at is not null)
    alt verification was not requested
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Check if verification time window has ended
    alt time window has expired
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Compare stored OTP hash with received one
    alt hashes do not match
            api->>db: Increment attempts counter
            db->>api:
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Check if email is already verified
    alt email verified
      api->>user: HTTP 422: already_verified
    end
    api->>db: Open transaction
    db->>api:
    api->>db: Clean OTP state (hash, attempts, expires_at)
    db->>api:
    api->>db: Mark User's email as verified
    db->>api:
    api->>db: Commit transaction
    db->>api:
    api->>api: Generate session tokens
    api->>db: Store tokens
    db->>api:
    api->>user: Return session tokens
Enter fullscreen mode Exit fullscreen mode

⚠️ Important:

  • HTTP 422: invalid_otp is returned always in order to prevent email enumeration.
  • If the OTP is valid, but the email is already verified – the endpoint returns HTTP 422: already_verified. This does not lead to email enumeration, because at this point we have already authenticated the user by verifying their OTP.
  • It is safe (and user-friendly) to login the user in the end.

Request password reset

Request password reset sequence diagram

mermaid code
sequenceDiagram
  participant user as User
  participant api as API
  participant db as DB
  participant job as Background Worker

  user->>api: Request password reset
  api->>db: Open transaction
  db->>api:
  api->>db: Find user by email FOR UPDATE NOWAIT
  db->>api:
  alt user does not exist
      api->>user: HTTP 204
  end
  api->>api: Check password reset cooldown
  alt cooldown not complete
      api->>user: HTTP 204
  end
  api->>db: Reset hashed OTP, cooldown, attempts and time window
  db->>api:
  api->>db: Commit transaction
  db->>api:
  api->>job: Schedule Background Job
  job->>api:
  api->>user: HTTP 204
  job->>job: Generate OTP
  job->>db: Store hashed OTP
  db->>job:
  job-->>user: Send plaintext OTP via email
Enter fullscreen mode Exit fullscreen mode

Verify password reset OTP

In order to improve the user experience, the password verification flow is split into two steps:

  • Verify password reset OTP (this endpoint)
  • Reset password (read further)

The former is used to exchange the OTP for a JWT, that is sent to the latter, along with the new password.

Verify password reset OTP sequence diagram

mermaid code
sequenceDiagram
    participant user as User
    participant api as API 
    participant db as DB

    user->>api: Send OTP and the email address
    api->>db: Find user by email
    db->>api:
    alt user not found
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Check number of attempts
    alt too many attempts
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Check if reset was requested (password_reset_expires_at is not null)
    alt reset was not requested
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Check if reset time window has ended
    alt time window has expired
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Compare stored OTP hash with received one
    alt hashes do not match
            api->>db: Increment attempts counter
            db->>api:
        api->>user: HTTP 422: invalid_otp
    end
    api->>api: Generate key pair for reset token
    api->>db: Clean OTP state (hash, attempts, expires_at), store reset token public key
    db->>api:
    api->>api: Build reset token JWT - sign with private key
    api->>user: Return reset token
Enter fullscreen mode Exit fullscreen mode

Reset password

Use the token returned by the endpoint above to set up a new password.

Reset password sequence diagram

mermaid code
sequenceDiagram
  participant user as User
  participant api as API
  participant db as DB

  user->>api: Send password reset token, new password
  api->>db: Find user (and token public key) by id from token
  db->>api:
  alt user not found
      api->>user: HTTP 422: invalid_token
  end
  api->>api: Check if token is valid (signature, expiration)
  alt token not valid
      api->>user: HTTP 422: invalid_token
  end
  api->>api: Hash new password
  api->>db: Store new password hash, clean reset token public key
  db->>api:
  api->>api: Generate session tokens
  api->>db: Store tokens
  db->>api:
  api->>user: Return session tokens
Enter fullscreen mode Exit fullscreen mode

User properties

Only relevant ones are shown

Field Name Type Description
email string Email address
email_verified_at timestamp Time when the email address was verified
email_verification_otp_digest string Hashed version of OTP
email_verification_expires_at timestamp Until when an OTP can be used
email_verification_otp_attempts integer How many unsuccessful attempts to submit OTP there were
email_verification_cooldown_resets_at timestamp When a new OTP can be requested
email_verification_last_requested_at timestamp When the last OTP was requested (nice to have for audit log)
password_reset_token_public_key byte array Public key for the password-reset token
password_reset_otp_digest string Hashed version of OTP
password_reset_expires_at timestamp Until when an OTP can be used
password_reset_otp_attempts integer How many unsuccessful attempts to submit OTP there were
password_reset_cooldown_resets_at timestamp When a new OTP can be requested
password_reset_last_requested_at timestamp When the last OTP was requested (nice to have for audit log)

Top comments (0)