OTP Authentication
Production-grade one-time password authentication with a separate otp_codes table, code expiration, single-use enforcement, and brute-force protection.
What You'll Build
A single API endpoint that handles four actions through one workflow:
| Action | What it does |
|---|---|
enable |
Enables OTP for the user, stores a row in otp_codes, and sets otp_enabled to "yes" on the user. No email is sent. |
disable |
Disables OTP for the user, stores a row in otp_codes, and sets otp_enabled to "no" on the user. No email is sent. |
send |
Generates a new 6-digit code for a user who already has OTP enabled and sends it via email |
verify |
Validates the submitted code against expiry, attempt limits, and single-use rules |
Endpoint: POST /api/v1/YOUR_ID/otp
Request body:
{
"email": "user@example.com",
"action": "enable",
"code": ""
}
The
codefield is only needed for theverifyaction. Forenableandsend, send it as an empty string.
Database Setup
You need one new table and one new column on the existing users table.
New table: otp_codes
| Column | Type | Description |
|---|---|---|
email |
TEXT | User's email address |
code |
TEXT | 6-digit OTP code |
action |
TEXT | What triggered it: "enable", "disable", or "login" |
created_at |
TEXT | Millisecond timestamp when the code was generated (String(Date.now())) |
used |
TEXT | 0 = unused, 1 = used |
attempts |
TEXT | Number of failed verification attempts (starts at 0) |
Add column to users
| Column | Type | Description |
|---|---|---|
otp_enabled |
TEXT | "yes" or empty/"no" |
No OTP codes or secrets are stored on the user row. The
otp_enabledflag just tells the login workflow whether to require OTP.
Workflow Flow
Flow Start
→ Data Validator (email)
├─ TRUE → Query Data (users) ─┐
│ Query Data (otp_codes) ─┤
│ → Code (route + validate)
│ → Condition (route?)
│ IF (insert_email) → Write Data (INSERT otp_codes) → HTTP Request (Resend) → Simple Output
│ ELSE IF (insert_user) → Write Data (INSERT otp_codes) → Write Data (UPDATE users) → Simple Output
│ ELSE IF (update_otp) → Write Data (UPDATE otp_codes) → Simple Output
│ ELSE (no_write) → Simple Output
└─ FALSE → Simple Output (400 error)
The old workflow used 4 Condition nodes with boolean flags. This version uses a single
routestring and one multi-branch Condition, cutting the node count significantly.
Step 1 - Flow Start
| Setting | Value |
|---|---|
| Trigger Type | API |
| Method | POST |
| Rate Limit | 60 |
| Timeout | 30 |
Step 2 - Data Validator
Validates the incoming email before any database queries run. Rejects malformed requests early.
Output variable: dataValidator
| Setting | Value |
|---|---|
| Data | {"email":"{{email}}"} |
| Root Type | Object |
Schema:
| Field | Type | Required | Validation |
|---|---|---|---|
| string | yes | minLength: 5, pattern: ^[^\s@]+@[^\s@]+\.[^\s@]+$ |
Branches:
| Branch | Next |
|---|---|
| TRUE | Query Data (users) + Query Data (otp_codes) |
| FALSE | Simple Output → "No such user exists" (status 400) |
Step 3a - Query Data (Find User)
Look up the user to check if they exist and whether OTP is already enabled. Runs in parallel with Step 3b.
Output variable: user_data
| Setting | Value |
|---|---|
| Source | users (Structured) |
| Filter | email equals {{email}} |
| Limit | 1 |
Step 3b - Query Data (Latest OTP Code)
Fetch the most recent unused OTP code for this user. Runs in parallel with Step 3a. This is needed for the verify action to check the code against.
Output variable: latest_otp
| Setting | Value |
|---|---|
| Source | otp_codes (Structured) |
| Filter 1 | email equals {{email}} |
| Filter 2 | used equals 0 |
| Limit | 1 |
We filter by
used = 0to only get active (unused) codes. Enable/disable rows are inserted withused = 1so they never interfere with this query.
Step 4 - Code Node (Route + Validate)
Both Query Data nodes feed into this single Code node.
All business logic lives in this single Code node. Based on the action parameter, it validates the request and returns a route string that controls which branch the single Condition takes.
Output variable: otp_result
(function() {
const email = variables.email;
const action = variables.action;
const code = variables.code;
const userData = [{{user_data}}][0] || {};
const users = userData.rows || [];
const user = users.find(u => u.email === email);
if (!user) {
return {
route: 'no_write',
response: { success: false, error: 'User not found' }
};
}
if (!['enable', 'send', 'verify', 'disable'].includes(action)) {
return {
route: 'no_write',
response: { success: false, error: 'Invalid action' }
};
}
const otpData = [{{latest_otp}}][0] || {};
const otpRows = otpData.rows || [];
const latestCode = otpRows.length > 0 ? otpRows[0] : null;
const now = String(Date.now());
// --- ENABLE ---
if (action === 'enable') {
if (user.otp_enabled === 'yes') {
return {
route: 'no_write',
response: { success: false, error: 'OTP is already enabled' }
};
}
return {
route: 'insert_user',
email: user.email,
name: user.name,
otp_code: '',
otp_action: 'enable',
created_at: now,
otp_used: '1',
otp_enabled: 'yes',
response: { success: true, message: 'OTP has been enabled.' }
};
}
// --- DISABLE ---
if (action === 'disable') {
if (user.otp_enabled !== 'yes') {
return {
route: 'no_write',
response: { success: false, error: 'OTP is not enabled' }
};
}
return {
route: 'insert_user',
email: user.email,
name: user.name,
otp_code: '',
otp_action: 'disable',
created_at: now,
otp_used: '1',
otp_enabled: 'no',
response: { success: true, message: 'OTP has been disabled.' }
};
}
// --- SEND ---
if (action === 'send') {
if (user.otp_enabled !== 'yes') {
return {
route: 'no_write',
response: { success: false, error: 'OTP is not enabled' }
};
}
const newCode = String(Math.floor(100000 + Math.random() * 900000));
return {
route: 'insert_email',
email: user.email,
name: user.name,
otp_code: newCode,
otp_action: 'login',
created_at: now,
response: { success: true, message: 'OTP code sent to your email.' }
};
}
// --- VERIFY ---
if (action === 'verify') {
if (user.otp_enabled !== 'yes') {
return {
route: 'no_write',
response: { success: false, error: 'OTP is not enabled' }
};
}
if (!latestCode) {
return {
route: 'no_write',
response: { success: false, error: 'No active OTP code found' }
};
}
const attempts = parseInt(latestCode.attempts || '0', 10);
if (attempts >= 5) {
return {
route: 'no_write',
response: { success: false, error: 'Too many attempts. Request a new code.' }
};
}
const createdAtMs = parseInt(latestCode.created_at, 10);
const fiveMin = 5 * 60 * 1000;
if (isNaN(createdAtMs) || Date.now() - createdAtMs > fiveMin) {
return {
route: 'update_otp',
email: user.email,
otp_code: latestCode.code,
otp_used: '1',
otp_attempts: String(attempts),
created_at: latestCode.created_at,
response: { success: false, error: 'OTP code has expired. Request a new one.' }
};
}
if (!code || code !== latestCode.code) {
return {
route: 'update_otp',
email: user.email,
otp_code: latestCode.code,
otp_used: '0',
otp_attempts: String(attempts + 1),
created_at: latestCode.created_at,
response: { success: false, error: 'Invalid OTP code' }
};
}
return {
route: 'update_otp',
email: user.email,
otp_code: latestCode.code,
otp_used: '1',
otp_attempts: String(attempts),
created_at: latestCode.created_at,
response: { success: true, message: 'OTP verified successfully' }
};
}
})();
Security: Every return includes a
responseobject that contains ONLY what the client should see. Internal fields likeotp_code,route, etc. stay on the server. All Simple Output nodes reference{{otp_result.response}}.
Route values
| Route | When | What happens next |
|---|---|---|
no_write |
Errors, already enabled, too many attempts | → Simple Output (error) |
insert_email |
send action |
→ INSERT otp_codes → HTTP Request (email) → Simple Output |
insert_user |
enable / disable action |
→ INSERT otp_codes → UPDATE users → Simple Output |
update_otp |
verify action (success, wrong code, expired) |
→ UPDATE otp_codes → Simple Output |
What each action returns
| Action | Route | What happens |
|---|---|---|
enable |
insert_user |
Insert row → Update user (otp_enabled → "yes") |
disable |
insert_user |
Insert row → Update user (otp_enabled → "no") |
send |
insert_email |
Insert code → Send email |
verify (success) |
update_otp |
Update code (mark used) |
verify (wrong code) |
update_otp |
Update code (increment attempts) |
verify (expired) |
update_otp |
Update code (mark used) |
| Any error | no_write |
Return error, no writes |
Step 5 - Condition (route?)
A single multi-branch Condition replaces the old 4-Condition chain. Uses AND conditions to match the route value.
| Branch | Condition | Next |
|---|---|---|
| IF | {{otp_result.route}} == insert_email |
Write Data (INSERT) → HTTP Request → Output |
| ELSE IF | {{otp_result.route}} == insert_user |
Write Data (INSERT) → Write Data (UPDATE users) → Output |
| ELSE IF | {{otp_result.route}} == update_otp |
Write Data (UPDATE otp_codes) → Output |
| ELSE | Everything else (no_write) |
Simple Output (error) |
Step 6a - Write Data (INSERT otp_codes) - Send path
This runs for the send action (insert_email route). Inserts a new row with the generated code, then sends the email.
| Setting | Value |
|---|---|
| Source | otp_codes (Insert) |
| Column | Value |
|---|---|
{{otp_result.email}} |
|
| code | {{otp_result.otp_code}} |
| action | {{otp_result.otp_action}} |
| created_at | {{otp_result.created_at}} |
| used | 0 |
| attempts | 0 |
Step 6a.1 - HTTP Request (Send OTP Email)
| Setting | Value |
|---|---|
| Method | POST |
| URL | https://api.resend.com/emails |
| Header | Authorization: Bearer YOUR_RESEND_API_KEY |
| Header | Content-Type: application/json |
Body:
{
"from": "YourApp <noreply@yourdomain.com>",
"to": "{{otp_result.email}}",
"subject": "Your OTP Code",
"html": "<h2>Hi {{otp_result.name}},</h2><p>Your one-time password is:</p><h1 style='letter-spacing:8px;font-size:36px;'>{{otp_result.otp_code}}</h1><p>This code expires in 5 minutes and can only be used once.</p>"
}
Step 6a.2 - Simple Output (Send success)
| Setting | Value |
|---|---|
| Status | 200 |
| Type | JSON |
| Output | {{otp_result.response}} |
Step 6b - Write Data (INSERT otp_codes) - Enable/Disable path
This runs for enable/disable actions (insert_user route). Same INSERT as 6a but with used set to 1 since these are audit records, not verifiable codes.
| Column | Value |
|---|---|
{{otp_result.email}} |
|
| code | {{otp_result.otp_code}} |
| action | {{otp_result.otp_action}} |
| created_at | {{otp_result.created_at}} |
| used | {{otp_result.otp_used}} |
| attempts | 0 |
Step 6b.1 - Write Data (UPDATE users)
Update the user's otp_enabled flag.
| Setting | Value |
|---|---|
| Source | users (Update) |
| Where | email equals {{otp_result.email}} |
| Column | Value |
|---|---|
| otp_enabled | {{otp_result.otp_enabled}} |
Step 6b.2 - Simple Output (Enable/Disable success)
| Setting | Value |
|---|---|
| Status | 200 |
| Type | JSON |
| Output | {{otp_result.response}} |
Step 6c - Write Data (UPDATE otp_codes) - Verify path
This runs for the verify action (update_otp route). Updates the existing code row to mark it as used or increment the attempt counter.
| Setting | Value |
|---|---|
| Source | otp_codes (Update) |
Where (to find the exact row):
| Column | Condition | Value |
|---|---|---|
| equals | {{otp_result.email}} |
|
| code | equals | {{otp_result.otp_code}} |
| created_at | equals | {{otp_result.created_at}} |
Columns to update:
| Column | Value |
|---|---|
| used | {{otp_result.otp_used}} |
| attempts | {{otp_result.otp_attempts}} |
Step 6c.1 - Simple Output (Verify result)
| Setting | Value |
|---|---|
| Status | 200 |
| Type | JSON |
| Output | {{otp_result.response}} |
Step 6d - Simple Output (No write / Error)
The ELSE branch - handles all error cases where no database write is needed.
| Setting | Value |
|---|---|
| Status | 200 |
| Type | JSON |
| Output | {{otp_result.response}} |
Security Features
| Feature | How it works |
|---|---|
| Response isolation | response object separates client-facing data from internal routing - OTP codes never reach the network |
| Code expiration | 5-minute window, checked via created_at timestamp |
| Single-use codes | used flag set to 1 after successful verification |
| Brute-force protection | Max 5 attempts per code, tracked in attempts column |
| Separate storage | Codes live in otp_codes table, not on the user row |
| Audit trail | Every code generation creates a new row with a timestamp |
| Single routing Condition | One multi-branch Condition with route string instead of 4 boolean flag Conditions |
Testing
Using curl
Test 1 - Enable OTP:
curl -X POST https://workflow.ubex.ai/api/v1/YOUR_ID/otp \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "action": "enable", "code": ""}'
Expected: { "success": true, "message": "OTP has been enabled." }
Test 2 - Disable OTP:
curl -X POST https://workflow.ubex.ai/api/v1/YOUR_ID/otp \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "action": "disable", "code": ""}'
Expected: { "success": true, "message": "OTP has been disabled." }
Test 3 - Send new code:
curl -X POST https://workflow.ubex.ai/api/v1/YOUR_ID/otp \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "action": "send", "code": ""}'
Expected: { "success": true, "message": "OTP code sent to your email." }
The OTP code is NOT in the response. Check your email.
Test 4 - Verify with wrong code:
curl -X POST https://workflow.ubex.ai/api/v1/YOUR_ID/otp \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "action": "verify", "code": "000000"}'
Expected: { "success": false, "error": "Invalid OTP code" }
Test 5 - Verify with correct code:
curl -X POST https://workflow.ubex.ai/api/v1/YOUR_ID/otp \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com", "action": "verify", "code": "THE_REAL_CODE"}'
Expected: { "success": true, "message": "OTP verified successfully" }
Edge cases
| Test | Expected response |
|---|---|
| Enable when already enabled | "OTP is already enabled" |
| Disable when not enabled | "OTP is not enabled" |
| Send when OTP not enabled | "OTP is not enabled" |
| Verify when OTP not enabled | "OTP is not enabled" |
| Invalid action value | "Invalid action" |
| Non-existent email | "User not found" |
| Reuse same code after verify | "No active OTP code found" |
| 5+ wrong attempts | "Too many attempts. Request a new code." |
| Code older than 5 minutes | "OTP code has expired. Request a new one." |
Integration with Login
To make OTP work with your login workflow, you need two changes. See the User Login tutorial for the full updated code.
If you also have TOTP (Google Authenticator) enabled, TOTP takes priority over email OTP during login. See the Google Authenticator (TOTP) tutorial for details on how both methods work together.
Changes to the login workflow
- Query Data (Step 4) - Add
otp_enabledto the columns list - Code Node (Step 5) - After password and email verification pass, add:
if (user.otp_enabled === 'yes') {
return {
success: false,
otp_required: true,
message: 'OTP verification required'
};
}
- Condition (Step 6) - Both IF and ELSE branches should return status 200
Frontend flow
- User enters email + password, clicks "Sign in"
- Login returns
{ success: false, otp_required: true } - Frontend shows the OTP code input
- Frontend calls
/otpwithaction: "send"to trigger the email - User enters the 6-digit code from their email
- Frontend calls
/otpwithaction: "verify"with the code - On success → user is authenticated
Security Checklist
| Control | Status |
|---|---|
| Email validation (Data Validator) | ✅ |
| Response isolation (OTP codes never in API response) | ✅ |
| Code expiration (5 minutes) | ✅ |
| Single-use codes (used flag) | ✅ |
| Brute-force protection (5 attempts per code) | ✅ |
| Separate storage (otp_codes table, not on user row) | ✅ |
| Audit trail (new row per code generation) | ✅ |
| Action validation (whitelist of allowed actions) | ✅ |
| User existence check before any action | ✅ |
| State checks (OTP enabled/disabled before action) | ✅ |
| Single routing Condition (no boolean flag chains) | ✅ |
| No real secrets in tutorial JSON | ✅ |