This guide shows you how to stop duplicate password reset emails and SMS messages from being sent when a user clicks the reset button multiple times, retries after a timeout, or when your system retries failed sends. You’ll implement idempotency keys, deduplication logic, and audit logging so that each reset request triggers exactly one message, even under retries or network glitches.
What You’ll Need
- Access to your backend server code (Node.js, Python, Java, etc.)
- A database or cache (Redis, PostgreSQL, MySQL, etc.) to store request tokens and audit logs
- Your email and SMS provider credentials (e.g., SendGrid, Twilio, AWS SES) with API access
- Basic understanding of HTTP requests, database transactions, and logging
Step-by-Step
- Generate a unique reset request ID (idempotency key) for each password reset initiation. When a user submits the reset form, create a UUID (or similar) and store it in the database along with the user’s identifier (email or phone), the requested channel (email/SMS), and a timestamp. This key will be the single source of truth for that specific reset attempt.
- Before sending any message, check if a reset request with the same key already exists and has been processed. In your send function, query the database for the key. If a record exists with status
sentorcompleted, do not send again. Instead, return the existing response (e.g., “reset link already sent”) to the client. - Use a database unique constraint on the idempotency key. Add a unique index on the
request_idcolumn. When a duplicate request arrives, the database will reject the insert, and your code can catch the duplicate-key error and treat it as a no-op. This prevents race conditions where two simultaneous requests both pass the “check” step. - Implement a short-lived cache for fast deduplication (optional but recommended). Store the request ID in Redis with a TTL equal to your reset token expiry (e.g., 15 minutes). Before hitting the database, check the cache first. This reduces load and speeds up the duplicate check.
- Set a cooldown period for repeated reset requests from the same user. Even with idempotency keys, a user might click “resend” after a few seconds. Add a cooldown (e.g., 60 seconds) where you block new reset requests for that email/phone, returning a message like “Please wait before requesting another reset.” This is separate from idempotency and prevents spam.
- Handle retries from your email/SMS provider gracefully. If your provider fails to send (timeout, 5xx error), do not immediately mark the request as failed. Instead, retry with exponential backoff (e.g., 1s, 2s, 4s) up to 3 times. But crucially, use the same request ID for all retries so that if a retry succeeds, you don’t accidentally send a second message later.
- Log every attempt and outcome in an audit table. Create a table
password_reset_auditwith columns:id,request_id,user_identifier,channel,status(attempted, sent, failed, duplicate),provider_response,timestamp. Write a log entry before and after each send attempt. This gives you a full trail for troubleshooting and compliance. - Make your send function idempotent at the provider level. Some providers allow you to pass a custom
idempotency_keyin the API call. If supported, pass the same request ID. This ensures that even if your code accidentally calls the provider twice with the same key, the provider will only send once. - Test with concurrent requests. Write a test that fires 10 simultaneous reset requests for the same email. Verify that only one email/SMS is sent. Use your audit log to confirm that 9 were marked as duplicates.
- Monitor and alert on anomalies. Set up alerts for when the number of duplicate requests spikes (e.g., more than 5 per minute per user). This could indicate a bug or an attack. Also alert on send failures that persist after retries.
Common Mistakes
- Checking existence without a unique constraint: Relying only on a SELECT before INSERT leaves a race condition. Always add a unique index on the request ID.
- Using the user’s email as the idempotency key: That would block legitimate resets after a password change. Always use a unique generated ID, not the user identifier.
- Ignoring provider-side idempotency: If your provider doesn’t support it, you must handle retries carefully. A retry after a timeout might have actually sent the message, so you need to check with the provider (e.g., via a message lookup) before resending.
- Not logging enough detail: Without provider response codes, you can’t tell why a send failed. Always log the full provider response.
- Setting cooldown too long: A 5-minute cooldown might frustrate users. 60 seconds is usually enough to prevent accidental double-clicks while not blocking legitimate retries.
Tips
- Use a short TTL for the idempotency record. After the reset token expires (e.g., 15 minutes), delete the record to save space. But keep the audit log for longer (e.g., 30 days) for compliance.
- Return the same response for duplicates. If a user clicks “resend” and a request is already in flight, return “We’ve already sent a reset link. Please check your inbox.” This avoids confusion.
- Consider using a distributed lock (e.g., Redis SETNX) for extra safety. This is overkill for most apps, but if you have multiple server instances, it prevents two instances from both sending.
- Use a message queue (e.g., SQS, RabbitMQ) for sending. This decouples the request from the send, making it easier to retry without blocking the user.
- Always hash the user identifier in logs. For privacy, store a hash of the email/phone in your audit log, not the raw value.
Troubleshooting
- Duplicate email still sent: Check your audit log for two entries with the same request ID. If both show “sent”, your unique constraint is missing or your send function is not checking it. Verify the constraint exists and that your code catches the duplicate-key error.
- No message sent at all: Check if the request ID was created. If not, your initial insert failed. Look for errors in the database transaction. Also check if a cooldown is blocking the request—test with a fresh user.
- Retries cause multiple sends: If your provider doesn’t support idempotency keys, you must query the provider’s message status before retrying. For example, Twilio has a “message status” endpoint. Implement a check: if the message status is “sent”, do not retry.
- Race condition still occurs: Even with a unique constraint, if you are using an ORM that doesn’t handle duplicate-key errors gracefully, you might get an unhandled exception. Catch the specific database error (e.g., IntegrityError in SQLAlchemy) and treat it as a duplicate.
- Audit log is too large: If you log every attempt, it can grow fast. Add a cleanup job that deletes logs older than 30 days, or move them to cold storage.
Frequently Asked Questions
What is idempotency in this context?
Idempotency means that calling the same reset request multiple times produces the same result—only one message is sent. It’s achieved by using a unique request ID that the system checks before sending.
Do I need a database or can I use a simple in-memory cache?
For a single-server app, an in-memory cache might work, but it’s lost on restart. A database or Redis is safer because it survives restarts and works across multiple server instances.
How long should I keep the idempotency record?
Keep it at least as long as the reset token is valid (e.g., 15-30 minutes). After that, the record is no longer needed for deduplication, but you may keep it for audit purposes.
What if the user requests a reset, then changes their email, then requests again?
That’s a new request with a new user identifier. The old request ID is not relevant. Make sure your cooldown is based on the current email/phone, not a global per-user ID.
Can I use the same idempotency key for both email and SMS?
No, each channel should have its own key. If a user requests both email and SMS reset, you need two separate request IDs, one per channel.
How do I test this without sending real messages?
Use a test provider (like Mailtrap for email, or Twilio’s sandbox for SMS) or mock the provider API in your tests. Then verify that your code only calls the provider once per request ID.
Related
General steps for current mainstream versions; menus can differ by device and version. See the disclaimer or report an error.
More in this bed
- How to Temporarily Disable Hey Siri on iOS
- Type to Siri on iOS 11: Enable It in 2 Minutes
- Install Microsoft Teams on Ubuntu Linux
- VPN Not Connecting on Windows 10? 9 Fixes That Work
- Destiny 2 PC Settings for Stable FPS & Clear Visibility
- Apple vs PC: Key Differences Explained