AdvancedAuthentication

OTP Authentication

One-time password authentication for enhanced security via email.

Click to expand
Flow Start/api/v1/NTCYr/otpData ValidatordataValidatorTRUEFALSESimple OutputNo such user existsQuery Datauser_dataQuery Data{{latest_otp}}Codeotp_resultConditionIFELSE IFELSE IFELSEWrite Datawrite_sendWrite Datawrite_enableWrite Datawrite_dataSimple Output{{otp_result.response}}HTTP RequesthttpRequestWrite Datawrite_userSimple Output{{otp_result.response}}Simple Output{{otp_result.response}}Simple Output{{otp_result.response}}

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 code field is only needed for the verify action. For enable and send, 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_enabled flag 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 route string 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
email 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 = 0 to only get active (unused) codes. Enable/disable rows are inserted with used = 1 so 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 response object that contains ONLY what the client should see. Internal fields like otp_code, email, 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
email {{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
email {{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
email 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

  1. Query Data (Step 4) - Add otp_enabled to the columns list
  2. 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'
  };
}
  1. Condition (Step 6) - Both IF and ELSE branches should return status 200

Frontend flow

  1. User enters email + password, clicks "Sign in"
  2. Login returns { success: false, otp_required: true }
  3. Frontend shows the OTP code input
  4. Frontend calls /otp with action: "send" to trigger the email
  5. User enters the 6-digit code from their email
  6. Frontend calls /otp with action: "verify" with the code
  7. 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