IntermediateAuthentication

Email Verification

Secure token-based email verification with rate limiting, hashed tokens, expiration, and audit logging.

Click to expand
Flow StartAPI POSTData MapperdataMapperData ValidatorinputValidatorTRUEFALSESimple OutputError 400Codeverify_inputQuery DatarateLimitDataConditionIFELSESimple OutputError 429CodeattemptCalcWrite DataattemptWriteQuery DataqueryDataConditionIFELSECodeaudit_log_2Simple OutputError 400Write DatawriteDataWrite DataclearAttemptsCodeaudit_log_successSimple OutputSuccess 200

Email Verification

Verify user emails using a secure, token-based flow. When a user clicks the verification link from their email, this workflow validates the request schema, sanitizes input, enforces rate limits, compares a hashed token against the database, checks expiration, and marks the account as verified.

What You'll Build

A hardened API endpoint that:

  • Receives email and token in a POST request body (never in query params)
  • Validates the request schema using a Data Validator (email: string, token: string, both required)
  • Validates email format and token format (64-char hex or UUID v4) via Data Validator regex patterns
  • Normalizes email (lowercase, trimmed)
  • Hashes the token with SHA-256 before comparing against the database
  • Enforces per-email rate limiting (5 attempts per 15 minutes)
  • Checks token expiration (token_expires_at >= NOW())
  • Rejects already-verified accounts
  • Updates the user with a triple WHERE clause (email + hashed token + not verified)
  • Clears the token and expiration, records verified_at timestamp
  • Resets the rate-limit counter on success
  • Logs all attempts (success and failure) with IP and user-agent for audit
  • Returns identical generic error messages for all failure types (no information leakage)

Endpoint: POST /api/v1/YOUR_ID/verify

Request body:

{
  "email": "user@example.com",
  "token": "a1b2c3d4e5f..."
}

Responses:

Scenario Status Body
Success 200 { "success": true, "message": "Email verified successfully." }
Invalid schema 400 { "valid": false, "errors": [...] }
Invalid input / no match / expired 400 { "success": false, "error": "Verification failed. Please request a new link." }
Rate limited 429 { "success": false, "error": "Too many attempts. Please try again later." }

All failure responses use generic messages to prevent email enumeration and token-guessing attacks.

Prerequisites

Table: users

Same table used by the registration tutorial. Make sure it includes these columns:

Column Type Description
email text User's email (unique)
verified text "yes" or "no"
verification_token text SHA-256 hash of the token sent in the email
token_expires_at datetime When the verification token expires (e.g. 24 hours after registration)
verified_at datetime Timestamp of when the account was verified

Table: verification_attempts (new)

A separate table to track rate limiting per email:

Column Type Description
email text The email being verified (unique)
attempt_count number Number of attempts in the current window
last_attempt_at datetime Timestamp of the most recent attempt

Your registration workflow must hash the token with sha256(token) before storing it, and set token_expires_at to a time 24 hours in the future.

Security Improvements Over the Basic Flow

Area Before After
HTTP method GET (token in URL) POST only (token in body)
Schema validation None Data Validator (email + token required strings, regex patterns)
Rate limit 60/min global 10/min global + 5 per email per 15 min
Input validation Existence check only Email regex + token format (hex/UUID) in Data Validator
Email handling Raw Normalized (lowercase, trimmed)
Token comparison Plaintext SHA-256 hashed
Token expiration None token_expires_at >= NOW()
Already-verified guard None verified != yes filter
Write WHERE clause Email only Email + hashed token + not verified
Error messages Different per failure Identical generic message
Audit logging None All attempts logged with IP + user-agent
Security headers None HSTS, X-Content-Type-Options, X-Frame-Options, no-cache
Timeout 30s 15s

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)

GET is intentionally removed. Tokens in URLs leak through browser history, server logs, referrer headers, and proxy logs. Always use POST for sensitive operations.

Step 2 - Data Mapper

Map the incoming request body fields into a clean object. The Data Validator cannot resolve template expressions directly in its Input Data field - it needs a reference to an already-resolved variable. The Data Mapper handles this.

Output variable: dataMapper

Template:

{
  "email": "{{email}}",
  "token": "{{token}}"
}
Mapping From To
1 email email
2 token token

Step 3 - Data Validator

Validate the mapped data schema and format before any processing. The Data Validator supports pattern regex, so we use it to enforce both structural and format validation in a single node - no need for a separate Code node to check email/token formats.

Output variable: inputValidator

Input data: {{dataMapper}}

Field Type Required Validation
email string yes minLength: 5, pattern: ^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$
token string yes minLength: 1, pattern: ^[a-zA-Z0-9-]{8,128}$

The email pattern enforces RFC 5322 simplified format. The token pattern accepts alphanumeric strings between 8 and 128 characters, covering short random tokens, 64-char hex hashes, and UUIDs.

The Data Validator has two output ports:

Port Fires when Next node
TRUE Both fields present, correct type, and match regex 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}}

This returns the validator's error details directly to the caller, so they know exactly which fields failed and why (missing, wrong type, or pattern mismatch).

By using the Data Validator's pattern field, we catch invalid email formats and malformed tokens before they ever reach the Code node. This means the Code node only needs to handle normalization and hashing - keeping each node focused and reducing code complexity.

Step 4 - Code Node (Normalize & Hash)

Since the Data Validator already verified email format and token format via regex patterns, this node only needs to normalize the email (lowercase, trim), hash the token with SHA-256 for secure database comparison, and calculate the rate-limit cutoff timestamp.

Output variable: verify_input

(function() {
  const token = (variables.token || '').trim();
  const email = (variables.email || '').trim().toLowerCase();

  // Hash the token for secure DB comparison
  var hashedToken = sha256(token);

  // Calculate 15-minute-ago cutoff for rate limiting
  var cutoff = new Date(Date.now() - 15 * 60 * 1000).toISOString();

  return {
    valid: true,
    hashedToken: hashedToken,
    email: email,
    rateLimitCutoff: cutoff,
    requestTimestamp: new Date().toISOString()
  };
})();

Why hash the token?

If your database is ever compromised, raw verification tokens would let an attacker verify any pending account. By storing and comparing only the SHA-256 hash, a database leak doesn't expose usable tokens. This is the same principle behind storing password hashes instead of plaintext passwords.

Why validate token format in the Data Validator?

Without format validation, an attacker could send token=1 and potentially get a match. By requiring a 64-character hex string or a valid UUID v4 at the validator level, malformed tokens are rejected immediately with a 400 error - they never reach the Code node or the database.

Step 5 - Query Data (Check Rate Limit)

Query the verification_attempts table to see how many attempts this email has made in the last 15 minutes.

Output variable: rateLimitData

Setting Value
Source verification_attempts (Structured)
Filter 1 email equals {{verify_input.email}}
Filter 2 last_attempt_at >= {{verify_input.rateLimitCutoff}}
Limit 1

Step 6 - Condition (Rate Limited?)

Branch Action
IF {{rateLimitData.data.rows[0].attempt_count}} >= 5 → Return 429 error
ELSE Continue to Step 7

IF branch - Simple Output (Rate Limited):

Setting Value
Status 429
Type JSON
Output { "success": false, "error": "Too many attempts. Please try again later." }

5 attempts per 15 minutes is strict enough to block brute-force while generous enough for legitimate users who might click the link multiple times.

Step 7 - Code Node (Compute Attempt Count)

Calculate the new attempt count from the rate limit query result. Pongo2 templates don't support || or complex array expressions, so we compute this in JavaScript.

Output variable: attemptCalc

(function() {
  var rows = (variables.rateLimitData && variables.rateLimitData.data && variables.rateLimitData.data.rows) || [];
  var current = (rows.length > 0 && rows[0].attempt_count) ? parseInt(rows[0].attempt_count) : 0;
  return { newCount: current + 1 };
})();

Step 8 - Write Data (Track Attempt)

Upsert into verification_attempts to increment the counter before we even check the token. This way, failed attempts always count.

Setting Value
Source verification_attempts (Upsert)
Match Column email
Column Value
email {{verify_input.email}}
attempt_count {{attemptCalc.newCount}}
last_attempt_at {{verify_input.requestTimestamp}}

Step 9 - Query Data (Find User by Hashed Token + Email)

This is the core lookup. Note the four filters - all must match:

Output variable: queryData

Setting Value
Source users (Structured)
Filter 1 email equals {{verify_input.email}}
Filter 2 verification_token equals {{verify_input.hashedToken}}
Filter 3 token_expires_at >= {{verify_input.requestTimestamp}}
Filter 4 verified not equals yes
Limit 1

Why four filters?

  • email + hashed token: Both must match. Prevents token reuse across accounts.
  • token_expires_at >= NOW(): Rejects expired tokens. A 24-hour window is standard.
  • verified != yes: Prevents re-verification of already-verified accounts (idempotency guard).

Step 10 - Condition (Token Match Found?)

Branch Action
IF {{queryData.data.totalCount}} equals 0 → Log + return generic error
ELSE Continue to Step 11 (user found, token valid, not expired)

IF branch - Log + Error Output (No Match)

Code node - logs the failed attempt (hashes the email in the log for privacy):

(function() {
  console.log(JSON.stringify({
    event: 'verification_failed',
    reason: 'no_match_or_expired',
    email_hash: sha256(variables.verify_input.email).substring(0, 12),
    ip: variables._request?.ip || 'unknown',
    userAgent: variables._request?.headers?.['user-agent'] || 'unknown',
    timestamp: new Date().toISOString()
  }));
  return { logged: true };
})();

Simple Output:

Setting Value
Status 400
Type JSON
Output { "success": false, "error": "Verification failed. Please request a new link." }

Same generic error message for all failures. The attacker learns nothing about why it failed.

Step 11 - Write Data (Verify User)

Mark the user as verified with a triple WHERE clause for safety.

Setting Value
Source users (Update)
Filter 1 email equals {{verify_input.email}}
Filter 2 verification_token equals {{verify_input.hashedToken}}
Filter 3 verified not equals yes
Column Value
verified yes
verification_token (empty string)
token_expires_at (empty string)
verified_at {{verify_input.requestTimestamp}}

Why a triple WHERE clause?

The original workflow only filtered by email. That means if the condition check somehow passed (race condition, bug), any account with that email would get verified regardless of token. By including the hashed token and the not-verified guard in the WHERE clause itself, the database acts as the final gatekeeper.

Step 12 - Write Data (Clear Rate Limit Counter)

On successful verification, reset the attempt counter so the user isn't penalized for future actions.

Setting Value
Source verification_attempts (Update)
Filter email equals {{verify_input.email}}
Column Value
attempt_count 0

Step 13 - Code Node (Log Success)

Log the successful verification for audit:

(function() {
  console.log(JSON.stringify({
    event: 'verification_success',
    email_hash: sha256(variables.verify_input.email).substring(0, 12),
    ip: variables._request?.ip || 'unknown',
    timestamp: new Date().toISOString()
  }));
  return { logged: true };
})();

Step 14 - Simple Output (Success)

Setting Value
Status 200
Type JSON
Output { "success": true, "message": "Email verified 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}}. The regex patterns for email and token format are already configured.
  4. Query Data (Rate Limit) - Connect to your verification_attempts table and map the column IDs
  5. Write Data (Track Attempt) - Connect to your verification_attempts table
  6. Query Data (Find User) - Connect to your users table and map the column IDs for email, verification_token, token_expires_at, and verified
  7. Write Data (Verify User) - Connect to your users table and map the column IDs
  8. Write Data (Clear Counter) - Connect to your verification_attempts table

Frontend Integration

Your frontend should send a POST request with the token and email in the body, not as query parameters:

private async verifyEmail(token: string, email: string) {
  const result = await new Promise<{ ok: boolean; message: string }>((resolve) => {
    const xhr = new XMLHttpRequest();
    let resolved = false;
    const done = (ok: boolean, message: string) => {
      if (resolved) return;
      resolved = true;
      xhr.abort();
      resolve({ ok, message });
    };

    xhr.open('POST', 'https://your-domain.com/api/v1/YOUR_ID/verify');
    xhr.setRequestHeader('Content-Type', 'application/json');

    xhr.onprogress = () => {
      if (xhr.status >= 200 && xhr.status < 300 && xhr.responseText.trim()) {
        try {
          const json = JSON.parse(xhr.responseText.trim());
          done(!!json.success, json.message || json.error || 'Done');
        } catch { done(true, xhr.responseText.trim()); }
      }
    };
    xhr.onload = () => {
      try {
        const json = JSON.parse(xhr.responseText.trim());
        done(!!json.success, json.message || json.error || 'Done');
      } catch {
        done(xhr.status >= 200 && xhr.status < 300, xhr.responseText.trim());
      }
    };
    xhr.onerror = () => done(false, 'Could not reach the server. Please try again later.');
    xhr.ontimeout = () => done(false, 'Request timed out. Please try again.');
    xhr.timeout = 15000;

    xhr.send(JSON.stringify({ token, email }));
  });
}

The token and email still come from query parameters in the verification URL the user clicks. The frontend reads them from the URL, then sends them in a POST body to the API. This keeps tokens out of server access logs.

Testing

Using curl

Test 1 - Valid verification:

curl -X POST https://your-domain.com/api/v1/YOUR_ID/verify \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "token": "YOUR_TOKEN_HERE"}'

Expected: { "success": true, "message": "Email verified successfully." }

Test 2 - Missing fields (caught by Data Validator):

curl -X POST https://your-domain.com/api/v1/YOUR_ID/verify \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com"}'

Expected: 400 with validator error details (token field missing)

Test 3 - Invalid token format (caught by Data Validator pattern):

curl -X POST https://your-domain.com/api/v1/YOUR_ID/verify \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "token": "short"}'

Expected: 400 with validator error details (token field failed pattern validation)

Test 4 - Reuse the same link:

Send the same valid request again after already verifying.

Expected: { "success": false, "error": "Verification failed. Please request a new link." } (400, token was cleared)

Test 5 - Rate limiting (send 6 requests rapidly):

for i in {1..6}; do
  curl -s -X POST https://your-domain.com/api/v1/YOUR_ID/verify \
    -H "Content-Type: application/json" \
    -d '{"email": "test@example.com", "token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
done

Expected: First 5 return 400, 6th returns { "success": false, "error": "Too many attempts. Please try again later." } (429)

Test 6 - GET method rejected:

curl "https://your-domain.com/api/v1/YOUR_ID/verify?email=test@example.com&token=abc"

Expected: 405 Method Not Allowed (only POST is accepted)

What to verify in the database

After successful verification, check users:

Column Expected
verified yes
verification_token (empty)
token_expires_at (empty)
verified_at Timestamp of verification

After successful verification, check verification_attempts:

Column Expected
attempt_count 0 (reset on success)

Security Checklist

Control Status
POST only (no GET)
Schema validation (Data Validator)
Input validation (email + token regex in Data Validator)
Email normalization
Token hashed (SHA-256) before DB lookup
Token expiration enforced
Already-verified guard
Per-email rate limiting (5/15min)
Global rate limiting (10/min)
Triple WHERE on update (email + token + not verified)
Generic error messages (no enumeration)
Audit logging on all paths
Security headers (HSTS, X-Frame-Options, etc.)
Token + expiration cleared on success
Rate limit counter reset on success
Reduced timeout (15s)