IntermediateAuthentication

Forgotten Password

Secure password reset with two actions: request a reset link and reset the password. Rate limiting, hashed tokens, expiration, and audit logging.

Click to expand
Flow StartAPI POSTData MapperdataMapperData ValidatorinputValidatorTRUEFALSESimple OutputError 400Codefp_dataConditionIFELSESimple OutputError 400Query DatarateLimitDataCoderateCheckConditionIFELSESimple OutputError 429Write DataattemptWriteConditionIFELSEQuery DatauserDataCoderesetTokenConditionIFELSESimple OutputGeneric 200Write DatawriteTokenHTTP RequestResend APISimple OutputSuccess 200Query DataresetUserConditionIFELSECodeaudit_failSimple OutputError 400Write DatawritePasswordWrite DataclearAttemptsCodeaudit_successSimple OutputSuccess 200

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_at to 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 request action 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
email 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
email 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 token and newPassword fields will be empty strings when the action is request. The Code node handles this - the Data Validator only enforces that action and email are present.

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 email
Column Value
email {{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:

  1. Flow Start node - Update CORS origins to your production domain
  2. Data Mapper - Already configured, no changes needed
  3. Data Validator - Verify that Input Data is set to {{dataMapper}}
  4. Query Data (Rate Limit) - Connect to your password_reset_attempts table and map column IDs
  5. Write Data (Track Attempt) - Connect to your password_reset_attempts table
  6. Query Data (Find Verified User) - Connect to your users table, map email and verified columns
  7. Write Data (Store Reset Token) - Connect to your users table, map reset_token and reset_token_expires_at columns
  8. HTTP Request - Set your Resend API key and update the from address and reset URL domain
  9. Query Data (Find User by Token) - Connect to your users table, map email, reset_token, reset_token_expires_at columns
  10. Write Data (Update Password) - Connect to your users table, map password, reset_token, reset_token_expires_at columns
  11. Write Data (Clear Counter) - Connect to your password_reset_attempts table

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