Forgotten Password
Build a secure password reset flow with two actions in a single workflow: requesting a reset link and resetting the password. Uses schema validation, rate limiting, hashed tokens, token expiration, password strength enforcement, and generic error messages to prevent email enumeration.
What You'll Build
A single API endpoint that handles two actions via an action field in the request body:
Action: request
User submits their email to receive a password reset link.
- Validates the request schema (email required, action must be
request) - Normalizes email (lowercase, trimmed)
- Enforces per-email rate limiting (3 requests per 15 minutes)
- Looks up the user by email (must exist and be verified)
- Generates a reset token, hashes it with SHA-256 before storing
- Sets
reset_token_expires_atto 1 hour in the future - Sends a reset email via Resend
- Returns a generic success message regardless of whether the email exists (no enumeration)
Action: reset
User submits email, token, and new password to complete the reset.
- Validates the request schema (email, token, newPassword required, action must be
reset) - Normalizes email, hashes token with SHA-256
- Enforces per-email rate limiting (5 attempts per 15 minutes)
- Validates password strength (same rules as registration)
- Looks up user by email + hashed token + not expired
- Hashes the new password with bcrypt
- Updates the password, clears the reset token and expiration
- Resets the rate-limit counter on success
- Logs all attempts for audit
Endpoint: POST /api/v1/YOUR_ID/forgotten-password
Request body (request action):
{
"action": "request",
"email": "user@example.com"
}
Request body (reset action):
{
"action": "reset",
"email": "user@example.com",
"token": "a1b2c3d4e5f...",
"newPassword": "MyN3wP@ss!"
}
Responses:
| Scenario | Status | Body |
|---|---|---|
| Request success (or email not found) | 200 | { "success": true, "message": "If an account exists, a reset link has been sent." } |
| Reset success | 200 | { "success": true, "message": "Password has been reset successfully." } |
| Invalid schema | 400 | { "valid": false, "errors": [...] } |
| Invalid action | 400 | { "success": false, "error": "Invalid action." } |
| Password too weak | 400 | { "success": false, "error": "..." } |
| Token invalid/expired | 400 | { "success": false, "error": "Reset failed. Please request a new link." } |
| Rate limited | 429 | { "success": false, "error": "Too many attempts. Please try again later." } |
All failure responses for the
requestaction use the same generic success message. The attacker cannot determine whether an email exists in the system.
Prerequisites
Table: users
Same table used by registration and verification. Add these columns if they don't exist:
| Column | Type | Description |
|---|---|---|
| text | User's email (unique) | |
| password | text | Bcrypt hash of the password |
| verified | text | "yes" or "no" |
| reset_token | text | SHA-256 hash of the reset token sent in the email |
| reset_token_expires_at | datetime | When the reset token expires (1h after request) |
Table: password_reset_attempts (new)
A separate table to track rate limiting per email:
| Column | Type | Description |
|---|---|---|
| text | The email requesting reset (unique) | |
| attempt_count | number | Number of attempts in the current window |
| last_attempt_at | datetime | Timestamp of the most recent attempt |
Security Design
| Area | Implementation |
|---|---|
| HTTP method | POST only |
| Schema validation | Data Validator (action, email required; token + newPassword required for reset) |
| Email format | Regex pattern in Data Validator |
| Email handling | Normalized (lowercase, trimmed) |
| Token storage | SHA-256 hashed |
| Token expiration | 1 hour (shorter than registration's 24h - reset tokens are higher risk) |
| Password hashing | Bcrypt |
| Password strength | 8+ chars, uppercase, lowercase, number, special char |
| Rate limit (global) | 10/min |
| Rate limit (per-email request) | 3 per 15 min |
| Rate limit (per-email reset) | 5 per 15 min |
| Error messages | Generic (no email enumeration) |
| Timeout | 15s |
| Audit logging | All attempts logged with hashed email + IP |
Step 1 - Flow Start
| Setting | Value |
|---|---|
| Trigger Type | API |
| Method | POST only |
| Rate Limit | 10 requests/minute |
| Timeout | 15 seconds |
| CORS | Your production domain (HTTPS only) |
Step 2 - Data Mapper
Map the incoming request body fields into a clean object for the Data Validator.
Output variable: dataMapper
Template:
{
"action": "{{action}}",
"email": "{{email}}",
"token": "{{token}}",
"newPassword": "{{newPassword}}"
}
The
tokenandnewPasswordfields will be empty strings when the action isrequest. The Code node handles this - the Data Validator only enforces thatactionand
Step 3 - Data Validator
Validate the base schema. Only action and email are required at this stage - the Code node validates token and newPassword conditionally based on the action.
Output variable: inputValidator
Input data: {{dataMapper}}
| Field | Type | Required | Validation |
|---|---|---|---|
action |
string | yes | minLength: 1 |
email |
string | yes | minLength: 5, pattern: ^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$ |
The Data Validator has two output ports:
| Port | Fires when | Next node |
|---|---|---|
| TRUE | Both fields present, correct type, and match patterns | → Step 4 (Code node) |
| FALSE | Missing, wrong type, or invalid format | → Simple Output (400 error) |
Step 3F - Simple Output (Validation Error)
Connected to the Data Validator's FALSE port.
| Setting | Value |
|---|---|
| Status | 400 |
| Type | JSON |
| Output | {{inputValidator}} |
Step 4 - Code Node (Validate & Prepare)
This is the routing node. Based on the action field, it validates the required fields, normalizes email, hashes the token (for reset), enforces password strength (for reset), and calculates rate-limit cutoffs.
Output variable: fp_data
(function() {
var action = (variables.action || '').trim().toLowerCase();
var email = (variables.email || '').trim().toLowerCase();
var token = (variables.token || '').trim();
var newPassword = variables.newPassword || '';
if (action !== 'request' && action !== 'reset') {
return { valid: false, action: 'invalid', error: 'Invalid action.' };
}
var cutoff = new Date(Date.now() - 15 * 60 * 1000).toISOString();
var requestTimestamp = new Date().toISOString();
if (action === 'request') {
return {
valid: true,
action: 'request',
email: email,
rateLimitCutoff: cutoff,
requestTimestamp: requestTimestamp,
rateLimitMax: 3
};
}
// action === 'reset'
if (!token || token.length < 8) {
return { valid: false, action: 'reset', error: 'Token is required.' };
}
if (!newPassword) {
return { valid: false, action: 'reset', error: 'New password is required.' };
}
if (newPassword.length < 8) {
return { valid: false, action: 'reset', error: 'Password must be at least 8 characters' };
}
if (!/[A-Z]/.test(newPassword)) {
return { valid: false, action: 'reset', error: 'Password must contain at least one uppercase letter' };
}
if (!/[a-z]/.test(newPassword)) {
return { valid: false, action: 'reset', error: 'Password must contain at least one lowercase letter' };
}
if (!/[0-9]/.test(newPassword)) {
return { valid: false, action: 'reset', error: 'Password must contain at least one number' };
}
if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(newPassword)) {
return { valid: false, action: 'reset', error: 'Password must contain at least one special character' };
}
var hashedToken = sha256(token);
var hashedPassword = bcryptHash(newPassword);
return {
valid: true,
action: 'reset',
email: email,
hashedToken: hashedToken,
hashedPassword: hashedPassword,
rateLimitCutoff: cutoff,
requestTimestamp: requestTimestamp,
rateLimitMax: 5
};
})();
Why 1-hour token expiration?
Password reset tokens are higher risk than verification tokens. If an attacker gains access to a user's email temporarily, a shorter window limits the damage. One hour is enough for a legitimate user to complete the reset.
Step 5 - Condition (Valid Input?)
Route based on whether the Code node validation passed.
| Branch | Action |
|---|---|
| IF | {{fp_data.valid}} equals true → Continue to Step 6 |
| ELSE | → Simple Output (error) |
ELSE branch - Simple Output (Validation Error)
| Setting | Value |
|---|---|
| Status | 400 |
| Type | JSON |
| Output | { "success": false, "error": "{{fp_data.error}}" } |
Step 6 - Query Data (Check Rate Limit)
Query the password_reset_attempts table to see how many attempts this email has made in the last 15 minutes.
Output variable: rateLimitData
| Setting | Value |
|---|---|
| Source | password_reset_attempts (Structured) |
| Filter 1 | email equals {{fp_data.email}} |
| Filter 2 | last_attempt_at >= {{fp_data.rateLimitCutoff}} |
| Limit | 1 |
Step 7 - Code Node (Check Rate Limit)
Compare the attempt count against the action-specific limit. Compute the new attempt count for the upsert.
Output variable: rateCheck
(function() {
var rows = (variables.rateLimitData && variables.rateLimitData.rows) || [];
var current = (rows.length > 0 && rows[0].attempt_count) ? parseInt(rows[0].attempt_count) : 0;
var max = variables.fp_data.rateLimitMax || 5;
return {
limited: current >= max,
newCount: current + 1
};
})();
Step 8 - Condition (Rate Limited?)
| Branch | Action |
|---|---|
| IF | {{rateCheck.limited}} equals true → Return 429 error |
| ELSE | Continue to Step 9 |
IF branch - Simple Output (Rate Limited)
| Setting | Value |
|---|---|
| Status | 429 |
| Type | JSON |
| Output | { "success": false, "error": "Too many attempts. Please try again later." } |
Step 9 - Write Data (Track Attempt)
Upsert into password_reset_attempts to increment the counter before processing.
| Setting | Value |
|---|---|
| Source | password_reset_attempts (Upsert) |
| Match Column |
| Column | Value |
|---|---|
{{fp_data.email}} |
|
| attempt_count | {{rateCheck.newCount}} |
| last_attempt_at | {{fp_data.requestTimestamp}} |
Step 10 - Condition (Which Action?)
Route to the correct branch based on the action.
| Branch | Action |
|---|---|
| IF | {{fp_data.action}} equals request → Request branch (Step 11) |
| ELSE | → Reset branch (Step 14) |
Request Branch (Steps 11–13)
Step 11 - Query Data (Find Verified User)
Look up the user by email. Only verified users can request a password reset.
Output variable: userData
| Setting | Value |
|---|---|
| Source | users (Structured) |
| Filter 1 | email equals {{fp_data.email}} |
| Filter 2 | verified equals yes |
| Limit | 1 |
Step 12 - Code Node (Generate Reset Token)
Generate a reset token only if the user exists. If the user doesn't exist, we still return success (no enumeration) but skip the email.
Output variable: resetToken
(function() {
var totalCount = variables.userData.totalCount;
if (totalCount === 0) {
return { found: false };
}
var rawToken = uuid().replace(/-/g, '');
var hashedToken = sha256(rawToken);
var expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
return {
found: true,
rawToken: rawToken,
hashedToken: hashedToken,
expiresAt: expiresAt
};
})();
Step 13 - Condition (User Found?)
| Branch | Action |
|---|---|
| IF | {{resetToken.found}} equals true → Write token + send email + return success |
| ELSE | → Return generic success (no enumeration) |
IF branch - Write Data (Store Reset Token)
Update the user's reset token and expiration.
| Setting | Value |
|---|---|
| Source | users (Update) |
| Filter | email equals {{fp_data.email}} |
| Column | Value |
|---|---|
| reset_token | {{resetToken.hashedToken}} |
| reset_token_expires_at | {{resetToken.expiresAt}} |
IF branch - HTTP Request (Send Reset Email)
| Setting | Value |
|---|---|
| Method | POST |
| URL | https://api.resend.com/emails |
| Auth | Bearer YOUR_RESEND_API_KEY |
| Header | Content-Type: application/json |
Body:
{
"from": "YourApp <noreply@yourdomain.com>",
"to": "{{fp_data.email}}",
"subject": "Reset your password",
"html": "<h2>Password Reset</h2><p>Click below to reset your password:</p><p><a href='https://yourdomain.com/reset-password?token={{resetToken.rawToken}}&email={{fp_data.email}}'>Reset my password</a></p><p>This link expires in 1 hour.</p><p>If you didn't request this, you can ignore this email.</p>"
}
IF branch - Simple Output (Success)
| Setting | Value |
|---|---|
| Status | 200 |
| Type | JSON |
| Output | { "success": true, "message": "If an account exists, a reset link has been sent." } |
ELSE branch - Simple Output (Generic Success)
Same message as the IF branch - the caller cannot distinguish between "email found" and "email not found."
| Setting | Value |
|---|---|
| Status | 200 |
| Type | JSON |
| Output | { "success": true, "message": "If an account exists, a reset link has been sent." } |
Reset Branch (Steps 14–17)
Step 14 - Query Data (Find User by Token)
Look up the user by email + hashed reset token + not expired.
Output variable: resetUser
| Setting | Value |
|---|---|
| Source | users (Structured) |
| Filter 1 | email equals {{fp_data.email}} |
| Filter 2 | reset_token equals {{fp_data.hashedToken}} |
| Filter 3 | reset_token_expires_at >= {{fp_data.requestTimestamp}} |
| Limit | 1 |
Step 15 - Condition (Token Valid?)
| Branch | Action |
|---|---|
| IF | {{resetUser.totalCount}} equals 0 → Log + return generic error |
| ELSE | → Update password |
IF branch - Code Node (Log Failed Reset)
(function() {
console.log(JSON.stringify({
event: 'password_reset_failed',
reason: 'no_match_or_expired',
email_hash: sha256(variables.fp_data.email).substring(0, 12),
ip: variables._request?.ip || 'unknown',
timestamp: new Date().toISOString()
}));
return { logged: true };
})();
IF branch - Simple Output (Reset Failed)
| Setting | Value |
|---|---|
| Status | 400 |
| Type | JSON |
| Output | { "success": false, "error": "Reset failed. Please request a new link." } |
ELSE branch - Write Data (Update Password)
Update the password and clear the reset token with a triple WHERE clause.
| Setting | Value |
|---|---|
| Source | users (Update) |
| Filter 1 | email equals {{fp_data.email}} |
| Filter 2 | reset_token equals {{fp_data.hashedToken}} |
| Filter 3 | reset_token_expires_at >= {{fp_data.requestTimestamp}} |
| Column | Value |
|---|---|
| password | {{fp_data.hashedPassword}} |
| reset_token | (empty string) |
| reset_token_expires_at | (empty string) |
Step 16 - Write Data (Clear Rate Limit)
On successful reset, clear the attempt counter.
| Setting | Value |
|---|---|
| Source | password_reset_attempts (Update) |
| Filter | email equals {{fp_data.email}} |
| Column | Value |
|---|---|
| attempt_count | 0 |
Step 17 - Code Node (Log Success)
(function() {
console.log(JSON.stringify({
event: 'password_reset_success',
email_hash: sha256(variables.fp_data.email).substring(0, 12),
ip: variables._request?.ip || 'unknown',
timestamp: new Date().toISOString()
}));
return { logged: true };
})();
Step 17 - Simple Output (Reset Success)
| Setting | Value |
|---|---|
| Status | 200 |
| Type | JSON |
| Output | { "success": true, "message": "Password has been reset successfully." } |
Post-Import Setup
After importing this workflow, you need to configure:
- Flow Start node - Update CORS origins to your production domain
- Data Mapper - Already configured, no changes needed
- Data Validator - Verify that Input Data is set to
{{dataMapper}} - Query Data (Rate Limit) - Connect to your
password_reset_attemptstable and map column IDs - Write Data (Track Attempt) - Connect to your
password_reset_attemptstable - Query Data (Find Verified User) - Connect to your
userstable, map email and verified columns - Write Data (Store Reset Token) - Connect to your
userstable, map reset_token and reset_token_expires_at columns - HTTP Request - Set your Resend API key and update the
fromaddress and reset URL domain - Query Data (Find User by Token) - Connect to your
userstable, map email, reset_token, reset_token_expires_at columns - Write Data (Update Password) - Connect to your
userstable, map password, reset_token, reset_token_expires_at columns - Write Data (Clear Counter) - Connect to your
password_reset_attemptstable
Frontend Integration
// Request a password reset link
async requestPasswordReset(email: string) {
const response = await fetch('https://your-domain.com/api/v1/YOUR_ID/forgotten-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'request', email })
});
return response.json();
}
// Reset the password with token from email link
async resetPassword(email: string, token: string, newPassword: string) {
const response = await fetch('https://your-domain.com/api/v1/YOUR_ID/forgotten-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reset', email, token, newPassword })
});
return response.json();
}
Testing
Test 1 - Request reset for existing user:
curl -X POST https://your-domain.com/api/v1/YOUR_ID/forgotten-password \
-H "Content-Type: application/json" \
-d '{"action": "request", "email": "test@example.com"}'
Expected: { "success": true, "message": "If an account exists, a reset link has been sent." }
Test 2 - Request reset for non-existent email:
curl -X POST https://your-domain.com/api/v1/YOUR_ID/forgotten-password \
-H "Content-Type: application/json" \
-d '{"action": "request", "email": "nobody@example.com"}'
Expected: Same response as Test 1 (no enumeration)
Test 3 - Reset with valid token:
curl -X POST https://your-domain.com/api/v1/YOUR_ID/forgotten-password \
-H "Content-Type: application/json" \
-d '{"action": "reset", "email": "test@example.com", "token": "YOUR_TOKEN_HERE", "newPassword": "MyN3wP@ss!"}'
Expected: { "success": true, "message": "Password has been reset successfully." }
Test 4 - Reset with expired/invalid token:
curl -X POST https://your-domain.com/api/v1/YOUR_ID/forgotten-password \
-H "Content-Type: application/json" \
-d '{"action": "reset", "email": "test@example.com", "token": "invalidtoken12345678901234567890ab", "newPassword": "MyN3wP@ss!"}'
Expected: { "success": false, "error": "Reset failed. Please request a new link." }
Test 5 - Weak password:
curl -X POST https://your-domain.com/api/v1/YOUR_ID/forgotten-password \
-H "Content-Type: application/json" \
-d '{"action": "reset", "email": "test@example.com", "token": "sometoken", "newPassword": "weak"}'
Expected: { "success": false, "error": "Password must be at least 8 characters" }
Test 6 - Rate limiting (request action, send 4 rapidly):
for i in {1..4}; do
curl -s -X POST https://your-domain.com/api/v1/YOUR_ID/forgotten-password \
-H "Content-Type: application/json" \
-d '{"action": "request", "email": "test@example.com"}'
done
Expected: First 3 return 200, 4th returns { "success": false, "error": "Too many attempts. Please try again later." } (429)
Test 7 - Invalid action:
curl -X POST https://your-domain.com/api/v1/YOUR_ID/forgotten-password \
-H "Content-Type: application/json" \
-d '{"action": "delete", "email": "test@example.com"}'
Expected: { "success": false, "error": "Invalid action." }
What to verify in the database
After a successful reset, check users:
| Column | Expected |
|---|---|
| password | A new bcrypt hash (starts with $2a or $2b) |
| reset_token | (empty) |
| reset_token_expires_at | (empty) |
After a successful reset, check password_reset_attempts:
| Column | Expected |
|---|---|
| attempt_count | 0 (reset on success) |
Security Checklist
| Control | Status |
|---|---|
| POST only (no GET) | ✅ |
| Schema validation (Data Validator) | ✅ |
| Email format validation (regex pattern) | ✅ |
| Email normalization (lowercase, trim) | ✅ |
| Action routing in Code node | ✅ |
| Token hashed (SHA-256) before storage and comparison | ✅ |
| Token expiration (1h) | ✅ |
| Password strength enforcement | ✅ |
| Password hashed with bcrypt | ✅ |
| Triple WHERE on password update | ✅ |
| Per-email rate limiting (3/15min request, 5/15min reset) | ✅ |
| Global rate limiting (10/min) | ✅ |
| Generic error messages (no email enumeration) | ✅ |
| Audit logging on all paths | ✅ |
| Token + expiration cleared on success | ✅ |
| Rate limit counter reset on success | ✅ |
| Reduced timeout (15s) | ✅ |
| No real secrets in tutorial JSON | ✅ |