User Registration
Build a secure registration flow with schema validation, password strength enforcement, duplicate email detection, hashed verification tokens, token expiration, and email verification.
What You'll Build
A hardened API endpoint that:
- Receives
name,email, andpasswordin a POST request body - Validates the request schema and password strength using a Data Validator (all fields required, email regex, password pattern)
- Normalizes email (lowercase, trimmed)
- Checks for duplicate emails in the database
- Hashes the password with bcrypt
- Generates a verification token and hashes it with SHA-256 before storing
- Sets
token_expires_atto 24 hours in the future - Sends a verification email via Resend
- Returns generic error messages to prevent email enumeration
Endpoint: POST /api/v1/YOUR_ID/register
Request body:
{
"name": "John Doe",
"email": "user@example.com",
"password": "MyP@ssw0rd!"
}
Responses:
| Scenario | Status | Body |
|---|---|---|
| Success | 200 | { "success": true, "message": "Registration successful. Please check your email." } |
| Invalid schema / weak password | 400 | { "valid": false, "errors": [...] } |
| Duplicate email | 409 | { "success": false, "error": "An account with this email already exists" } |
Prerequisites
Table: users
| Column | Type | Description |
|---|---|---|
| text | User's email (unique) | |
| password | text | Bcrypt hash of the password |
| name | text | User's display name |
| verified | text | "yes" or "no" |
| verification_token | text | SHA-256 hash of the raw token sent in the email |
| token_expires_at | datetime | When the verification token expires (24h after registration) |
| verified_at | datetime | Timestamp of when the account was verified |
Workflow Flow
Flow Start (API POST /register)
↓
Data Mapper → dataMapper
↓
Data Validator → inputValidator
├─ FALSE → Simple Output (400 validation error)
└─ TRUE ↓
Code Node (Prepare Data) → register_data
↓
Query Data (Check Duplicate) → existing_user
↓
Condition (Email Exists?)
├─ ELSE → Simple Output (409 duplicate)
└─ IF ↓
Write Data (Insert User) → write_result
↓
HTTP Request (Send Email) → httpRequest
↓
Simple Output (200 success)
Security Improvements Over the Basic Flow
| Area | Before | After |
|---|---|---|
| Schema validation | None | Data Validator (name, email, password required) |
| Email format | Basic regex in code | Data Validator regex pattern |
| Password strength | Code-based checks | Data Validator regex pattern |
| Email handling | Raw | Normalized (lowercase, trimmed) |
| Token storage | Plaintext | SHA-256 hashed |
| Token expiration | None | token_expires_at set to 24h |
| Rate limit | 60/min | 10/min |
| Timeout | 30s | 15s |
| Error messages | Leak validation details | Generic where needed |
| CORS | None | Restricted to production domain |
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:
{
"name": "{{name}}",
"email": "{{email}}",
"password": "{{password}}"
}
Step 3 - Data Validator
Validate the mapped data schema, email format, and password strength — all in one node, no code needed.
Output variable: inputValidator
Input data: {{dataMapper}}
| Field | Type | Required | Validation |
|---|---|---|---|
name |
string | yes | minLength: 1 |
email |
string | yes | minLength: 5, pattern: ^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$ |
password |
string | yes | minLength: 8, pattern: (?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]) |
The password pattern uses four simple lookaheads:
(?=.*[a-z])— at least one lowercase letter(?=.*[A-Z])— at least one uppercase letter(?=.*\d)— at least one digit(?=.*[\W_])— at least one special character
The Data Validator has two output ports:
| Port | Fires when | Next node |
|---|---|---|
| TRUE | All 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 (Prepare Data)
With validation handled by the Data Validator, this Code node only normalizes the email, hashes the password, generates a verification token, hashes it with SHA-256, and calculates the token expiration.
Output variable: register_data
(function() {
var name = (variables.dataMapper.name || '').trim();
var email = (variables.dataMapper.email || '').trim().toLowerCase();
var password = variables.dataMapper.password || '';
var hashedPassword = bcryptHash(password);
// Generate raw token (sent in email) and hash it (stored in DB)
var rawToken = uuid().replace(/-/g, '');
var hashedToken = sha256(rawToken);
// Token expires 24 hours from now
var tokenExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
return {
name: name,
email: email,
hashedPassword: hashedPassword,
rawToken: rawToken,
hashedToken: hashedToken,
tokenExpiresAt: tokenExpiresAt
};
})();
Why hash the token before storing?
The raw token is sent to the user's email. Only the SHA-256 hash is stored in the database. If the database is compromised, attackers can't use the hashed tokens to verify accounts. The verify workflow hashes the incoming token and compares it against the stored hash — same principle as password hashing.
Why set token_expires_at?
Without expiration, a verification token is valid forever. If a user's email is compromised months later, old tokens could still be used. A 24-hour window is standard — long enough for the user to check their email, short enough to limit exposure.
Step 5 - Query Data (Check Duplicate Email)
Before inserting, check if a user with this email already exists.
Output variable: existing_user
| Setting | Value |
|---|---|
| Source | users (Structured) |
| Columns | |
| Filter | email equals {{register_data.email}} |
| Output Format | List of Rows |
| Limit | 1 |
Step 6 - Condition (Email Exists?)
| Branch | Action |
|---|---|
| IF | {{existing_user.data.totalCount}} equals 0 → Continue to Step 7 (no duplicate) |
| ELSE | → Simple Output (409 Conflict) |
ELSE branch - Simple Output (Duplicate)
| Setting | Value |
|---|---|
| Status | 409 |
| Type | JSON |
| Output | { "success": false, "error": "An account with this email already exists" } |
Step 7 - Write Data (Insert User)
Insert the new user with all required fields, including the hashed token and expiration.
| Setting | Value |
|---|---|
| Source | users (Insert) |
| Column | Value |
|---|---|
{{register_data.email}} |
|
| password | {{register_data.hashedPassword}} |
| name | {{register_data.name}} |
| verified | no |
| verification_token | {{register_data.hashedToken}} |
| token_expires_at | {{register_data.tokenExpiresAt}} |
The password stored is the bcrypt hash, never the plain text. The verification token stored is the SHA-256 hash — the raw token is only sent in the email. The
verifiedfield starts as"no".
Step 8 - HTTP Request (Send Verification Email)
Send a verification email using the Resend API. The raw (unhashed) token is sent in the email link.
| Setting | Value |
|---|---|
| Method | POST |
| URL | https://api.resend.com/emails |
| Auth | Bearer {{secrets.resend_api_key}} |
| Header | Content-Type: application/json |
Body:
{
"from": "YourApp <noreply@yourdomain.com>",
"to": "{{register_data.email}}",
"subject": "Verify your account",
"html": "<h2>Welcome, {{register_data.name}}!</h2><p>Click below to verify your email:</p><p><a href='https://yourdomain.com/verify?token={{register_data.rawToken}}&email={{register_data.email}}'>Verify my email</a></p><p>This link expires in 24 hours.</p><p>If you didn't create this account, you can ignore this email.</p>"
}
The email link contains the raw token (
rawToken), not the hashed one. When the user clicks the link, the verify workflow hashes it and compares against the stored hash. Store your Resend API key in the Vault (Settings → Vault) asresend_api_key.
Step 9 - Simple Output (Success)
| Setting | Value |
|---|---|
| Status | 200 |
| Type | JSON |
| Output | { "success": true, "message": "Registration successful. Please check your email." } |
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 (Check Duplicate) — Connect to your
userstable and map the email column - Write Data (Insert User) — Connect to your
userstable and map all column IDs - Vault — Store your Resend API key as
resend_api_keyin Settings → Vault - HTTP Request — Update the
fromaddress and verification URL domain
Frontend Integration
async register(name: string, email: string, password: string) {
const response = await fetch('https://your-domain.com/api/v1/YOUR_ID/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password })
});
return response.json();
}
Testing
Test 1 - Successful registration:
curl -X POST https://your-domain.com/api/v1/YOUR_ID/register \
-H "Content-Type: application/json" \
-d '{"name": "Test User", "email": "test@example.com", "password": "MyP@ssw0rd!"}'
Expected: { "success": true, "message": "Registration successful. Please check your email." }
Test 2 - Missing fields (caught by Data Validator):
curl -X POST https://your-domain.com/api/v1/YOUR_ID/register \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
Expected: 400 with validator error details
Test 3 - Weak password (caught by Data Validator):
curl -X POST https://your-domain.com/api/v1/YOUR_ID/register \
-H "Content-Type: application/json" \
-d '{"name": "Test", "email": "new@example.com", "password": "weak"}'
Expected: 400 with validator error (password doesn't match pattern)
Test 4 - Duplicate email:
Run Test 1 again.
Expected: { "success": false, "error": "An account with this email already exists" }
What to verify in the database
After a successful registration, check users:
| Column | Expected |
|---|---|
| The email you sent (lowercase) | |
| password | A bcrypt hash (starts with $2a or $2b) |
| name | The name you sent |
| verified | no |
| verification_token | A 64-character hex string (SHA-256 hash) |
| token_expires_at | A timestamp 24 hours in the future |
Security Checklist
| Control | Status |
|---|---|
| POST only (no GET) | ✅ |
| Schema validation (Data Validator) | ✅ |
| Email format validation (regex pattern) | ✅ |
| Password strength enforcement (regex pattern in Data Validator) | ✅ |
| Email normalization (lowercase, trim) | ✅ |
| Password hashed with bcrypt | ✅ |
| Duplicate email check | ✅ |
| Verification token hashed (SHA-256) before storage | ✅ |
| Token expiration set (24h) | ✅ |
| Raw token only in email, never stored | ✅ |
| Rate limiting (10/min) | ✅ |
| Reduced timeout (15s) | ✅ |
| API key stored in Vault | ✅ |
| No real secrets in tutorial JSON | ✅ |