prudkohliad

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.

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
    

⚠️ 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

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

⚠️ 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

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
  • 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

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

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.

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

Reset password

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

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
  

User properties

Only relevant ones are shown

Field NameTypeDescription
emailstringEmail address
email_verified_attimestampTime when the email address was verified
email_verification_otp_digeststringHashed version of OTP
email_verification_expires_attimestampUntil when an OTP can be used
email_verification_otp_attemptsintegerHow many unsuccessful attempts to submit OTP there were
email_verification_cooldown_resets_attimestampWhen a new OTP can be requested
email_verification_last_requested_attimestampWhen the last OTP was requested (nice to have for audit log)
password_reset_token_public_keybyte arrayPublic key for the password-reset token
password_reset_otp_digeststringHashed version of OTP
password_reset_expires_attimestampUntil when an OTP can be used
password_reset_otp_attemptsintegerHow many unsuccessful attempts to submit OTP there were
password_reset_cooldown_resets_attimestampWhen a new OTP can be requested
password_reset_last_requested_attimestampWhen the last OTP was requested (nice to have for audit log)

Comments

🚧 Please enable JavaScript to use the comments feature

More posts