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
emailandtokenin 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_attimestamp - 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 |
|---|---|---|
| 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 |
|---|---|---|
| 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 settoken_expires_atto 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
patternfield, 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 |
| Column | Value |
|---|---|
{{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:
- 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}}. The regex patterns for email and token format are already configured. - Query Data (Rate Limit) - Connect to your
verification_attemptstable and map the column IDs - Write Data (Track Attempt) - Connect to your
verification_attemptstable - Query Data (Find User) - Connect to your
userstable and map the column IDs foremail,verification_token,token_expires_at, andverified - Write Data (Verify User) - Connect to your
userstable and map the column IDs - Write Data (Clear Counter) - Connect to your
verification_attemptstable
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) | ✅ |