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.
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
⚠️ 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 withFOR UPDATE NOWAIT– and handling the Postgres error55P03(If you’re using Rails –ActiveRecord::LockWaitTimeouterror 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
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
⚠️ 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
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
⚠️ Important:
- HTTP 422:
invalid_otpis 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
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
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.
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
Reset password
Use the token returned by the endpoint above to set up a new password.
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
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)